讣丢 发表于 2025-10-20 00:50:13

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

项目背景

最近我们团队自研了一个基于 React 的 H5 前端框架,领导让我来负责编写框架的使用文档。我选择了 dumi 来搭建文档站点,大部分内容都是手动写 Markdown 来介绍各种功能,包括:初始化、目录结构、生命周期、状态管理、插件系统 等等。
框架里有个很重要的子包,主要负责多个 App 的桥接能力,深度集成了各端环境的监测和桥接逻辑。这个子包对外提供了一个 App 实例对象,里面封装了很多原生能力,比如: 设置导航栏、录音、保存图片到相册 等
这些 API 代码格式都比较统一,领导希望避免在框架源码和文档里重复定义相同的接口,最好能直接从源代码自动生成文档内容。需要提取的信息包括:API支持的App版本、功能描述、开发状态、使用方式,如果是函数的话还要有参数说明和返回值说明。
我的解决方案

经过一番思考,我想到了一个方案:
核心思路:在不改动源代码逻辑的前提下,通过增加注释信息来补充文档需要的元数据
具体实现路径:

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

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

[*]@appVersion —— 支持该API的App版本
[*]@description —— API的功能描述
[*]@apiType —— API类型,默认是函数,可选property(属性)和function(函数)
[*]@usage —— 使用示例
[*]@param —— 函数参数说明(只有函数类型需要)
[*]@returns —— 函数返回值说明(只有函数类型需要)
[*]@status —— 发布状态
在实际代码中这样使用,完全不会影响原来的业务逻辑:
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;
        },
}解析脚本

接下来要写一个解析脚本,把注释内容提取成键值对格式,主要用正则表达式来解析注释:
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;
const parentName = match;
const childName = match;
const description = match.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) {
      merged = { ...item };
      } else {
      // 合并子元素
      if (!merged.children) merged.children = [];
      merged.children.push(...item.children);
      }
    } else {
      // 普通参数
      if (!merged) {
      merged = 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;
      currentContent = tagMatch;
    } 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.trim();

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

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

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

      // 查找注释后面的代码行
      for (let k = j + 1; k < lines.length; k++) {
          const codeLine = lines.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;
            } else {
                // 如果没有匹配到名称,尝试其他模式
                const funcMatch = codeLine.match(/^(?:async\s+)?(\w+)\s*\(/);
                if (funcMatch) {
                  name = funcMatch;
                }
            }

            if (name) {
                if (itemType === 'property') {
                  annotations.properties = annotation;
                } else if (itemType === 'method') {
                  annotations.methods = 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,
          ...annotations
      };
      }
    }
});

return allAnnotations;
}

module.exports = {
parseJSDocAnnotation,
extractAnnotationsFromSource,
extractAnnotationsFromFile,
extractAllAnnotations
};集成到构建流程

然后创建一个脚本,指定要解析的源文件,把生成的 JSON 文件 输出到 build 目录里:
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 的时候自动运行解析脚本:
{
    "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 文档里,把解析逻辑封装成组件:
---
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 来呈现代码片段会导致页面样式简陋、缺乏可读性。我们需要借助代码高亮工具来提升展示效果,同时添加便捷的复制功能,让开发者能够轻松复用示例代码。
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 = 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 (          {/* 语言标签 */}            {language}                  {copied ? '✅ 已复制' : '
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

万俟谷雪 发表于 2025-10-20 05:36:34

前排留名,哈哈哈

貊淀 发表于 2025-11-20 07:53:21

谢谢楼主提供!

东郭欣然 发表于 前天 19:39

这个有用。
页: [1]
查看完整版本: 前端框架文档新思路:基于源码解析的自动化方案