《TypeScript编程入门》16.2 esbuild/swc/tsup 与打包产物

本节解释为什么 tsc 不再是唯一的编译路径,并对比 esbuild、swc、tsup 三类工具的定位与取舍。先看它们「快」的代价是什么,再讲清产物三件套 ESM、CJS、d.ts 分别服务谁,接着拆解 sourcemap 与 tree-shaking 在打包语境下的真实行为,最后给出「类型检查与打包分离」的工程范式与高频踩坑清单。读完你能为库或应用选出合适的构建链。

本节目标:知道 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。它们不做类型检查。

能力tscesbuildswctsup
语法降级是是是是(内部调 esbuild)
类型检查是否否否(需另跑 tsc)
打包(bundle)否是是是
代码分割否有限有限是
生成 .d.ts是否否是(调 tsc/rollup)
速度慢极快极快快
语言TypeScriptGoRustTypeScript

关键认知:「删掉类型」和「检查类型」是两件可以分开的事。新工具只做前者,把后者交给 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 ESMexports.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

常见的三个坑:

  1. 只生成了 map 却没上传。 线上报错平台(如 Sentry)需要你上传 sourcemap 才能还原栈,忘了上传等于没做。
  2. sourcemap 泄漏源码。 .map 里含原始代码,如果随产物一起发布到 CDN,等于把源码公开。正确做法是只上传到错误监控平台、不发布到公网。
  3. 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"
  }
}

要点有三条:

  1. 开发期不阻塞在类型检查上。 让 tsc --watch 在另一个终端跑,编辑器负责即时反馈,构建器只管出产物。
  2. CI 必须跑一次完整类型检查。 否则「本地能跑」的代码会带着类型错误合进主干。
  3. .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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes