TypeScript 声明文件:DTS 生成的两种策略
前置知识
- ›已完成第 1–3 篇文章
- ›对 TypeScript 编译器 API 有基本了解(ts.createProgram、ts.Program)
- ›理解 .d.ts、.d.mts 和 .d.cts 文件的作用
- ›熟悉 Rollup 的插件 API
TypeScript 声明文件:DTS 生成的两种策略
esbuild 之所以速度惊人,是因为它直接剥离类型而不做任何处理。但对于库作者来说,.d.ts 文件不可或缺——没有它,使用者将无法获得 IntelliSense、类型检查和自动补全。tsup 提供了两套截然不同的声明文件生成策略,各有其取舍。本文将对两者逐一拆解。
为什么 DTS 生成要与 JS 打包分开
正如第 1 篇文章所介绍的,build() 会派发两条独立的任务链:
await Promise.all([dtsTask(), mainTasks()])
JS 流水线使用 esbuild,DTS 流水线则使用具备 TypeScript 感知能力的工具。两者必须并行执行,因为 DTS 生成往往是 tsup 构建中最慢的环节——TypeScript 类型检查器虽然严谨,但速度并不快。与 esbuild 并发运行后,总构建时间约等于 max(esbuild_time, dts_time),而非两者之和。
flowchart LR
subgraph "Parallel Execution"
direction TB
M["mainTasks()<br/>esbuild → plugins → disk"]
D["dtsTask()<br/>TypeScript → Rollup/API Extractor → disk"]
end
B["build()"] --> M
B --> D
M -.->|"Promise.all"| DONE["Build complete"]
D -.-> DONE
--dts 路径会在 Worker 线程中运行以实现隔离。但 Worker 线程有一个根本性的限制:它们通过结构化克隆进行通信,而函数无法被克隆。因此 dtsTask() 在传递选项之前,会先对其进行清理处理:
worker.postMessage({
configName: item?.name,
options: {
...options,
injectStyle: typeof options.injectStyle === 'function'
? undefined : options.injectStyle,
banner: undefined,
footer: undefined,
esbuildPlugins: undefined,
esbuildOptions: undefined,
plugins: undefined,
treeshake: undefined,
onSuccess: undefined,
outExtension: undefined,
},
})
所有函数类型的选项都会被置为 undefined。DTS 流水线本就不需要它们——banner、footer 和 esbuild 插件对类型声明来说毫无意义。
策略一 —— --dts:Worker 线程 + rollup-plugin-dts
--dts 路径会启动一个运行 src/rollup.ts 的 Node.js Worker 线程。Worker 接收到包含清理后选项的消息后,随即构建 Rollup 流水线。
sequenceDiagram
participant Main as Main Thread (index.ts)
participant Worker as Worker Thread (rollup.ts)
participant Rollup as Rollup
participant DTS as rollup-plugin-dts
Main->>Worker: postMessage({ options })
Worker->>Worker: getRollupConfig(options)
Worker->>Rollup: rollup(inputConfig)
Rollup->>DTS: Process .ts files → .d.ts
DTS-->>Rollup: Bundled declarations
Rollup-->>Worker: bundle.write(outputConfig)
Worker->>Main: postMessage('success')
Main->>Main: resolve() / terminate worker
getRollupConfig() 中的 Rollup 配置组合了五个插件:
tsupCleanPlugin—— 当启用clean选项时,从输出目录中删除已有的.d.ts文件tsResolvePlugin—— 在设置了dts.resolve时,从node_modules中解析类型jsonPlugin—— 通过@rollup/plugin-json处理.json导入ignoreFiles—— 对非代码文件(图片、CSS 等)返回空字符串,避免 Rollup 报错dtsPlugin—— 核心插件rollup-plugin-dts,读取 TypeScript 源码并输出打包后的.d.ts文件
第 111–131 行的 dtsPlugin 配置强制指定了若干编译器选项:
dtsPlugin.default({
tsconfig: options.tsconfig,
compilerOptions: {
...compilerOptions,
declaration: true,
noEmit: false,
emitDeclarationOnly: true,
noEmitOnError: true,
checkJs: false,
declarationMap: false,
skipLibCheck: true,
target: ts.ScriptTarget.ESNext,
},
})
设置 target: ESNext 可确保解析器能处理所有现代语法。skipLibCheck: true 则通过跳过 node_modules 中 .d.ts 文件的类型检查来提升速度。
在 watch 模式下,Worker 使用 Rollup 的 watch() API 替代 rollup() + bundle.write(),订阅相关事件,并在每次重新构建时向主线程发送 'success' 消息。
另外值得注意的是:当启用 cjsInterop 且格式为 CJS 时,输出配置中会加入来自 fix-dts-default-cjs-exports 包的 FixDtsDefaultCjsExportsPlugin,确保声明文件能正确反映 cjsInterop 插件所应用的 module.exports = exports.default 模式。
tsResolvePlugin:在 node_modules 中查找类型
tsResolvePlugin 是一个自定义 Rollup 插件,用于在 node_modules 中定位 .d.ts 文件。默认情况下,使用 --dts 构建时,所有依赖都会被外部化——它们的类型以 import 语句的形式出现在输出的 .d.ts 中。但当启用 dts.resolve 时,该插件会将指定包的类型内联进来。
flowchart TD
A["resolveId(source, importer)"] --> B{"Built-in module?"}
B -->|yes| C["return false (external)"]
B -->|no| D{"Matches resolveOnly?"}
D -->|no match & resolveOnly set| E["return null (skip)"]
D -->|matches| F{"Relative path?"}
F -->|yes| G["resolve with .d.ts/.ts extensions"]
F -->|no| H["Try node_modules resolution<br/>using pkg.types || pkg.typings"]
G --> I{"Found?"}
H --> I
I -->|yes| J["return resolved path"]
I -->|no| K["return false (external)"]
第 93–103 行的解析逻辑使用 resolve 包,并通过自定义 packageFilter 告知它优先查找 package.json 中的 types 或 typings 字段,而非 main:
packageFilter(pkg) {
pkg.main = pkg.types || pkg.typings
return pkg
},
paths: ['node_modules', 'node_modules/@types'],
同时,它还会搜索 node_modules/@types,以覆盖 DefinitelyTyped 上的包。
跨线程日志
DTS 流水线在 Worker 线程中运行时,console.log 的输出会直接写入 stderr,且没有正确的格式。tsup 在 src/log.ts 中解决了这一问题:
log(label, type, ...data) {
const args = [makeLabel(name, label, type), ...data.map(/*...*/)]
switch (type) {
case 'error': {
if (!isMainThread) {
parentPort?.postMessage({ type: 'error', text: util.format(...args) })
return
}
return console.error(...args)
}
default:
if (silent) return
if (!isMainThread) {
parentPort?.postMessage({ type: 'log', text: util.format(...args) })
return
}
console.log(...args)
}
}
sequenceDiagram
participant W as Worker (rollup.ts)
participant L as Logger (log.ts)
participant PP as parentPort
participant M as Main Thread (index.ts)
W->>L: logger.success('dts', 'Build success')
L->>L: isMainThread? No
L->>PP: postMessage({ type: 'log', text: 'DTS Build success' })
PP-->>M: 'message' event
M->>M: console.log(data.text)
node:worker_threads 提供的 isMainThread 标志决定了是直接打印日志,还是通过 parentPort.postMessage 转发。第 240–254 行的主线程消息处理器会将这些消息重新输出到控制台,从而保证正确的输出顺序。
提示: 如果你在调试 DTS 构建问题时发现日志缺失,请检查是否启用了静默模式(
--silent)。Worker 线程与主线程一样遵循silent标志,但无论静默模式是否开启,错误信息始终会被输出。
策略二 —— --experimental-dts:tsc + API Extractor
--experimental-dts 路径采用了截然不同的方式。它不使用 Rollup,而是直接调用 TypeScript 编译器 API,再通过微软的 API Extractor 来合并声明文件。
src/tsc.ts 中的 runTypeScriptCompiler() 函数负责第一阶段。emit() 函数以仅输出声明的编译选项创建完整的 TypeScript program:
const parsedTsconfig = ts.parseJsonConfigFileContent({
compilerOptions: {
...compilerOptions,
noEmit: false,
declaration: true,
declarationMap: true,
declarationDir, // .tsup/declaration/
emitDeclarationOnly: true,
},
}, ts.sys, /* ... */)
声明文件首先输出到 .tsup/declaration/——这是一个暂存目录,并非最终输出位置。emitDtsFiles() 函数通过自定义 WriteFileCallback 构建源文件路径到输出声明路径的映射关系。
输出完成后,getExports() 遍历 TypeScript program 的根文件,通过 checker.getExportsOfModule() 提取所有导出符号。每个导出都会生成一个 ExportDeclaration,包含其名称、别名(用于去重)、源文件和目标文件。
flowchart TD
A["runTypeScriptCompiler()"] --> B["Load tsconfig"]
B --> C["ts.createProgram()"]
C --> D["program.emit()<br/>with custom WriteFileCallback"]
D --> E[".tsup/declaration/*.d.ts<br/>(staging directory)"]
D --> F["fileMapping: source → output"]
F --> G["getExports(program, fileMapping)"]
G --> H["ExportDeclaration[]"]
H --> I["runDtsRollup()"]
I --> J["Format aggregation file"]
J --> K["API Extractor → single bundled .d.ts"]
K --> L["Per-entry distribution files"]
第二阶段由 src/api-extractor.ts 中的 runDtsRollup() 负责。它先写入一个聚合文件来重新导出所有内容,再运行 API Extractor 生成单一的合并声明文件,最后输出各入口点的分发文件。
用 exports.ts 格式化重导出语句
exports.ts 模块负责格式化重导出语句,其中两个函数分别服务于不同阶段:
formatAggregationExports() 生成 API Extractor 的输入文件——它从暂存的 .d.ts 文件中重导出所有符号:
export { MyClass } from './staging/index.js';
export { helper as helper_alias_1 } from './staging/utils.js';
formatDistributionExports() 生成最终的各入口输出文件——它们从合并后的声明文件中重导出内容。
tsc.ts 中的 AliasPool 类负责处理名称去重。当多个源文件导出同名符号时,该池会分配唯一的别名:
class AliasPool {
private seen = new Set<string>()
assign(name: string): string {
let suffix = 0
let alias = name === 'default' ? 'default_alias' : name
while (this.seen.has(alias)) {
alias = `${name}_alias_${++suffix}`
}
this.seen.add(alias)
return alias
}
}
default 导出会得到特殊处理——它始终被重命名为 default_alias,因为 default 是保留字,在某些上下文中不能作为绑定名使用。
两种策略对比:取舍与适用场景
| 维度 | --dts(Rollup) |
--experimental-dts(tsc + API Extractor) |
|---|---|---|
| 引擎 | rollup-plugin-dts | TypeScript 编译器 + @microsoft/api-extractor |
| 隔离性 | Worker 线程 | 主线程(同一进程) |
| 速度 | 对简单项目通常更快 | 对大型项目可能更快(无 Rollup 开销) |
| 正确性 | 复杂重导出场景可能存在问题 | 更好地处理 TypeScript 完整类型系统 |
| 依赖项 | rollup、rollup-plugin-dts(已内置) |
@microsoft/api-extractor(需单独安装) |
| Watch 模式 | 完整的 Rollup watch,支持增量重建 | 不支持 watch(每次运行完整 tsc) |
| 类型解析 | 通过 dts.resolve 选项配合自定义解析器 |
遵循 TypeScript 原生解析规则 |
| Ambient 模块 | 可能存在问题 | 处理正确 |
| 声明合并 | 支持有限 | 完整支持 |
| 格式化输出 | 按格式输出对应扩展名 | 按格式输出对应扩展名 |
两种策略不能同时使用——同时启用会在第 205–208 行抛出错误:
if (options.dts && options.experimentalDts) {
throw new Error(
"You can't use both `dts` and `experimentalDts` at the same time",
)
}
提示: 建议优先使用
--dts——它是经过充分验证的稳定路径,能覆盖绝大多数使用场景。只有在遇到复杂类型模式的问题时才考虑切换到--experimental-dts,例如声明合并、跨重导出的条件类型,或 ambient 模块扩充等情况。另外,使用前别忘了将@microsoft/api-extractor安装为开发依赖。
下一篇
本系列的最后一篇——第 6 篇,将深入介绍 tsup 的 watch 模式:chokidar 如何监听文件变化、esbuild 的 metafile 如何追踪构建依赖以实现智能重建、用于合并频繁文件变更的防抖工具,以及 onSuccess 生命周期的完整流程与跨平台进程管理机制。