TS 工具链分工:tsc / esbuild / tsx / tsup / tsdown / vite
tsc、tsx、tsup、tsdown、esbuild、vite、swc、rollup……这些名字放在一起很容易混。根本原因不是你记性差,而是它们压根不是同一层的东西——但名字都带个 ts/t,看起来像同类。
先把一件事说死:类型检查、转译、打包、运行,是四件独立的事。 大部分混淆都来自以为它们是一件事。
一、一张图看清整条流水线
关键点:只有 ① 会看你的类型对不对。 ②③④ 全都是"把类型擦掉、把语法降级",类型写错了它们照样给你跑。
二、五类角色,别混着记
| 角色 | 干什么 | 产出 | 代表工具 |
|---|---|---|---|
| 类型检查器 | 验证类型、报错 | 无 | tsc --noEmit、vue-tsc |
| 转译器 | 擦类型 + 语法降级,单文件进单文件出 | .js | tsc、esbuild、swc、oxc、Babel |
| 打包器 | 合并模块、tree-shaking、代码分割 | dist/ | rollup、rolldown、esbuild、webpack、rspack |
| 运行时 / 加载器 | 让 Node 直接执行 .ts | 无 | tsx、ts-node、Node 原生类型剥离 |
| 一体化工具 | 把上面几步串起来 | 视情况 | vite、tsup、tsdown、unbuild |
vite / tsup / tsdown 属于最后一类——它们是编排者,内部会挑转译器和打包器来用。所以拿 tsup 和 esbuild 对比是错的:tsup 的底层就是 esbuild。
三、逐个说清楚
tsc —— 唯一的官方编译器
TypeScript 自带的编译器,同时做两件事:类型检查 + 转译。
tsc --noEmit # 只检查,不产出任何文件(CI 里跑这个)
tsc # 检查 + 产出 .js
tsc -b # build 模式,配合 project references 做增量构建它不打包。 这是最常被误解的一点:tsc 只是把 src/a.ts 变成 dist/a.js,一进一出。它不会把 node_modules 里的依赖合并进来,不做 tree-shaking,不做代码分割。想发布一个单文件的库,tsc 做不到。
慢,是因为它要做完整的类型推导——这份慢换来的是唯一可信的类型检查。
esbuild —— 快的转译器 + 打包器
Go 写的,快一个数量级。既能转译也能打包,vite 和 tsup 都拿它当底座。
它明确不做类型检查(官方 FAQ 写了永远不会支持)。所以:
{
"scripts": {
"build": "esbuild src/index.ts --bundle --outfile=dist/index.js",
// 类型错误不会让上面那行失败,必须单独跑
"typecheck": "tsc --noEmit"
}
}因为它单文件转译(不知道其他文件的信息),你的代码必须满足 isolatedModules 的约束——这条约束和我们后面要讲的所有快速工具都相关。
swc / oxc —— 同类竞品
swc:Rust 写的转译器,定位对标esbuild,Next.js 早期用过它oxc:更新的 Rust 工具链,tsdown用它生成.d.ts
同样都不做类型检查。
tsx —— 让 Node 直接跑 TS
一个运行时加载器,底层是 esbuild。
npx tsx src/index.ts # 直接跑
npx tsx watch src/index.ts # 改文件自动重启
node --import tsx ./x.ts # 当 loader 挂到 node 上- 不做类型检查
- 同时支持 ESM 和 CJS,不需要你区分
- 读
tsconfig.json,所以paths别名能用(这是它相对 Node 原生的关键优势) - 支持 JSX
- 常被当作 Jest 的 TS loader,替代配置繁琐的
ts-jest
启动开销约 120ms,ts-node 约 300ms。
ts-node —— 老牌运行时
用真正的 TypeScript 编译器来跑,默认会做类型检查,所以慢。
npx ts-node src/index.ts # 会检查类型,慢
npx ts-node --transpile-only src/index.ts # 不检查,快什么时候还离不开它:需要 emitDecoratorMetadata 的框架(典型是 NestJS 的依赖注入)。esbuild / swc / 类型剥离都不产出装饰器元数据,这类项目必须走 tsc 或 ts-node。
Node 原生跑 TS —— 零依赖选项
node --experimental-strip-types src/index.ts # Node 22.6 ~ 22.17
node src/index.ts # Node 22.18+ / 23.6+ / 24,默认可用原理是擦除类型(type stripping),不是编译。三条硬约束:
- 不转换
enum、namespace、参数属性——这些是有运行时产物的语法。要用得加--experimental-transform-types。所以想用原生模式,老老实实开erasableSyntaxOnly - 不读
tsconfig.json——paths别名不生效,需要别名的项目请用tsx - 不处理
node_modules里的 TS——发布到 npm 的包必须提供编译后的 JS
启动开销约 15ms,最快,且零依赖。适合脚本、定时任务、内部工具。
WARNING
三种运行方式都不检查类型。这不是缺陷,是刻意的分工:执行归执行,检查归检查。CI 里必须有独立的 tsc --noEmit 步骤,否则类型错误会一路跑到生产。
vite —— 前端应用的一体化工具
dev :esbuild 转译 + 依赖预打包 + 原生 ESM dev server(毫秒级 HMR)
build:rollup(Vite 7 起可切换 rolldown)不做类型检查。 你在 Vite 项目里看到的类型错误,来自 IDE(背后是 TS 语言服务),不是来自 Vite。所以 Vite 项目的 CI 要跑:
{
"scripts": {
"build": "vue-tsc --noEmit && vite build", // Vue 项目
// 或
"build": "tsc --noEmit && vite build" // React 项目
}
}tsup —— 库打包器(esbuild 底座)
零配置打包 TS 库,一条命令出 ESM + CJS + .d.ts:
npx tsup src/index.ts --format esm,cjs --dts// tsup.config.ts
import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true, // 生成 .d.ts
clean: true,
sourcemap: true,
// dependencies / peerDependencies 默认自动 external,不会被打进产物
})一个容易误解的点:dts: true 这一步不是 esbuild 干的。类型声明仍然要靠 tsc(或 rollup-plugin-dts)生成,所以开了 dts 之后构建会明显变慢——慢的那部分就是类型系统在干活。
tsdown —— tsup 的继任者(rolldown 底座)
官方定位是 "tsup 的精神继任者",底层换成 Rolldown(Rust,Rollup 的兼容替代):
// tsdown.config.ts
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm', 'cjs'],
dts: true,
})相对 tsup 的差异:
| 项 | tsup | tsdown |
|---|---|---|
| 引擎 | esbuild(Go) | Rolldown(Rust)+ Oxc 生成 dts |
| 插件生态 | esbuild 插件 | Rollup / Rolldown / unplugin / 部分 Vite 插件 |
| 默认 format | cjs | esm |
clean 默认 | false | true |
dts | 默认关 | package.json 有 types 字段时自动开 |
| 其他 | — | 内置 workspace 模式、产物校验、CSS、可执行打包 |
迁移有官方命令 npx tsdown-migrate,多数选项直接兼容。
怎么选:新项目追求性能和未来兼容 → tsdown;要最稳、生态插件最全 → tsup。两者配置文件几乎一样,切换成本很低。
unbuild —— unjs 生态的选择
Rollup 底座,Nuxt / Nitro / h3 这些包在用。最大特色是 stub 模式:
npx unbuild --stub它在 dist/ 里生成转发到 src/ 的代理文件,改源码立刻生效,连 watch 都不用跑。本地联调多个包时体验很好。tsdown 明确不支持这个模式。
rollup / webpack / rspack / rolldown
通用打包器。日常不直接配置它们——你用的 vite、tsup、tsdown 已经替你选好了。只有需要精细化控制产物时才直接上手。
四、决策树
五、最容易踩的六个误区
1. "Vite / tsup 会帮我检查类型" 不会。它们的流水线里没有 tsc。你在编辑器里看到的红线是 TS 语言服务给的,跟构建工具无关。所以 npm run build 成功 ≠ 类型没问题。
2. "tsc 会打包" 不会。tsc 是一进一出的转译器,不做依赖合并、不做 tree-shaking。想产出可分发的单文件,需要打包器。
3. "装了 tsx 就不用 tsc 了" 不行。tsx 只负责跑起来,不检查类型。两者是配合关系,不是替代关系。
4. "esbuild 能处理所有 TS 语法" 不能。单文件转译模式下,enum、namespace、参数属性、export = 这些"需要跨文件信息"或"有运行时产物"的语法都有坑。这也是 isolatedModules / verbatimModuleSyntax / erasableSyntaxOnly 这三个开关存在的意义——它们提前把这类写法禁掉。
5. "d.ts 是 esbuild 生成的" 不是。类型声明至今仍然依赖 TypeScript 自己的 API:tsup 内部调 tsc / rollup-plugin-dts,tsdown 用 Oxc。所以开了 dts 之后构建变慢是必然的。
6. "Node 能跑 .ts,那 tsconfig 的 paths 也能用" 不能。Node 的类型剥离完全不读 tsconfig.json。需要 paths 别名就回到 tsx 或打包器。
六、tsconfig 里哪些项真正影响这些工具
| 配置项 | 影响谁 | 说明 |
|---|---|---|
strict 家族 | 只有 tsc | esbuild / swc 直接无视 |
target | tsc 产出;esbuild 用自己的 target | 两边不一致会导致产物行为不同 |
isolatedModules | 所有单文件转译工具 | 不满足就无法用 esbuild / swc / vite |
verbatimModuleSyntax | 同上 | 强制区分 import type |
erasableSyntaxOnly | Node 原生跑 TS | 禁掉 enum / namespace / 参数属性 |
useDefineForClassFields | tsc / esbuild / swc 都要对齐 | 不一致会出现"本地好、线上炸" |
paths | tsc 只影响类型 | 运行时要打包器或 tsx 支持;Node 原生不支持 |
experimentalDecorators + emitDecoratorMetadata | 只有 tsc / ts-node | NestJS 这类框架锁死在 tsc 上 |
一句话总结:tsconfig.json 是给 tsc 和 IDE 看的,其他工具只读其中一小部分(主要是模块解析和少量语法开关)。
七、三套可以照抄的组合
前端应用
{
"scripts": {
"dev": "vite",
"typecheck": "tsc --noEmit", // 或 vue-tsc --noEmit
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
}
}Node 后端服务
{
"scripts": {
"dev": "tsx watch src/index.ts",
"typecheck": "tsc --noEmit",
"build": "tsc", // 产出 dist/
"start": "node dist/index.js" // 生产跑编译后的 JS
}
}npm 库
{
"scripts": {
"dev": "tsdown --watch",
"typecheck": "tsc --noEmit",
"build": "tsdown", // 出 ESM + CJS + d.ts
"prepublishOnly": "npm run typecheck && npm run build"
}
}三者都有一个独立的 typecheck——这条是刻意的。把类型检查从执行/构建里拆出来,既能享受 esbuild 的速度,又不丢 tsc 的严谨。