Read OSS

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 配置组合了五个插件:

  1. tsupCleanPlugin —— 当启用 clean 选项时,从输出目录中删除已有的 .d.ts 文件
  2. tsResolvePlugin —— 在设置了 dts.resolve 时,从 node_modules 中解析类型
  3. jsonPlugin —— 通过 @rollup/plugin-json 处理 .json 导入
  4. ignoreFiles —— 对非代码文件(图片、CSS 等)返回空字符串,避免 Rollup 报错
  5. 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 中的 typestypings 字段,而非 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 完整类型系统
依赖项 rolluprollup-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 生命周期的完整流程与跨平台进程管理机制。