tsup 架构概览:读懂代码库
前置知识
- ›具备 TypeScript 和 Node.js 模块系统的基础知识
- ›对打包工具的作用有基本认识(入口文件、输出格式、外部依赖等)
tsup 架构概览:读懂代码库
tsup 将自己定位为"基于 esbuild 的零配置 TypeScript 打包工具",对大多数用户来说,了解这些就够了。但在这层简洁的表面之下,tsup 是一条精心设计的处理流水线,协调着 esbuild、Rollup、TypeScript 编译器、SWC、Sucrase 和 Terser 的协同工作——每种工具都被部署在最能发挥其优势的环节。本文将带你梳理整个代码库的结构,无论你是打算贡献代码、开发插件,还是单纯想弄清楚库文件是如何从 .ts 变成可发布产物的,都能从中找到方向。
仓库结构一览
tsup 的代码库相当精简。src/ 目录下约 20 个源文件承担了所有核心工作,按功能分别组织在 esbuild 插件、tsup 插件以及 Rollup 相关的 DTS 子目录中。
| 路径 | 作用 |
|---|---|
src/index.ts |
程序化 API:build()、defineConfig()、normalizeOptions() |
src/cli-default.ts |
tsup 命令的入口文件 |
src/cli-node.ts |
tsup-node 命令的入口文件 |
src/cli-main.ts |
基于 cac 的 CLI 参数解析 |
src/esbuild/ |
esbuild 编排逻辑及 esbuild 插件(external、postcss、svelte、swc 等) |
src/plugins/ |
tsup 构建后插件(shebang、cjs-splitting、tree-shaking、terser 等) |
src/rollup.ts |
通过 rollup-plugin-dts 处理 --dts 的 Worker 线程 |
src/rollup/ |
用于类型解析的自定义 Rollup 插件 |
src/tsc.ts |
用于 --experimental-dts 的程序化 TypeScript 编译器 |
src/api-extractor.ts |
用于 --experimental-dts 的 API Extractor 集成 |
src/exports.ts |
实验性 DTS 的重导出语句格式化 |
assets/ |
注入到产物中的 CJS 和 ESM shim 文件 |
test/ |
基于 Vitest 的集成测试 |
graph TD
subgraph "Source Tree"
CLI["src/cli-*.ts<br/>CLI entry points"]
IDX["src/index.ts<br/>build() orchestrator"]
ESB["src/esbuild/<br/>esbuild plugins"]
PLG["src/plugins/<br/>tsup plugins"]
ROL["src/rollup.ts<br/>DTS worker"]
TSC["src/tsc.ts<br/>TypeScript compiler"]
API["src/api-extractor.ts<br/>API Extractor"]
end
CLI --> IDX
IDX --> ESB
IDX --> PLG
IDX --> ROL
IDX --> TSC
TSC --> API
assets/ 目录只有两个文件——assets/cjs_shims.js 和 assets/esm_shims.js——用于填充跨格式的全局变量,例如在 CJS 中模拟 import.meta.url,在 ESM 中模拟 __dirname/__filename。我们将在第 3 篇文章中详细介绍。
三个入口:tsup、tsup-node 与 build()
tsup 提供两个 CLI 命令和一个程序化 API,而这两个 CLI 都极为简洁——每个文件不超过 10 行。
src/cli-default.ts 是 tsup 命令的入口:
#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'
main().catch(handleError)
src/cli-node.ts 是 tsup-node 的入口,仅多了一个预设选项:
#!/usr/bin/env node
import { handleError } from './errors'
import { main } from './cli-main'
main({
skipNodeModulesBundle: true,
}).catch(handleError)
两者唯一的区别是 skipNodeModulesBundle: true,这会让 esbuild 将 node_modules 中的所有依赖标记为外部依赖,而不是打包进去。对于 Node.js 应用(相对于库而言),这是更合适的默认行为。
flowchart LR
A["tsup CLI"] -->|"main()"| C["cli-main.ts"]
B["tsup-node CLI"] -->|"main({skipNodeModulesBundle: true})"| C
C -->|"dynamic import('.')"| D["build()"]
D --> E["esbuild pipeline"]
D --> F["DTS pipeline"]
真正的 CLI 逻辑位于 src/cli-main.ts,它使用 cac 定义了约 30 个命令行参数。注意第 104 行有一处关键的性能优化:build() 是在 .action() 回调内部通过动态 import('.') 懒加载的:
.action(async (files: string[], flags) => {
const { build } = await import('.')
// ...
await build(options)
})
这意味着当你运行 tsup --help 或 tsup --version 时,整个构建流水线——esbuild、Rollup、TypeScript——都不会被加载。由于 cac 处理帮助和版本信息无需触发 action,CLI 的启动速度因此得以保持轻快。
提示:
package.json的bin字段直接指向编译产物:"tsup": "dist/cli-default.js"和"tsup-node": "dist/cli-node.js"。tsup 自己也在使用自己打包——其构建命令正是tsup src/cli-*.ts src/index.ts src/rollup.ts --clean --splitting。
build() 编排器与并行任务设计
src/index.ts 中的 build() 函数是 tsup 的核心。它负责加载配置、规范化选项,然后并行调度两条独立的任务流:
flowchart TD
B["build(_options)"] --> CL["Load config file"]
CL --> NM["normalizeOptions()"]
NM --> PA["Promise.all()"]
PA --> DTS["dtsTask()"]
PA --> MAIN["mainTasks()"]
DTS --> EDTS{"experimentalDts?"}
EDTS -->|yes| TSC["tsc + API Extractor"]
EDTS -->|no| RDTS{"dts?"}
RDTS -->|yes| WORKER["Worker thread<br/>Rollup + rollup-plugin-dts"]
MAIN --> BA["buildAll()"]
BA --> FMT1["runEsbuild(format: 'cjs')"]
BA --> FMT2["runEsbuild(format: 'esm')"]
BA --> OS["onSuccess hook"]
第 455 行的并行设计看似简单,却至关重要:
await Promise.all([dtsTask(), mainTasks()])
JavaScript 打包与声明文件生成是完全解耦的。JS 流水线使用 esbuild 来保证速度;DTS 流水线则使用 Rollup(配合 rollup-plugin-dts)或 TypeScript 编译器(配合 API Extractor)。两条流水线除了共享规范化后的选项之外,没有任何其他交集——而且由于函数无法跨线程传递,选项在发送给 Worker 线程之前还必须经过序列化处理。
在 mainTasks() 内部,buildAll() 函数通过第 312–347 行的另一个 Promise.all() 为每种输出格式并行执行 runEsbuild()。如果你同时构建 cjs 和 esm,两个 esbuild 实例会并发运行,各自持有独立的 PluginContainer 实例。
双层插件架构:概览
tsup 拥有两套独立的插件层,分别在构建的不同阶段发挥作用。在深入第 3 和第 4 篇文章的细节之前,理解这种分层设计至关重要。
flowchart TD
subgraph "Layer 1: esbuild Plugins"
direction TB
EP1["external.ts<br/>Resolve externals"]
EP2["node-protocol.ts<br/>Strip node: prefix"]
EP3["postcss.ts<br/>CSS processing"]
EP4["swc.ts<br/>Decorator metadata"]
EP5["svelte.ts<br/>Compile .svelte"]
EP6["native-node-module.ts<br/>.node binary handling"]
end
subgraph "Layer 2: tsup Plugins"
direction TB
TP1["shebang<br/>CLI permissions"]
TP2["tree-shaking<br/>Rollup pass"]
TP3["cjs-splitting<br/>ESM→CJS via Sucrase"]
TP4["cjs-interop<br/>Default export compat"]
TP5["swc-target<br/>ES5/ES3 downlevel"]
TP6["terser<br/>Minification"]
TP7["size-reporter<br/>Output stats"]
end
SRC["Source files"] --> EP1
EP6 --> OUT["esbuild outputFiles<br/>(in memory)"]
OUT --> TP1
TP7 --> DISK["Written to disk"]
第一层插件在 esbuild 构建过程中运行,通过 esbuild 的 onResolve 和 onLoad 钩子,在文件进入打包流程之前对其进行转换——解析外部依赖、处理 CSS、编译 Svelte、生成装饰器元数据。
第二层插件在 esbuild 完成之后运行,操作对象是保存在内存中的完整输出 chunk。它们实现了 tsup 的 Plugin 接口,提供 renderChunk 和 buildEnd 等钩子。CJS 分包、tree-shaking、代码压缩和文件权限设置都在这一层完成。
这一机制的关键在于 esbuild 配置中的 write: false——esbuild 将所有输出以内存中 Uint8Array 缓冲区的形式返回,而不是直接写入磁盘,从而让 tsup 插件层有机会在最终落盘之前对所有内容进行处理。
选项与类型:Options 与 NormalizedOptions
tsup 在 src/options.ts 中通过两个类型将面向用户的配置与内部状态清晰地区分开来。
Options 类型(第 103–262 行)代表用户可以提供的配置项,包含约 40 个可选字段。所有字段均为可选,默认值会在后续步骤中填充:
export type Options = {
name?: string
entry?: Entry
target?: Target | Target[]
format?: Format[] | Format // Note: can be a single string
dts?: boolean | string | DtsConfig // Polymorphic
// ... ~35 more optional fields
}
NormalizedOptions 类型(第 269–279 行)才是所有下游代码实际使用的类型:
export type NormalizedOptions = Omit<
MarkRequired<Options, 'entry' | 'outDir'>,
'dts' | 'experimentalDts' | 'format'
> & {
dts?: DtsConfig
experimentalDts?: NormalizedExperimentalDtsConfig
tsconfigResolvePaths: Record<string, string[]>
tsconfigDecoratorMetadata?: boolean
format: Format[]
}
classDiagram
class Options {
+entry?: Entry
+outDir?: string
+format?: Format[] | Format
+dts?: boolean | string | DtsConfig
+target?: Target | Target[]
... ~35 more optional fields
}
class NormalizedOptions {
+entry: Entry ⟵ required
+outDir: string ⟵ required
+format: Format[] ⟵ always array
+dts?: DtsConfig ⟵ normalized
+tsconfigResolvePaths: Record
+tsconfigDecoratorMetadata?: boolean
}
Options --> NormalizedOptions : normalizeOptions()
来自 ts-essentials 的 MarkRequired 工具类型确保 entry 和 outDir 必定存在。dts 字段从三种可能的输入形式(boolean | string | DtsConfig)统一收敛为 DtsConfig | undefined。而 format 也始终是 Format[] 数组,不再是单个字符串。
这种设计意味着,normalizeOptions() 之后的所有函数都可以信任输入数据的结构,无需再做防御性检查。看似一个细小的设计决策,却从根源上消除了一整类 bug。
提示: 向 tsup 贡献代码时,如果你编写的逻辑在
build()启动后运行,请始终使用NormalizedOptions。Options类型专属于面向用户的层面(配置文件、CLI 解析、defineConfig函数)。
下一步
有了这张代码库的全局地图,第 2 篇文章将深入配置加载流水线——tsup 如何在七种支持的配置格式中发现配置文件、bundle-require 如何无需任何配置即可支持 TypeScript 配置文件,以及 normalizeOptions() 如何将 glob 匹配、tsconfig 路径和输出扩展名解析为我们刚刚探讨过的完整类型化 NormalizedOptions 对象。