前端开发··2 阅读·预计 12 分钟

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:tscvite 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 的两个优势:

  1. 增量编译:本地开发时 --watch 模式下只重新生成变更文件的 .d.ts
  2. 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 声明文件不只是"编译产物"——它是组件库消费方开发体验的入口,也是构建链路中的隐性性能瓶颈。三条核心原则:

  1. declarationMap 是 DX 基础设施——几 KB 的 .d.ts.map 让 IDE 跳转到源码,边际成本趋近于零
  2. 构建与类型检查解耦并行——让 tsc 和 Vite 各自专注所长,用进程并行替代串行等待
  3. emitDeclarationOnly + skipLibCheck 精准削减类型检查范围——只检查你的代码,不反复扫描 node_modules

下一次打开组件库的 tsconfig.json 时,不妨先看看 declarationMaptrue 还是被遗漏了——那个小小的缺失,可能正在悄悄消耗团队每个成员的调试时间。

0 评论

评论区

登录 后参与评论