動態

詳情 返回 返回

前端框架文檔新思路:基於源碼解析的自動化方案 - 動態 詳情

項目背景

最近我們團隊自研了一個基於 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 —— 發佈狀態

在實際代碼中這樣使用,完全不會影響原來的業務邏輯:

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 來呈現代碼片段會導致頁面樣式簡陋、缺乏可讀性。我們需要藉助代碼高亮工具來提升展示效果,同時添加便捷的複製功能,讓開發者能夠輕鬆複用示例代碼。

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 內置的錨點導航功能。這就需要我們自主實現一套導航系統,並確保其在不同屏幕尺寸下都能保持良好的可用性,避免出現佈局錯亂的問題。

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 探索工具,最終呈現效果如下:

通過這套自動化文檔方案,我們實現了代碼和文檔的實時同步,大大減少了維護成本,同時給開發者提供了出色的使用體驗。現在開發同學只需要在代碼裏寫好註釋,文檔就會自動更新,再也不用擔心文檔落後於代碼了

如果你對前端工程化有興趣,或者想了解更多前端相關的內容,歡迎查看我的其他文章,這些內容將持續更新,希望能給你帶來更多的靈感和技術分享~

user avatar u_9849794 頭像 zhuifengdekukafei 頭像 pulsgarney 頭像 kayo5994 頭像 pinmingxueqianduandelaji 頭像 xiange 頭像 luchanan 頭像 xiaoniuhululu 頭像 zxbing0066 頭像 laoliangfe 頭像
點贊 10 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.