需求背景
主管和其他同事基於公司的業務特點,開發了一套自研前端框架。技術選型是 React + JavaScript 的組合,上線後表現還不錯。現在他們想把這個組件庫推廣到其他團隊使用,所以讓我琢磨一下:怎麼能讓使用者用得更順手一點?尤其是能不能在寫代碼的時候有自動提示?
我調研了一下市面上常見的幾種方案,大致有以下幾類:
- 把整個項目從 JavaScript 重構為 TypeScript,這樣就能通過 .ts 或 .tsx 文件自動生成 .d.ts 類型聲明文件;
- 不動源碼,在外面單獨為每個導出的組件手動寫 .d.ts 文件;
- 使用 TypeScript 編譯器解析 JavaScript 文件,直接生成 .d.ts 文件;對於那些識別不全的部分,再通過 JSDoc 註釋來輔助生成更準確的類型信息。
主管的意思是,希望儘可能少投入人力,因為框架已經穩定運行了,不想為了一個“非剛需”的功能去大動干戈。所以最終我們選擇了第三種方案——它的最大優點就是:對源碼幾乎無侵入,改動小,成本低,見效快!
不過它也不是十全十美。比如 TypeScript 自動生成的 .d.ts 文件中,很多函數參數或對象屬性都會被推斷成 any,即使配合 JSDoc 使用,也並不是所有組件都能有完整的類型提示。有些時候你還是得手動點進 .d.ts 文件裏看定義。
但總的來説,瑕不掩瑜。畢竟現在的目標是高效產出,不是追求完美主義。
項目結構説明
我們的框架是一個典型的多包項目,主要由兩個核心目錄組成:packages 和 components。
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"]
}
自動化腳本編寫
為了讓這個流程自動化,我們還需要一個腳本,遍歷 packages 和 components 文件夾,找到帶有 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",
}
這樣用户在使用組件時,就能看到清晰的類型提示和跳轉定義了。
效果對比圖
沒有代碼提示時:
有了代碼提示之後:
是不是瞬間感覺開發起來輕鬆多了?通過這種“低成本+高收益”的方式,我們不僅提升了組件庫的易用性,也讓團隊內外的開發者們寫起代碼來更順手、更安心。
如果你對前端工程化有興趣,或者想了解更多前端相關的內容,歡迎查看我的其他文章,這些內容將持續更新,希望能給你帶來更多的靈感和技術分享~