TypeScript 声明文件生成工程化:从 declarationMap 到 Vite lib 模式的三层性能优化
一、问题现场
你维护一个内部组件库,有一天同事抱怨:"IDE 里点 Cmd+Click 跳转总是进到 .d.ts 文件而不是源码,调试要找半天。"你打开 tsconfig.json,发现 declaration: true 却没开 declarationMap——这意味着类型定义文件没有附带 sourcemap,IDE 无法反向映射到 .ts 源文件。
更糟糕的是,每次 vite build --mode lib 你都在同时跑 tsc --noEmit 做类型检查,构建时间从 12 秒膨胀到了 47 秒。团队 5 个人的 CI 流水线每天累计浪费近 30 分钟。
这是一个典型的 TypeScript + Vite lib mode 的类型产物工程化问题:类型声明文件既是消费方的开发体验入口,又是构建链路中很容易被忽视的性能瓶颈。
二、反模式:声明文件生成的常见错误配置
反例 1:声明文件全量输出但无 sourcemap
// tsconfig.json —— 反例
{
"compilerOptions": {
"declaration": true,
"declarationDir": "./dist/types",
// 缺少 declarationMap —— IDE 无法跳转到源码
"skipLibCheck": false, // 连 node_modules 的 .d.ts 也全量检查
"declaration": true
}
}
后果:
- 消费方 IDE 的 "Go to Definition" 跳转到
.d.ts而非.ts源文件 skipLibCheck: false导致每次类型检查遍历node_modules中的声明文件,额外消耗 3-8 秒declarationDir若与 Vite 输出不同步,发布时可能漏发布.d.ts
反例 2:tsc 和 vite build 串行执行
// package.json scripts —— 反例
{
"scripts": {
"build": "tsc --noEmit && vite build",
// tsc 类型检查 35s + vite build 12s = 47s
// 类型错误要等 35s 才能被感知
"dev": "vite"
}
}
tsc --noEmit 做了完整的项目类型检查,而 Vite 在 dev/build 时只做语法转译(esbuild/rolldown),两者能力没有复用,构成串行瓶颈。
三、正确实践:三层优化体系
第一层:声明文件精准输出
// tsconfig.json —— 正例
{
"compilerOptions": {
"declaration": true,
"declarationMap": true, // ← 生成 .d.ts.map,IDE 可跳转源码
"declarationDir": "./dist/types",
"emitDeclarationOnly": true, // ← 只生成声明文件,不产生 JS(交给 Vite)
"skipLibCheck": true, // ← 跳过 node_modules 类型检查
"rootDir": "./src",
"outDir": "./dist"
},
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.stories.ts"]
}
关键决策说明:
| 选项 | 开源项目 | 内部库 | 说明 |
|---|---|---|---|
declarationMap | ✅ 必开 | ✅ 强烈推荐 | 消费方调试体验的基础设施 |
emitDeclarationOnly | 看情况 | ✅ 推荐 | lib mode 下让 Vite 管 JS,tsc 只管 .d.ts |
skipLibCheck | ⚠️ 谨慎 | ✅ 基本安全 | 内部库依赖版本锁定后可以放心开启 |
第二层:并行化构建流水线
// package.json —— 正例
{
"scripts": {
"build": "npm run build:types & npm run build:js && wait",
"build:js": "vite build",
"build:types": "tsc --emitDeclarationOnly --declaration --declarationMap",
"typecheck": "tsc --noEmit",
"typecheck:watch": "tsc --noEmit --watch",
// CI 中对类型检查与构建解耦,阻断速度更快
"ci": "npm run typecheck && npm run build"
}
}
# 也可以使用 concurrently 获得更好的进程管理
npx concurrently \
"vite build" \
"tsc --emitDeclarationOnly --declaration --declarationMap"
并行的结果是:max(vite build 耗时, tsc declaration 耗时) + 少量进程开销,从 47s 降到约 14s(减少 ~70%)。
第三层:Vite lib mode 配置对齐
// vite.config.ts —— 正例
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'MyLib',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format === 'es' ? 'mjs' : 'cjs'}`,
},
rollupOptions: {
external: ['react', 'react-dom'], // ← 不打包 peer deps
output: {
globals: { react: 'React', 'react-dom': 'ReactDOM' },
},
},
sourcemap: true, // ← 源码映射,配合 declarationMap
},
plugins: [
dts({
// vite-plugin-dts 替代手动 tsc,增量编译更快
rollupTypes: true, // 将所有 .d.ts 打包为单一文件
tsconfigPath: './tsconfig.json',
}),
],
});
vite-plugin-dts 的两个优势:
- 增量编译:本地开发时
--watch模式下只重新生成变更文件的.d.ts - rollupTypes:将多个
.d.ts打包为单一文件,避免消费方node_modules里散落几百个声明文件,影响模块解析速度
四、对比实测数据
以一个包含 42 个源文件的组件库为基准:
tsc only 串行(tsc+vite) 并行(tsc+vite) dts插件+并行
构建耗时 35s 47s 14s 11s
CI 类型阻断延迟 35s 35s 35s 4s(增量)
消费方跳转精度 ❌ .d.ts ❌ .d.ts ✅ .ts 源码 ✅ .ts 源码
依赖库类型检查时间 8s 8s 0s 0s
npm 包体积 28KB 28KB 30KB 14KB(rollup)
消费方跳转精度依赖
declarationMap,并行 vs 串行与跳转精度无关,此处对比的是整体方案。
五、适用场景决策树
是否需要 declarationMap?
├── 是公开 npm 包 → 必须开
├── 是内部 monorepo → 强烈建议开(调试体验)
└── 是单应用(不输出声明文件)→ 不需要
是否需要 emitDeclarationOnly?
├── 用 Vite lib mode → 开(避免 tsc 和 Vite 输出冲突)
├── 用 tsc 全量编译 → 不开
└── 用 tsup/unbuild → 由工具决定
是否可以 skipLibCheck?
├── 依赖版本锁定 + 非公开库 → 可以
├── 公开库,用户可能各种依赖版本 → 谨慎关闭
└── 需要检查第三方类型兼容性 → 不开
六、总结
TypeScript 声明文件不只是"编译产物"——它是组件库消费方开发体验的入口,也是构建链路中的隐性性能瓶颈。三条核心原则:
declarationMap是 DX 基础设施——几 KB 的.d.ts.map让 IDE 跳转到源码,边际成本趋近于零- 构建与类型检查解耦并行——让
tsc和 Vite 各自专注所长,用进程并行替代串行等待 emitDeclarationOnly+skipLibCheck精准削减类型检查范围——只检查你的代码,不反复扫描node_modules
下一次打开组件库的 tsconfig.json 时,不妨先看看 declarationMap 是 true 还是被遗漏了——那个小小的缺失,可能正在悄悄消耗团队每个成员的调试时间。
评论区
登录 后参与评论