项目背景
最近我们团队自研了一个基于 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 ------ 发布状态
在实际代码中这样使用,完全不会影响原来的业务逻辑:
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 探索工具,最终呈现效果如下:
通过这套自动化文档方案 ,我们实现了代码和文档的实时同步 ,大大减少了维护成本,同时给开发者提供了出色的使用体验。现在开发同学只需要在代码里写好注释,文档就会自动更新,再也不用担心文档落后于代码了。
如果你对前端工程化有兴趣,或者想了解更多前端相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享~