本节目标:知道 esbuild、swc、tsup 各自解决什么问题、代价是什么,理解「编译」与「类型检查」为什么要拆成两条流水线,并能看懂一个 npm 包发布时输出的
dist/目录里每个文件存在的理由。
16.2 esbuild/swc/tsup 与打包产物
上一节我们把 tsconfig 调对了,但有一个现实问题没解决:tsc 慢。在一个中等规模的前端项目里,全量 tsc 跑几十秒是常态,热更新时更让人抓狂。
于是生态里出现了一批新工具:esbuild、swc、tsup、Vite、Rollup……它们都能把 TypeScript 变成可运行的东西。但它们的定位差别很大,混用会踩坑。这一节把这张地图画清楚。
16.2.1 tsc 慢在哪,新工具快在哪
tsc 慢,不是因为它写得差,而是因为它做的事更多。它要在内存里构建完整的类型图,做类型推导、泛型实例化、控制流分析——这些是纯计算密集的语义分析。相比之下,新工具大多只做语法转换:把 const 换成 var(如果 target 需要)、删掉类型标注、改写 import。它们不做类型检查。
| 能力 | tsc | esbuild | swc | tsup |
|---|---|---|---|---|
| 语法降级 | 是 | 是 | 是 | 是(内部调 esbuild) |
| 类型检查 | 是 | 否 | 否 | 否(需另跑 tsc) |
| 打包(bundle) | 否 | 是 | 是 | 是 |
| 代码分割 | 否 | 有限 | 有限 | 是 |
生成 .d.ts | 是 | 否 | 否 | 是(调 tsc/rollup) |
| 速度 | 慢 | 极快 | 极快 | 快 |
| 语言 | TypeScript | Go | Rust | TypeScript |
关键认知:「删掉类型」和「检查类型」是两件可以分开的事。新工具只做前者,把后者交给 tsc --noEmit 并行跑。这个范式是理解现代构建链的钥匙。
16.2.2 esbuild:快到不讲道理,但只做转换
esbuild 用 Go 写,核心优势是快——通常比 tsc 快 10 到 100 倍。它的用法极简:
npx esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --platform=node
但它的取舍也很明确:
- 不做类型检查。 你写
const x: number = "hello",esbuild 照样输出,只有tsc会报错。 - 不生成
.d.ts。 库作者必须另外跑一遍tsc --emitDeclarationOnly。 - 某些 TS 特性不支持。 比如
const enum、emitDecoratorMetadata(装饰器元数据)在纯 esbuild 下有兼容问题,需要experimentalDecorators加插件补齐。 - 降级不完整。 它对老旧目标(如
es5)的支持有限,--target=es5在某些语法上会直接报错而不是降级。
一条真实经验:esbuild 报错信息里的行号有时对不上源码,因为它默认不生成 sourcemap 之外的映射。调试时务必加 --sourcemap。
延伸阅读本站的 esbuild 原理剖析 ,那里有更细的转换流程拆解。
16.2.3 swc:Rust 生态,配置更细
swc 用 Rust 写,速度与 esbuild 同级,但配置粒度更细,尤其在降级与装饰器场景更成熟。它的配置文件是 .swcrc:
{
"jsc": {
"parser": {
"syntax": "typescript",
"decorators": true
},
"target": "es2022",
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
}
},
"module": {
"type": "es6"
},
"sourceMaps": true
}
一个常见误解是「swc 能替代 tsc」。不能。swc 同样不做类型检查,它只是把 TypeScript 当作「带类型标注的 JavaScript」来处理。NestJS 这类重度依赖装饰器元数据的框架,用 swc 需要显式开启 decoratorMetadata,否则依赖注入会在运行时找不到类型。
16.2.4 tsup:给库作者的零配置打包器
如果你要发布一个 npm 包,tsup 是最省心的选择。它内部用 esbuild 做转换、用 Rollup(或 tsc)生成 .d.ts,把「产物三件套」一次配好:
// tsup.config.ts
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
clean: true,
target: "es2022",
outDir: "dist",
});
跑一次:
npx tsup
产物长这样:
dist/
├── index.js # ESM 产物
├── index.cjs # CJS 产物
├── index.d.ts # 类型声明
├── index.d.cts # CJS 的类型声明
├── index.js.map
└── index.cjs.map
注意 dts: true 会额外启动一次 TypeScript 编译器——这也是 tsup 相比纯 esbuild 慢的原因。但这一次开销换来的是「消费者有类型可用」,对库来说是必需的。
关于包发布的完整流程(exports 字段、files 白名单、版本策略),可以配合第 11 章 11.3 npm 包、类型声明与 exports
一起看。
16.2.5 产物三件套:ESM、CJS、d.ts 分别服务谁
一个现代 npm 包的 dist/ 里通常有三种产物,它们各自对应一类消费者:
| 产物 | 消费者 | 入口字段 |
|---|---|---|
ESM(.js) | 现代打包器、Node ESM | exports.import |
CJS(.cjs) | 老 Node、老构建链 | exports.require |
.d.ts | 所有 TypeScript 用户 | exports.types |
package.json 里靠 exports 把它们接上:
{
"name": "my-lib",
"type": "module",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
}
}
types 必须放在 exports 条件的第一个——这是硬性约定,顺序错了 TypeScript 会解析不到类型,消费者会看到「找不到模块声明」。这个坑非常隐蔽,因为运行时一切正常,只有编辑器飘红。
还有一个更隐蔽的坑叫**「双包危害」(dual package hazard):如果一个包同时提供 ESM 与 CJS 两份产物,而它们各自持有模块级状态(比如一个全局计数器),那么同一个进程里 import 与 require 会拿到两个不同的实例**。库作者要避免在模块顶层放可变状态,或改用单一格式加包装。
16.2.6 sourcemap:让报错栈回到源码
打包之后,运行时报错的行号指向产物,而产物和你写的源码差了十万八千里。sourcemap 就是那个映射表:
npx esbuild src/index.ts --bundle --sourcemap --outfile=dist/index.js
产物末尾会多一行注释:
//# sourceMappingURL=index.js.map
Node 需要显式开启支持:
node --enable-source-maps dist/index.js
常见的三个坑:
- 只生成了
map却没上传。 线上报错平台(如 Sentry)需要你上传 sourcemap 才能还原栈,忘了上传等于没做。 - sourcemap 泄漏源码。
.map里含原始代码,如果随产物一起发布到 CDN,等于把源码公开。正确做法是只上传到错误监控平台、不发布到公网。 inlineSources打开后体积暴涨。 它会把源码内嵌进 map,只在特殊场景使用。
更深入的 sourcemap 机制见 Vite sourcemap 深入解析 。
16.2.7 tree-shaking:为什么你的包瘦不下来
tree-shaking 指「摇掉没被用到的导出」。它生效的前提是模块是静态可分析的,并且没有副作用。
先看一个反例:
// utils.ts
export function add(a: number, b: number) {
return a + b;
}
export function subtract(a: number, b: number) {
return a - b;
}
// 这行有副作用:导入即执行
console.log("utils loaded");
如果只用了 add,理论上 subtract 应该被摇掉。但因为模块顶层有 console.log,打包器无法确定「导入这个模块是否安全」,只能整体保留。
解决方式是显式声明副作用:
{
"sideEffects": false
}
或者在更精细的场景下列出有副作用的文件:
{
"sideEffects": ["./src/polyfill.ts", "*.css"]
}
这个字段写错的代价很大:如果把真有副作用的文件标成 false,打包器会把它摇掉,导致 CSS 丢失或 polyfill 不生效——而且这类 bug 只在生产构建出现,本地开发完全正常。
另一个影响 tree-shaking 的因素是产物格式:CJS 产物几乎无法 tree-shaking,因为 require 是运行时调用,静态分析无从下手。所以库作者要尽量提供 ESM 产物。第 11 章 11.1 ES 模块与模块解析
讲过 ESM 的静态特性,这里正是它的价值体现。
想量化摇掉了多少,可以用产物体积分析工具,参考 Vite 产物体积分析 。
16.2.8 工程范式:类型检查与打包分离
把前面的结论合起来,就是现代 TypeScript 项目的标准流水线:
// package.json
{
"scripts": {
// 开发:只转换,不检查,追求秒级响应
"dev": "esbuild src/index.ts --bundle --watch --outfile=dist/index.js",
// 类型检查:并行跑,可用 --watch 常驻
"typecheck": "tsc --noEmit --watch",
// 构建:转换 + 类型检查 + 声明文件,全都要
"build": "tsc --noEmit && tsup"
}
}
要点有三条:
- 开发期不阻塞在类型检查上。 让
tsc --watch在另一个终端跑,编辑器负责即时反馈,构建器只管出产物。 - CI 必须跑一次完整类型检查。 否则「本地能跑」的代码会带着类型错误合进主干。
.d.ts只在发布时生成。 日常开发不需要,生成声明文件会显著拖慢反馈循环。
如果你的构建时间已经长到影响效率,可以看 TypeScript 构建性能优化 里的增量与缓存策略。
16.2.9 常见坑与报错
| 现象 | 原因 | 处理 |
|---|---|---|
| 打包成功但运行时报类型错误 | 打包器不做类型检查 | CI 加 tsc --noEmit |
Cannot find module './x'(运行时) | ESM 下相对导入缺扩展名 | 补 .js 扩展名或改 moduleResolution |
| 消费者看不到类型 | exports.types 顺序不对 | 把 types 放到条件首位 |
const enum 编译报错 | esbuild/swc 默认不支持 | 改用普通 enum 或加插件 |
| 装饰器元数据丢失 | 未开 decoratorMetadata | 在 .swcrc 里显式开启 |
| 生产环境样式丢失 | sideEffects 误标为 false | 把有副作用的文件列进数组 |
| 报错行号错位 | 没开 sourcemap | 加 --sourcemap 与 --enable-source-maps |
有一条通用排查思路:先确认问题出在「转换」还是「检查」。如果 tsc --noEmit 干净但运行时出错,那问题在转换配置或运行时环境;如果 tsc --noEmit 就报错,那和打包器无关,回去看 tsconfig——上一节 16.1 编译目标与严格模式配置
那张速查表能覆盖大部分情况。
从 webpack 迁移过来的项目,可以看 从 webpack 迁移到 Vite 与 Vite 与 Rollup 构建流水线 这两篇,里面有不少配置对照。
小结
本节的核心结论只有一句:类型检查与代码转换是两条流水线。esbuild 与 swc 负责极快的转换,tsc --noEmit 负责把关类型,tsup 把库需要的「ESM + CJS + d.ts」三件套打包好。我们还拆开了产物的每个组成部分:exports 字段如何把消费者路由到正确的文件、types 为什么要放首位、sourcemap 为什么必须上传而不是发布、sideEffects 写错会造成什么生产事故、以及为什么 CJS 产物几乎无法 tree-shaking。
现在你已经能驾驭单个包的构建。但当项目从一个包变成十个包时,新的问题出现了:包与包之间怎么互相引用?改一个包要不要重新构建依赖它的所有包?下一节 16.3 Monorepo 与 Project References 就来解决这件事。
阅读导航:上一节:16.1 编译目标与严格模式配置 · 下一节:16.3 Monorepo 与 Project References 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。