前端框架文档新思路:基于源码解析的自动化方案

项目背景

最近我们团队自研了一个基于 React 的 H5 前端框架 ,领导让我来负责编写框架的使用文档。我选择了 dumi 来搭建文档站点,大部分内容都是手动写 Markdown 来介绍各种功能,包括:初始化目录结构生命周期状态管理插件系统 等等。

框架里有个很重要的子包 ,主要负责多个 App 的桥接能力,深度集成了各端环境的监测和桥接逻辑。这个子包对外提供了一个 App 实例对象 ,里面封装了很多原生能力,比如: 设置导航栏录音保存图片到相册

这些 API 代码格式都比较统一,领导希望避免在框架源码和文档里重复定义相同的接口,最好能直接从源代码自动生成文档内容。需要提取的信息包括:API支持的App版本、功能描述、开发状态、使用方式,如果是函数的话还要有参数说明和返回值说明。

我的解决方案

经过一番思考,我想到了一个方案:

核心思路:在不改动源代码逻辑的前提下,通过增加注释信息来补充文档需要的元数据

具体实现路径:

  1. 定义一套规范的注释标签
  2. 编写解析脚本提取信息,生成 JSON 文件
  3. 在文档项目中读取 JSON,动态渲染成 API 文档

定义注释规范

我定义了一系列标准的注释标签

  • @appVersion ------ 支持该API的App版本
  • @description ------ API的功能描述
  • @apiType ------ API类型,默认是函数,可选property(属性)和function(函数)
  • @usage ------ 使用示例
  • @param ------ 函数参数说明(只有函数类型需要)
  • @returns ------ 函数返回值说明(只有函数类型需要)
  • @status ------ 发布状态

在实际代码中这样使用,完全不会影响原来的业务逻辑:

javascript 复制代码
const app = {
  /**
   * @appVersion 1.0.0
   * @description 判断设备类型
   * @apiType property
   * @usage app.platform // notInApp | ios | android | HarmonyOS 
   * @status 已上线 
   */
   platform: getPlatform(),
   
   /**
   * @appVersion 1.0.6 
   * @description 注册事件监听
   * @param {Object} options - 配置选项
   * @param {string} options.title - 事件名称
   * @param {Function} options.callback - 注册事件时的处理函数逻辑
   * @param {Function} options.onSuccess - 设置成功的回调函数(可选)
   * @param {Function} options.onFail - 设置失败的回调函数(可选)
   * @param {Function} options.onComplete - 无论成功失败都会执行的回调函数(可选)
   * @usage app.monitor({ eventName: 'onOpenPage', callback: (data)=>{ console.log('端上push消息', data ) } })
   * @returns {String} id - 绑定事件的id
   * @status 已上线
   */
	monitor: ({ onSuccess, onFail, onComplete, eventName = "", callback = () => { } }) => {
		let _id = uuid();
		// 业务代码省略
		return _id;
	},
}

解析脚本

接下来要写一个解析脚本 ,把注释内容提取成键值对格式,主要用正则表达式来解析注释:

javascript 复制代码
const fs = require('fs');
const path = require('path');

/**
 * 解析参数或返回值标签
 * @param {string} content - 标签内容
 * @param {string} type - 类型 ('param' 或 'returns')
 * @returns {Object} 解析后的参数或返回值对象
 */
function parseParamOrReturn(content, type = 'param') {
  const match = content.match(/{([^}]+)}\s+(\w+)(?:\.(\w+))?\s*-?\s*(.*)/);
  if (!match) return null;

  const paramType = match[1];
  const parentName = match[2];
  const childName = match[3];
  const description = match[4].trim();
  const isParam = type === 'param';

  if (childName) {
    // 嵌套参数或返回值 (options.title 或 data.result 格式)
    return {
      name: parentName,
      type: 'Object',
      description: isParam ? `${parentName} 配置对象` : `${parentName} 返回对象`,
      required: isParam ? true : undefined,
      children: [{
        name: childName,
        type: paramType,
        description: description,
        required: isParam ? (!paramType.includes('?') && !description.includes('可选')) : undefined
      }]
    };
  } else {
    // 普通参数或返回值
    return {
      name: parentName,
      type: paramType,
      description: description,
      required: isParam ? (!paramType.includes('?') && !description.includes('可选')) : undefined
    };
  }
}

/**
 * 合并嵌套对象
 * @param {Array} items - 参数或返回值数组
 * @returns {Array} 合并后的数组
 */
function mergeNestedItems(items) {
  const merged = {};

  items.forEach(item => {
    if (item.children) {
      // 嵌套对象
      if (!merged[item.name]) {
        merged[item.name] = { ...item };
      } else {
        // 合并子元素
        if (!merged[item.name].children) merged[item.name].children = [];
        merged[item.name].children.push(...item.children);
      }
    } else {
      // 普通参数
      if (!merged[item.name]) {
        merged[item.name] = item;
      }
    }
  });

  return Object.values(merged);
}

/**
 * 保存标签内容到注解对象
 */
function saveTagContent(annotation, tag, content) {
  // 确保 parameters 和 returns 数组存在
  if (!annotation.parameters) annotation.parameters = [];
  if (!annotation.returns) annotation.returns = [];

  switch (tag) {
    case 'appVersion':
      annotation.appVersion = content;
      break;
    case 'sxzVersion':
      annotation.sxzVersion = content;
      break;
    case 'mddVersion':
      annotation.mddVersion = content;
      break;
    case 'description':
      annotation.description = content;
      break;
    case 'status':
      annotation.status = content;
      break;
    case 'usage':
      annotation.usage = content.trim();
      break;
    case 'apiType':
      // 解析类型:property 或 method
      annotation.type = content.toLowerCase();
      break;
    case 'param':
      const param = parseParamOrReturn(content, 'param');
      if (param) {
        annotation.parameters.push(param);
        // 合并嵌套对象
        annotation.parameters = mergeNestedItems(annotation.parameters);
      }
      break;
    case 'returns':
      const returnItem = parseParamOrReturn(content, 'returns');
      if (returnItem) {
        annotation.returns.push(returnItem);
        // 合并嵌套对象
        annotation.returns = mergeNestedItems(annotation.returns);
      }
      break;
  }
}

/**
 * 解析 JSDoc 注释中的注解信息 - 逐行解析
 */
function parseJSDocAnnotation(comment) {
  if (!comment) return null;

  const annotation = {};

  // 按行分割注释
  const lines = comment.split('\n');
  
  let currentTag = '';
  let currentContent = '';

  for (const line of lines) {
    // 清理行内容,移除 * 和首尾空格,但保留内部的换行意图
    const cleanLine = line.replace(/^\s*\*\s*/, '').trimRight();
    
    // 跳过空行和注释开始结束标记
    if (!cleanLine || cleanLine === '/' || cleanLine === '*/') continue;
    
    // 检测标签开始
    const tagMatch = cleanLine.match(/^@(\w+)\s*(.*)$/);
    if (tagMatch) {
      // 保存前一个标签的内容
      if (currentTag) {
        saveTagContent(annotation, currentTag, currentContent);
      }
      
      // 开始新标签
      currentTag = tagMatch[1];
      currentContent = tagMatch[2];
    } else if (currentTag) {
      // 继续当前标签的内容,但保留换行
      // 对于 @usage 标签,我们保留原始格式
      if (currentTag === 'usage') {
        currentContent += '\n' + cleanLine;
      } else {
        currentContent += ' ' + cleanLine;
      }
    }
  }
  
  // 保存最后一个标签的内容
  if (currentTag) {
    saveTagContent(annotation, currentTag, currentContent);
  }

  // 确保 parameters 和 returns 数组存在(即使为空)
  if (!annotation.parameters) annotation.parameters = [];
  if (!annotation.returns) annotation.returns = [];

  return Object.keys(annotation).length > 0 ? annotation : null;
}

/**
 * 使用 @apiType 标签指定类型
 */
function extractAnnotationsFromSource(sourceCode) {
  const annotations = { properties: {}, methods: {} };

  // 使用更简单的逻辑:按行分析
  const lines = sourceCode.split('\n');

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i].trim();

    // 检测 JSDoc 注释开始
    if (line.startsWith('/**')) {
      let jsdocContent = line + '\n';
      let j = i + 1;

      // 收集完整的 JSDoc 注释
      while (j < lines.length && !lines[j].trim().startsWith('*/')) {
        jsdocContent += lines[j] + '\n';
        j++;
      }

      if (j < lines.length) {
        jsdocContent += lines[j] + '\n'; // 包含结束的 */

        // 查找注释后面的代码行
        for (let k = j + 1; k < lines.length; k++) {
          const codeLine = lines[k].trim();
          if (codeLine && !codeLine.startsWith('//') && !codeLine.startsWith('/*')) {
            // 解析注解
            const annotation = parseJSDocAnnotation(jsdocContent);
            if (annotation) {
              // 从注解中获取类型(property 或 method)
              let itemType = annotation.type;
              let name = null;

              // 如果没有明确指定类型,默认设为 method
              if (!itemType) {
                itemType = 'method';
              }

              // 提取名称
              const nameMatch = codeLine.match(/^(\w+)\s*[:=]/);
              if (nameMatch) {
                name = nameMatch[1];
              } else {
                // 如果没有匹配到名称,尝试其他模式
                const funcMatch = codeLine.match(/^(?:async\s+)?(\w+)\s*\(/);
                if (funcMatch) {
                  name = funcMatch[1];
                }
              }

              if (name) {
                if (itemType === 'property') {
                  annotations.properties[name] = annotation;
                } else if (itemType === 'method') {
                  annotations.methods[name] = annotation;
                } else {
                  console.warn(`未知的类型: ${itemType},名称: ${name}`);
                }
              } else {
                console.warn(`无法提取名称: ${codeLine.substring(0, 50)}`);
              }
            }
            break;
          }
        }

        i = j; // 跳过已处理的行
      }
    }
  }

  return annotations;
}

/**
 * 从文件提取注解
 */
function extractAnnotationsFromFile(filePath) {
  if (!fs.existsSync(filePath)) {
    console.error('文件不存在:', filePath);
    return { properties: {}, methods: {} };
  }

  const sourceCode = fs.readFileSync(filePath, 'utf-8');
  return extractAnnotationsFromSource(sourceCode);
}

/**
 * 提取所有文件的注解
 */
function extractAllAnnotations(filePaths) {
  const allAnnotations = {};

  filePaths.forEach(filePath => {
    if (fs.existsSync(filePath)) {
      const fileName = path.basename(filePath, '.js');
      console.log(`\n=== 处理文件: ${fileName} ===`);

      const annotations = extractAnnotationsFromFile(filePath);

      if (Object.keys(annotations.properties).length > 0 ||
        Object.keys(annotations.methods).length > 0) {
        allAnnotations[fileName] = {
          fileName,
          ...annotations
        };
      }
    }
  });

  return allAnnotations;
}

module.exports = {
  parseJSDocAnnotation,
  extractAnnotationsFromSource,
  extractAnnotationsFromFile,
  extractAllAnnotations
};

集成到构建流程

然后创建一个脚本,指定要解析的源文件,把生成的 JSON 文件 输出到 build 目录里:

javascript 复制代码
const { extractAllAnnotations } = require('./jsdoc-annotations');
const fs = require('fs');
const path = require('path');

/**
 * 主函数 - 提取注解并生成JSON文件
 */
function main() {
  const filePaths = [
    path.join(process.cwd(), './app.js'),
    path.join(process.cwd(), './xxx.js'),
    path.join(process.cwd(), './yyy.js'),
  ].filter(fs.existsSync);

  if (filePaths.length === 0) {
    console.error('未找到任何文件,请检查文件路径');
    return;
  }

  const annotations = extractAllAnnotations(filePaths);
  const outputPath = path.join(process.cwd(), './build/api-annotations.json');

  // 保存为JSON文件
  fs.writeFileSync(outputPath, JSON.stringify(annotations, null, 2));
}

main();

package.json 里定义构建指令,确保 build 的时候自动运行解析脚本

json 复制代码
{
    "scripts": {
      "build:annotations": "node scripts/extract-annotations.js",
      "build": "(cd template/main-app && npm run build) && npm run build:annotations"
  },
}

执行效果:运行 npm run build 后,会生成结构化的 JSON 文件:

在文档中展示

框架项目和文档项目是分开的,把 JSON 文件生成到 build 文件夹,上传到服务器后提供固定访问路径。

有了结构化的 JSON 数据,生成文档页面就很简单了。在 dumi 文档里,把解析逻辑封装成组件:

markdown 复制代码
---
title: xxx
order: 2
---

```jsx
/**
 * inline: true
 */
import JsonToApi from '/components/jsonToApi/index.jsx';

export default () => <JsonToApi type="app" title="xxx" desc="App原生 api 对象"/>;
```

渲染效果如图所示

在将 JSON 数据解析并渲染到页面的过程中,有两个关键的技术点需要特别关注:

要点一:优雅的代码展示体验

直接使用 dangerouslySetInnerHTML 来呈现代码片段会导致页面样式简陋、缺乏可读性。我们需要借助代码高亮工具来提升展示效果,同时添加便捷的复制功能,让开发者能够轻松复用示例代码。

javascript 复制代码
import React from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';

const CodeBlock = ({
  children,
  language = 'javascript',
  showLineNumbers = true,
  highlightLines = []
}) => {

  const [copied, setCopied] = React.useState(false);

  // 可靠的复制方法
  const copyToClipboard = async (text) => {
    try {
      // 方法1: 使用现代 Clipboard API
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(text);
        return true;
      } else {
        // 方法2: 使用传统的 document.execCommand(兼容性更好)
        const textArea = document.createElement('textarea');
        textArea.value = text;
        textArea.style.position = 'fixed';
        textArea.style.left = '-999999px';
        textArea.style.top = '-999999px';
        document.body.appendChild(textArea);
        textArea.focus();
        textArea.select();

        const success = document.execCommand('copy');
        document.body.removeChild(textArea);
        return success;
      }
    } catch (err) {
      console.error('复制失败:', err);
      // 方法3: 备用方案 - 提示用户手动复制
      prompt('请手动复制以下代码:', text);
      return false;
    }
  };

  const handleCopy = async () => {
    const text = String(children).replace(/\n$/, '');
    const success = await copyToClipboard(text);

    if (success) {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    }
  };

  return (
    <div className="code-container" style={{ position: 'relative', margin: '20px 0' }}>
      {/* 语言标签 */}
      <div style={{
        background: '#1e1e1e',
        color: '#fff',
        padding: '8px 16px',
        borderTopLeftRadius: '8px',
        borderTopRightRadius: '8px',
        borderBottom: '1px solid #333',
        fontSize: '12px',
        fontFamily: 'monospace',
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center'
      }}>
        <span>{language}</span>
        <button
          onClick={handleCopy}
          style={{
            position: 'absolute',
            top: '8px',
            right: '8px',
            background: copied ? '#52c41a' : '#333',
            color: 'white',
            border: 'none',
            padding: '4px 8px',
            borderRadius: '4px',
            fontSize: '12px',
            cursor: 'pointer',
            zIndex: 10,
            transition: 'all 0.3s'
          }}
        >
          {copied ? '✅ 已复制' : '📋 复制'}
        </button>
      </div>

      {/* 代码区域 */}
      <SyntaxHighlighter
        language={language}
        style={vscDarkPlus}
        showLineNumbers={showLineNumbers}
        wrapLines={true}
        lineProps={(lineNumber) => ({
          style: {
            backgroundColor: highlightLines.includes(lineNumber)
              ? 'rgba(255,255,255,0.1)'
              : 'transparent',
            padding: '2px 0'
          }
        })}
        customStyle={{
          margin: 0,
          borderTopLeftRadius: 0,
          borderTopRightRadius: 0,
          borderBottomLeftRadius: '8px',
          borderBottomRightRadius: '8px',
          padding: '16px',
          fontSize: '14px',
          lineHeight: '1.5',
          background: '#1e1e1e',
          border: 'none',
          borderTop: 'none'
        }}
        codeTagProps={{
          style: {
            fontFamily: '"Fira Code", "Monaco", "Consolas", "Courier New", monospace',
            fontSize: '14px'
          }
        }}
      >
        {String(children).replace(/\n$/, '')}
      </SyntaxHighlighter>
    </div>
  );
};

export default CodeBlock;

要点二:锚点导航方案

由于我们是通过组件方式动态渲染内容,无法直接使用 dumi 内置的锚点导航功能。这就需要我们自主实现一套导航系统,并确保其在不同屏幕尺寸下都能保持良好的可用性,避免出现布局错乱的问题。

javascript 复制代码
import React, { useEffect, useRef } from 'react';
import { Anchor } from 'antd';
export default function readJson(props){

  const anchorRef = useRef(null);
  const anchorWrapperRef = useRef(null);
  
  useEffect(() => {
  // 使用更长的延迟确保 DOM 完全渲染
  const timer = setTimeout(() => {
    const contentElement = document.querySelector('.dumi-default-content');
    const anchorElement = anchorRef.current;
    
    if (!contentElement || !anchorElement) return;

    // 创建锚点容器
    const anchorWrapper = document.createElement('div');
    anchorWrapper.className = 'custom-anchor-wrapper';
    Object.assign(anchorWrapper.style, {
      position: 'sticky',
      top: '106px',
      width: '184px',
      marginInlineStart: '24px',
      maxHeight: '80vh',
      overflow: 'auto',
      overscrollBehavior: 'contain'
    });

    // 插入到内容元素后面
    if (contentElement.nextSibling) {
      contentElement.parentNode.insertBefore(anchorWrapper, contentElement.nextSibling);
    } else {
      contentElement.parentNode.appendChild(anchorWrapper);
    }

    // 移动锚点
    anchorWrapper.appendChild(anchorElement);
    
    // 记录锚点容器,用于清理
    anchorWrapperRef.current = anchorWrapper;
  }, 500); // 500ms 延迟,确保 DOM 完全渲染

  return <div ref={anchorRef}>
      <Anchor
        targetOffset={80}
        items={[
          {
            key: 'properties',
            href: '#properties',
            title: '属性',
            children: Object.keys(properties).map(item => ({
              key: item,
              href: `#${item}`,
              title: item
            }))
          },
          {
            key: 'methods',
            href: '#methods',
            title: '方法',
            children: Object.keys(methods).map(item => ({
              key: item,
              href: `#${item}`,
              title: item
            }))
          }
        ]}
      />
    </div>
}

当然,在页面功能上我们还可以进一步丰富,比如增加实用的筛选 功能。比如快速查看特定 App 版本 支持的 API、筛选"已上线"、"开发中"或"已废弃"的接口,这些筛选能力让文档不再是静态的参考手册,而变成了一个API 探索工具,最终呈现效果如下:

通过这套自动化文档方案 ,我们实现了代码和文档的实时同步 ,大大减少了维护成本,同时给开发者提供了出色的使用体验。现在开发同学只需要在代码里写好注释,文档就会自动更新,再也不用担心文档落后于代码了

如果你对前端工程化有兴趣,或者想了解更多前端相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享~