项目背景
最近我们团队自研了一个基于 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[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 目录里:- 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 来呈现代码片段会导致页面样式简陋、缺乏可读性。我们需要借助代码高亮工具来提升展示效果,同时添加便捷的复制功能,让开发者能够轻松复用示例代码。
[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 ? '✅ 已复制' : '
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |