Stories

Detail Return Return

提升開發體驗:基於 JSDoc 的 React 項目自動代碼提示方案詳解 - Stories Detail

需求背景

主管和其他同事基於公司的業務特點,開發了一套自研前端框架。技術選型是 React + JavaScript 的組合,上線後表現還不錯。現在他們想把這個組件庫推廣到其他團隊使用,所以讓我琢磨一下:怎麼能讓使用者用得更順手一點?尤其是能不能在寫代碼的時候有自動提示?

我調研了一下市面上常見的幾種方案,大致有以下幾類:

  • 把整個項目從 JavaScript 重構為 TypeScript,這樣就能通過 .ts 或 .tsx 文件自動生成 .d.ts 類型聲明文件;
  • 不動源碼,在外面單獨為每個導出的組件手動寫 .d.ts 文件;
  • 使用 TypeScript 編譯器解析 JavaScript 文件,直接生成 .d.ts 文件;對於那些識別不全的部分,再通過 JSDoc 註釋來輔助生成更準確的類型信息。

主管的意思是,希望儘可能少投入人力,因為框架已經穩定運行了,不想為了一個“非剛需”的功能去大動干戈。所以最終我們選擇了第三種方案——它的最大優點就是:對源碼幾乎無侵入,改動小,成本低,見效快!

不過它也不是十全十美。比如 TypeScript 自動生成的 .d.ts 文件中,很多函數參數或對象屬性都會被推斷成 any,即使配合 JSDoc 使用,也並不是所有組件都能有完整的類型提示。有些時候你還是得手動點進 .d.ts 文件裏看定義。

但總的來説,瑕不掩瑜。畢竟現在的目標是高效產出,不是追求完美主義。

項目結構説明

我們的框架是一個典型的多包項目,主要由兩個核心目錄組成:packagescomponents

packages 目錄下包含了四個子包:

  • cli:負責創建項目的命令行工具;
  • compatible:提供運行環境適配能力;
  • multipage:支持多頁面應用架構;
  • store:實現全局狀態管理。

components 目錄則主要是 UI 組件和交互能力的集合。

整個項目的目錄結構如下所示:

my-project/
├── components/
│   ├── src/
│   ├── build.config.mts
│   └── package.json
├── packages/
│   ├── cli/
│   ├── compatible/
│   │   ├── src/
│   │   ├── build.config.mts
│   │   └── package.json
│   ├── multipage/
│   └── store/
├── scripts/  
└── package.json

除了 cli 外,其餘子包都需要生成 .d.ts 文件。那我們的思路也很簡單:在根目錄安裝 TypeScript,然後給每個子包加上 tsconfig.json,最後寫個腳本批量處理這些子包,自動生成類型聲明文件。

安裝依賴

因為這些依賴項是多個子包共用的,所以我們統一安裝在根目錄下:

npm install --save-dev typescript jsdoc @types/react @types/react-dom

tsconfig.json 配置

每個子包都是獨立發佈的,所以每個子包都要有自己的 tsconfig.json 文件。
下面是通用配置,利用了 TypeScript 的 emitDeclarationOnly 功能,只用來生成 .d.ts 文件:

{
  "compilerOptions": {
    "module": "ESNext",
    "target": "ES5",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "skipLibCheck": true,
    "jsx": "preserve",

    "allowJs": true,
    "declaration": true,
    "emitDeclarationOnly": true,
    "outDir": "./types",

    "lib": ["es2017", "dom"]
  },
  "include": ["src/**/*.js", "src/**/*.jsx"],
  "exclude": ["node_modules"]
}

自動化腳本編寫

為了讓這個流程自動化,我們還需要一個腳本,遍歷 packagescomponents 文件夾,找到帶有 tsconfig.json 的子包,然後執行 TypeScript 命令生成 .d.ts 文件,並放在對應層級下的 types 文件夾中。

我們在 scripts 目錄下新建了一個 build-dts.js 腳本,內容如下:

const fs = require("fs");
const path = require("path");
const { execSync } = require("child_process");

const rootDir = __dirname + "/../";
const packagesDir = path.join(rootDir, "packages");
const componentsDir = path.join(rootDir, "components");

function buildDtsForPackage(pkgPath) {
  const tsconfigPath = path.join(pkgPath, "tsconfig.json");
  const typesOutDir = path.join(pkgPath, "types");

  if (!fs.existsSync(tsconfigPath)) {
    console.warn(
      `⚠️ No tsconfig.json found in ${pkgPath}, skipping .d.ts generation`
    );
    return;
  }

  // ---- 清空 types 文件夾 ----
  if (fs.existsSync(typesOutDir)) {
    console.log(`🧹 Clearing old types folder: ${typesOutDir}`);
    fs.rmSync(typesOutDir, { recursive: true, force: true });
  }

  try {
    // 執行 tsc 命令只生成類型聲明文件
    execSync(`tsc`, {
      cwd: pkgPath,
      stdio: "inherit",
    });

    console.log(`✅ .d.ts generated for ${pkgPath}`);
  } catch (e) {
    console.error(`❌ Failed to generate .d.ts for ${pkgPath}`);
  }
}

function processDirectory(targetDir) {
  if (!fs.existsSync(targetDir)) {
    console.warn(`⚠️ Directory not found: ${targetDir}, skipping.`);
    return;
  }

  const dirs = fs.readdirSync(targetDir);

  for (const dir of dirs) {
    const fullPath = path.join(targetDir, dir);
    const stat = fs.statSync(fullPath);
    if (stat.isDirectory()) {
      buildDtsForPackage(fullPath);
    }
  }
}

function processComponentsFlat(targetDir) {
  const tsconfigPath = path.join(targetDir, "tsconfig.json");

  if (!fs.existsSync(tsconfigPath)) {
    console.warn(`⚠️ components/tsconfig.json not found, skipping.`);
    return;
  }

  console.log(`✅ Building dts for flat components directory: ${targetDir}`);
  buildDtsForPackage(targetDir);
}

function main() {
  console.log("📦 Processing packages directory...");
  processDirectory(packagesDir);

  console.log("🧩 Processing components directory...");
  processComponentsFlat(componentsDir); // 處理扁平的 components 目錄
}

main();

接着在根目錄的 package.json 中加一條腳本指令:

{
  "scripts": {
     "build:dts": "node scripts/build-dts.js"
  }  
}

現在只需要在終端輸入:

npm run build:dts

就能一鍵為所有子包生成 .d.ts 文件啦!

實際效果展示

在沒有改一行源碼的情況下,TypeScript 自動生成的 .d.ts 文件長這樣:

雖然類型定義可能還不夠精準,但已經能幫開發者理解 API 的基本用法了。

但有些組件就比較複雜,TypeScript 推不出來詳細的結構,比如下面這個組件生成的 .d.ts 就顯得有點雞肋,根本看不出組件該如何使用:

這時候就可以藉助 JSDoc 來補充説明了。

用 JSDoc 補充類型信息

Page 組件為例,我們在源碼頂部加上 JSDoc 註釋,定義 props 結構和生命週期參數:

/**
 * @typedef {Object} PageProps
 * @property {React.ReactElement} children - 子元素
 * @property {(info: PageLifecycleInfo) => void} [onPageBeforeIn] - 頁面進入前觸發(路由切換時)
 * @property {(info: PageLifecycleInfo) => void} [onPageBeforeOut] - 頁面離開前觸發(路由切換時)
 * @property {(info: PageLifecycleInfo) => void} [onPageAfterIn] - 頁面進入後觸發(DOM掛載完成)
 * @property {(info: PageLifecycleInfo) => void} [onPageAfterOut] - 頁面離開後觸發(DOM卸載前)
 * @property {(info: PageLifecycleInfo) => boolean} [onPageBeforeUnmount] - 頁面卸載前觸發(可阻止卸載)
 * @property {(info: PageLifecycleInfo) => void} [onPageAfterUnmount] - 頁面卸載後觸發
 * @private
 */

/**
 * 頁面生命週期信息對象,提供頁面相關的上下文數據。
 *
 * @typedef {Object} PageLifecycleInfo
 * @property {string} path - 當前路由路徑
 * @property {Record<string, any>} params - 路由參數對象
 * @property {string} title - 頁面標題
 * @property {"PUSH" | "REPLACE" | "GO" | "BACK" | "FORWARD" | "LISTEN"} openMode - 路由打開方式
 * @property {boolean} hideNav - 是否隱藏導航欄
 * @property {boolean} micro - 是否作為微前端子頁面
 * @property {React.ReactElement} component - 頁面組件實例
 * @property {React.RefObject<HTMLElement>} pageRef - 頁面根元素的 Ref 對象
 * @property {PopStateEvent | HashChangeEvent} [event] - 原始路由事件對象
 */

/**
 * Page 是一個頁面級別的容器組件,用於管理頁面生命週期和渲染內容。
 * @type {React.FC<PageProps>}
 */

再執行

npm run build:dts

這會生成的 .d.ts 文件就能清楚地告訴開發者:這個組件到底接受哪些 props。

不過也要注意,並不是所有的 JSDoc 註釋生成類型聲明之後都能在編譯軟件上有代碼提示。有時候還是會遇到一些限制。

發佈到 npm

生成完 .d.ts 文件之後,還要確保它們能隨着組件一起發佈到 npm 上。這就需要在每個子包的 package.json 中添加如下配置:

{
   "files": [
    "cjs/**",
    "esm/**",
    "types/**"
  ],
  "types": "types/index.d.ts",
}

這樣用户在使用組件時,就能看到清晰的類型提示和跳轉定義了。

效果對比圖

沒有代碼提示時:

有了代碼提示之後:

是不是瞬間感覺開發起來輕鬆多了?通過這種“低成本+高收益”的方式,我們不僅提升了組件庫的易用性,也讓團隊內外的開發者們寫起代碼來更順手、更安心。

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

user avatar cicadasmile Avatar zhedan_sam_wan9 Avatar hyfhao Avatar qingzhan Avatar wanglinfeng Avatar bill_5bac65cc7963b Avatar
Favorites 6 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.