需求背景
主管和其他同事基于公司的业务特点,开发了一套自研前端框架。技术选型是 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 组件和交互能力的集合。
整个项目的目录结构如下所示:
javascript
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
,最后写个脚本批量处理这些子包,自动生成类型声明文件。
安装依赖
因为这些依赖项是多个子包共用的,所以我们统一安装在根目录下:
javascript
npm install --save-dev typescript jsdoc @types/react @types/react-dom
tsconfig.json 配置
每个子包都是独立发布的,所以每个子包都要有自己的 tsconfig.json
文件。
下面是通用配置,利用了 TypeScript 的 emitDeclarationOnly
功能,只用来生成 .d.ts
文件:
javascript
{
"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
脚本,内容如下:
javascript
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 中加一条脚本指令:
javascript
{
"scripts": {
"build:dts": "node scripts/build-dts.js"
}
}
现在只需要在终端输入:
javascript
npm run build:dts
就能一键为所有子包生成 .d.ts
文件啦!
实际效果展示
在没有改一行源码的情况下,TypeScript 自动生成的 .d.ts
文件长这样:
虽然类型定义可能还不够精准,但已经能帮开发者理解 API 的基本用法了。
但有些组件就比较复杂,TypeScript 推不出来详细的结构,比如下面这个组件生成的 .d.ts
就显得有点鸡肋,根本看不出组件该如何使用:
这时候就可以借助 JSDoc 来补充说明了。
用 JSDoc 补充类型信息
以 Page
组件为例,我们在源码顶部加上 JSDoc 注释,定义 props 结构和生命周期参数:
javascript
/**
* @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>}
*/
再执行
javascript
npm run build:dts
这会生成的 .d.ts 文件就能清楚地告诉开发者:这个组件到底接受哪些 props。
不过也要注意,并不是所有的 JSDoc 注释生成类型声明之后都能在编译软件上有代码提示。有时候还是会遇到一些限制。
发布到 npm
生成完 .d.ts
文件之后,还要确保它们能随着组件一起发布到 npm 上。这就需要在每个子包的 package.json
中添加如下配置:
javascript
{
"files": [
"cjs/**",
"esm/**",
"types/**"
],
"types": "types/index.d.ts",
}
这样用户在使用组件时,就能看到清晰的类型提示和跳转定义了。
效果对比图
没有代码提示时:
有了代码提示之后:
是不是瞬间感觉开发起来轻松多了?通过这种"低成本+高收益"的方式,我们不仅提升了组件库的易用性,也让团队内外的开发者们写起代码来更顺手、更安心。
如果你对前端工程化有兴趣,或者想了解更多前端相关的内容,欢迎查看我的其他文章,这些内容将持续更新,希望能给你带来更多的灵感和技术分享~