找回密码
 立即注册
首页 业界区 业界 前端框架文档新思路:基于源码解析的自动化方案 ...

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

讣丢 8 小时前
项目背景

最近我们团队自研了一个基于 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 —— 发布状态
在实际代码中这样使用,完全不会影响原来的业务逻辑:
  1. const app = {
  2.   /**
  3.    * @appVersion 1.0.0
  4.    * @description 判断设备类型
  5.    * @apiType property
  6.    * @usage app.platform // notInApp | ios | android | HarmonyOS
  7.    * @status 已上线
  8.    */
  9.    platform: getPlatform(),
  10.    
  11.    /**
  12.    * @appVersion 1.0.6
  13.    * @description 注册事件监听
  14.    * @param {Object} options - 配置选项
  15.    * @param {string} options.title - 事件名称
  16.    * @param {Function} options.callback - 注册事件时的处理函数逻辑
  17.    * @param {Function} options.onSuccess - 设置成功的回调函数(可选)
  18.    * @param {Function} options.onFail - 设置失败的回调函数(可选)
  19.    * @param {Function} options.onComplete - 无论成功失败都会执行的回调函数(可选)
  20.    * @usage app.monitor({ eventName: 'onOpenPage', callback: (data)=>{ console.log('端上push消息', data ) } })
  21.    * @returns {String} id - 绑定事件的id
  22.    * @status 已上线
  23.    */
  24.         monitor: ({ onSuccess, onFail, onComplete, eventName = "", callback = () => { } }) => {
  25.                 let _id = uuid();
  26.                 // 业务代码省略
  27.                 return _id;
  28.         },
  29. }
复制代码
解析脚本

接下来要写一个解析脚本,把注释内容提取成键值对格式,主要用正则表达式来解析注释:
  1. const fs = require('fs');
  2. const path = require('path');
  3. /**
  4. * 解析参数或返回值标签
  5. * @param {string} content - 标签内容
  6. * @param {string} type - 类型 ('param' 或 'returns')
  7. * @returns {Object} 解析后的参数或返回值对象
  8. */
  9. function parseParamOrReturn(content, type = 'param') {
  10.   const match = content.match(/{([^}]+)}\s+(\w+)(?:\.(\w+))?\s*-?\s*(.*)/);
  11.   if (!match) return null;
  12.   const paramType = match[1];
  13.   const parentName = match[2];
  14.   const childName = match[3];
  15.   const description = match[4].trim();
  16.   const isParam = type === 'param';
  17.   if (childName) {
  18.     // 嵌套参数或返回值 (options.title 或 data.result 格式)
  19.     return {
  20.       name: parentName,
  21.       type: 'Object',
  22.       description: isParam ? `${parentName} 配置对象` : `${parentName} 返回对象`,
  23.       required: isParam ? true : undefined,
  24.       children: [{
  25.         name: childName,
  26.         type: paramType,
  27.         description: description,
  28.         required: isParam ? (!paramType.includes('?') && !description.includes('可选')) : undefined
  29.       }]
  30.     };
  31.   } else {
  32.     // 普通参数或返回值
  33.     return {
  34.       name: parentName,
  35.       type: paramType,
  36.       description: description,
  37.       required: isParam ? (!paramType.includes('?') && !description.includes('可选')) : undefined
  38.     };
  39.   }
  40. }
  41. /**
  42. * 合并嵌套对象
  43. * @param {Array} items - 参数或返回值数组
  44. * @returns {Array} 合并后的数组
  45. */
  46. function mergeNestedItems(items) {
  47.   const merged = {};
  48.   items.forEach(item => {
  49.     if (item.children) {
  50.       // 嵌套对象
  51.       if (!merged[item.name]) {
  52.         merged[item.name] = { ...item };
  53.       } else {
  54.         // 合并子元素
  55.         if (!merged[item.name].children) merged[item.name].children = [];
  56.         merged[item.name].children.push(...item.children);
  57.       }
  58.     } else {
  59.       // 普通参数
  60.       if (!merged[item.name]) {
  61.         merged[item.name] = item;
  62.       }
  63.     }
  64.   });
  65.   return Object.values(merged);
  66. }
  67. /**
  68. * 保存标签内容到注解对象
  69. */
  70. function saveTagContent(annotation, tag, content) {
  71.   // 确保 parameters 和 returns 数组存在
  72.   if (!annotation.parameters) annotation.parameters = [];
  73.   if (!annotation.returns) annotation.returns = [];
  74.   switch (tag) {
  75.     case 'appVersion':
  76.       annotation.appVersion = content;
  77.       break;
  78.     case 'sxzVersion':
  79.       annotation.sxzVersion = content;
  80.       break;
  81.     case 'mddVersion':
  82.       annotation.mddVersion = content;
  83.       break;
  84.     case 'description':
  85.       annotation.description = content;
  86.       break;
  87.     case 'status':
  88.       annotation.status = content;
  89.       break;
  90.     case 'usage':
  91.       annotation.usage = content.trim();
  92.       break;
  93.     case 'apiType':
  94.       // 解析类型:property 或 method
  95.       annotation.type = content.toLowerCase();
  96.       break;
  97.     case 'param':
  98.       const param = parseParamOrReturn(content, 'param');
  99.       if (param) {
  100.         annotation.parameters.push(param);
  101.         // 合并嵌套对象
  102.         annotation.parameters = mergeNestedItems(annotation.parameters);
  103.       }
  104.       break;
  105.     case 'returns':
  106.       const returnItem = parseParamOrReturn(content, 'returns');
  107.       if (returnItem) {
  108.         annotation.returns.push(returnItem);
  109.         // 合并嵌套对象
  110.         annotation.returns = mergeNestedItems(annotation.returns);
  111.       }
  112.       break;
  113.   }
  114. }
  115. /**
  116. * 解析 JSDoc 注释中的注解信息 - 逐行解析
  117. */
  118. function parseJSDocAnnotation(comment) {
  119.   if (!comment) return null;
  120.   const annotation = {};
  121.   // 按行分割注释
  122.   const lines = comment.split('\n');
  123.   
  124.   let currentTag = '';
  125.   let currentContent = '';
  126.   for (const line of lines) {
  127.     // 清理行内容,移除 * 和首尾空格,但保留内部的换行意图
  128.     const cleanLine = line.replace(/^\s*\*\s*/, '').trimRight();
  129.    
  130.     // 跳过空行和注释开始结束标记
  131.     if (!cleanLine || cleanLine === '/' || cleanLine === '*/') continue;
  132.    
  133.     // 检测标签开始
  134.     const tagMatch = cleanLine.match(/^@(\w+)\s*(.*)$/);
  135.     if (tagMatch) {
  136.       // 保存前一个标签的内容
  137.       if (currentTag) {
  138.         saveTagContent(annotation, currentTag, currentContent);
  139.       }
  140.       
  141.       // 开始新标签
  142.       currentTag = tagMatch[1];
  143.       currentContent = tagMatch[2];
  144.     } else if (currentTag) {
  145.       // 继续当前标签的内容,但保留换行
  146.       // 对于 @usage 标签,我们保留原始格式
  147.       if (currentTag === 'usage') {
  148.         currentContent += '\n' + cleanLine;
  149.       } else {
  150.         currentContent += ' ' + cleanLine;
  151.       }
  152.     }
  153.   }
  154.   
  155.   // 保存最后一个标签的内容
  156.   if (currentTag) {
  157.     saveTagContent(annotation, currentTag, currentContent);
  158.   }
  159.   // 确保 parameters 和 returns 数组存在(即使为空)
  160.   if (!annotation.parameters) annotation.parameters = [];
  161.   if (!annotation.returns) annotation.returns = [];
  162.   return Object.keys(annotation).length > 0 ? annotation : null;
  163. }
  164. /**
  165. * 使用 @apiType 标签指定类型
  166. */
  167. function extractAnnotationsFromSource(sourceCode) {
  168.   const annotations = { properties: {}, methods: {} };
  169.   // 使用更简单的逻辑:按行分析
  170.   const lines = sourceCode.split('\n');
  171.   for (let i = 0; i < lines.length; i++) {
  172.     const line = lines[i].trim();
  173.     // 检测 JSDoc 注释开始
  174.     if (line.startsWith('/**')) {
  175.       let jsdocContent = line + '\n';
  176.       let j = i + 1;
  177.       // 收集完整的 JSDoc 注释
  178.       while (j < lines.length && !lines[j].trim().startsWith('*/')) {
  179.         jsdocContent += lines[j] + '\n';
  180.         j++;
  181.       }
  182.       if (j < lines.length) {
  183.         jsdocContent += lines[j] + '\n'; // 包含结束的 */
  184.         // 查找注释后面的代码行
  185.         for (let k = j + 1; k < lines.length; k++) {
  186.           const codeLine = lines[k].trim();
  187.           if (codeLine && !codeLine.startsWith('//') && !codeLine.startsWith('/*')) {
  188.             // 解析注解
  189.             const annotation = parseJSDocAnnotation(jsdocContent);
  190.             if (annotation) {
  191.               // 从注解中获取类型(property 或 method)
  192.               let itemType = annotation.type;
  193.               let name = null;
  194.               // 如果没有明确指定类型,默认设为 method
  195.               if (!itemType) {
  196.                 itemType = 'method';
  197.               }
  198.               // 提取名称
  199.               const nameMatch = codeLine.match(/^(\w+)\s*[:=]/);
  200.               if (nameMatch) {
  201.                 name = nameMatch[1];
  202.               } else {
  203.                 // 如果没有匹配到名称,尝试其他模式
  204.                 const funcMatch = codeLine.match(/^(?:async\s+)?(\w+)\s*\(/);
  205.                 if (funcMatch) {
  206.                   name = funcMatch[1];
  207.                 }
  208.               }
  209.               if (name) {
  210.                 if (itemType === 'property') {
  211.                   annotations.properties[name] = annotation;
  212.                 } else if (itemType === 'method') {
  213.                   annotations.methods[name] = annotation;
  214.                 } else {
  215.                   console.warn(`未知的类型: ${itemType},名称: ${name}`);
  216.                 }
  217.               } else {
  218.                 console.warn(`无法提取名称: ${codeLine.substring(0, 50)}`);
  219.               }
  220.             }
  221.             break;
  222.           }
  223.         }
  224.         i = j; // 跳过已处理的行
  225.       }
  226.     }
  227.   }
  228.   return annotations;
  229. }
  230. /**
  231. * 从文件提取注解
  232. */
  233. function extractAnnotationsFromFile(filePath) {
  234.   if (!fs.existsSync(filePath)) {
  235.     console.error('文件不存在:', filePath);
  236.     return { properties: {}, methods: {} };
  237.   }
  238.   const sourceCode = fs.readFileSync(filePath, 'utf-8');
  239.   return extractAnnotationsFromSource(sourceCode);
  240. }
  241. /**
  242. * 提取所有文件的注解
  243. */
  244. function extractAllAnnotations(filePaths) {
  245.   const allAnnotations = {};
  246.   filePaths.forEach(filePath => {
  247.     if (fs.existsSync(filePath)) {
  248.       const fileName = path.basename(filePath, '.js');
  249.       console.log(`\n=== 处理文件: ${fileName} ===`);
  250.       const annotations = extractAnnotationsFromFile(filePath);
  251.       if (Object.keys(annotations.properties).length > 0 ||
  252.         Object.keys(annotations.methods).length > 0) {
  253.         allAnnotations[fileName] = {
  254.           fileName,
  255.           ...annotations
  256.         };
  257.       }
  258.     }
  259.   });
  260.   return allAnnotations;
  261. }
  262. module.exports = {
  263.   parseJSDocAnnotation,
  264.   extractAnnotationsFromSource,
  265.   extractAnnotationsFromFile,
  266.   extractAllAnnotations
  267. };
复制代码
集成到构建流程

然后创建一个脚本,指定要解析的源文件,把生成的 JSON 文件 输出到 build 目录里:
  1. const { extractAllAnnotations } = require('./jsdoc-annotations');
  2. const fs = require('fs');
  3. const path = require('path');
  4. /**
  5. * 主函数 - 提取注解并生成JSON文件
  6. */
  7. function main() {
  8.   const filePaths = [
  9.     path.join(process.cwd(), './app.js'),
  10.     path.join(process.cwd(), './xxx.js'),
  11.     path.join(process.cwd(), './yyy.js'),
  12.   ].filter(fs.existsSync);
  13.   if (filePaths.length === 0) {
  14.     console.error('未找到任何文件,请检查文件路径');
  15.     return;
  16.   }
  17.   const annotations = extractAllAnnotations(filePaths);
  18.   const outputPath = path.join(process.cwd(), './build/api-annotations.json');
  19.   // 保存为JSON文件
  20.   fs.writeFileSync(outputPath, JSON.stringify(annotations, null, 2));
  21. }
  22. main();
复制代码
在 package.json 里定义构建指令,确保 build 的时候自动运行解析脚本
  1. {
  2.     "scripts": {
  3.       "build:annotations": "node scripts/extract-annotations.js",
  4.       "build": "(cd template/main-app && npm run build) && npm run build:annotations"
  5.   },
  6. }
复制代码
执行效果:运行 npm run build 后,会生成结构化的 JSON 文件:
1.webp

在文档中展示

框架项目和文档项目是分开的,把 JSON 文件生成到 build 文件夹,上传到服务器后提供固定访问路径。
有了结构化的 JSON 数据,生成文档页面就很简单了。在 dumi 文档里,把解析逻辑封装成组件:
  1. ---
  2. title: xxx
  3. order: 2
  4. ---
  5. ```jsx
  6. /**
  7. * inline: true
  8. */
  9. import JsonToApi from '/components/jsonToApi/index.jsx';
  10. export default () => <JsonToApi type="app" title="xxx" desc="App原生 api 对象"/>;
  11. ```
复制代码
渲染效果如图所示
2.webp

在将 JSON 数据解析并渲染到页面的过程中,有两个关键的技术点需要特别关注:
要点一:优雅的代码展示体验
直接使用 dangerouslySetInnerHTML 来呈现代码片段会导致页面样式简陋、缺乏可读性。我们需要借助代码高亮工具来提升展示效果,同时添加便捷的复制功能,让开发者能够轻松复用示例代码。
[code]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 (          {/* 语言标签 */}              {language}                  {copied ? '✅ 已复制' : '
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

4 小时前

举报

前排留名,哈哈哈
您需要登录后才可以回帖 登录 | 立即注册