需求背景
主管和其他同事基于公司的业务特点,开发了一套自研前端框架。技术选型是 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", }
这样用户在使用组件时,就能看到清晰的类型提示和跳转定义了。
效果对比图
没有代码提示时:

有了代码提示之后:

是不是瞬间感觉开发起来轻松多了?通过这种“低成本+高收益”的方式,我们不仅提升了组件库的易用性,也让团队内外的开发者们写起代码来更顺手、更安心。
如果你对前端工程化有兴趣,或者想了解更多前端相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享~