esbuild 编译 TypeScript 时,装饰器和类型注解能走到哪一步,哪些场景必须回头用 Babel
esbuild 对 TypeScript 的装饰器和类型注解支持是「够用但不完整」:装饰器默认只支持 TC39 最新规范语法,旧的 experimentalDecorators 行为与 tsc/Babel 有差异;类型注解只负责剥离、不做任何类型检查。如果你重度依赖 emitDecoratorMetadata、旧版装饰器语义、或者需要类型信息参与运行时行为,就必须回头用 Babel 或 tsc。
装饰器:规范版本是分水岭
esbuild 从 0.21.0 开始支持 TC39 Stage 3 装饰器(2023 年 5 月定稿的版本),语法是 @dec 直接修饰类、方法、字段、访问器。这个支持不需要任何额外配置,开箱即用。
但问题在于,绝大多数现有 TypeScript 项目里写的装饰器是「旧版实验性装饰器」——也就是 Angular、NestJS、TypeORM、InversifyJS 这些框架用的那套。旧版装饰器依赖 experimentalDecorators 编译选项,编译产物里会生成 __decorate、__param、__metadata 这些 helper 函数。esbuild 对这套旧版语义的支持是残缺的。
具体来说,esbuild 处理旧版装饰器时有三个明确的坑:
第一,emitDecoratorMetadata 完全不支持。 这个选项让编译器在装饰器执行时注入 design:type、design:paramtypes、design:returntype 元数据,NestJS 的依赖注入、TypeORM 的实体列类型推断都靠它。esbuild 直接忽略这个选项,编译产物里不会有任何 Reflect.metadata 调用。你可以在 esbuild 的 GitHub issue #257 里看到这个特性请求从 2020 年挂到现在,Evan Wallace 本人的态度是「不会实现,因为 esbuild 不做类型信息解析」。
第二,旧版装饰器对参数装饰器的编译结果不完整。 旧版规范里参数装饰器需要 __param helper 来记录参数索引,esbuild 能生成基本的 __param 调用,但当参数装饰器和方法装饰器同时出现时,生成的调用顺序与 tsc 不一致。这个问题在依赖注入场景里会直接导致运行时拿到的参数索引错位。
第三,装饰器表达式求值时机有差异。 旧版 TypeScript 规范要求装饰器表达式按定义顺序求值,esbuild 在某些嵌套场景下会改变求值顺序,特别是装饰器工厂函数带副作用时,行为会和 tsc/Babel 不同。
实际测试方法很简单:拿一个 NestJS 项目,把 ts-loader 或 babel-loader 换成 esbuild-loader,直接跑 nest build,依赖注入会在启动时抛 Cannot resolve dependency 或者静默注入 undefined。这不是配置问题,是 esbuild 根本不产出元数据。
类型注解:只擦除,不检查
esbuild 对类型注解的处理非常干脆:全部剥离,不做任何类型检查。interface、type、as 断言、泛型参数、readonly 修饰符、implements 子句,全部在编译时直接丢弃。const enum 会被当作普通 enum 处理(esbuild 不内联常量枚举),namespace 会被转换成 IIFE。
这意味着如果你想用 esbuild 替代 Babel 做类型擦除,行为基本一致——Babel 的 @babel/preset-typescript 也是纯擦除策略。但有几个边界场景需要留意:
const enum 的内联行为缺失。 tsc 默认会把 const enum 的成员值直接内联到使用处,esbuild 不会。如果你的代码里有 const enum Status { Active = 1 } 然后写 Status.Active,esbuild 编译产物里会保留完整的枚举对象定义和属性访问,体积比 tsc 产物大,运行时行为也不同(tsc 产物里 Status.Active 在运行时是字面量 1,esbuild 产物里是属性查找)。
旧版 import = 语法。 import foo = require('foo') 和 export = 是 TypeScript 特有的模块语法,esbuild 支持编译它们,但在 ESM 输出格式下会生成不兼容的代码。如果你的 tsconfig 里 module 设为 ESNext 且代码里混用了 export =,esbuild 编译产物在 Node ESM 环境下会直接报错。
装饰器元数据之外,还有 emitDecoratorMetadata 的另一个隐藏依赖:design:type 用于序列化推断。 class-transformer、class-validator 这些库在无显式类型参数时依赖元数据判断目标类型。esbuild 编译后这些库会静默回退到 Object 类型,导致校验和转换行为错误。
什么场景 esbuild 能完全替代 Babel
不是所有装饰器场景都不可用。以下情况 esbuild 可以直接上:
- 用 TC39 Stage 3 装饰器写新代码,不依赖旧版实验性语法
- 用旧版装饰器但不依赖
emitDecoratorMetadata,且装饰器只作用于类和方法(不涉及参数装饰器) - 类型注解只用于开发期提示,运行时不需要任何类型信息
const enum使用量小,或者体积差异可接受
比如 Vite 的默认 React/Vue 模板用 esbuild 做 TS 编译就完全没问题,因为这些场景里装饰器用得极少甚至不用,类型注解纯擦除即可。Lit 的 TS 装饰器用法在 esbuild 下也能工作,因为 Lit 的 @customElement、@property 装饰器只做类级别的注册操作,不需要参数元数据。
必须回退 Babel 或 tsc 的场景清单
| 场景 | 原因 | 替代方案 |
|---|---|---|
| NestJS / InversifyJS 依赖注入 | 需要 emitDecoratorMetadata 生成 design:paramtypes |
用 Babel 的 @babel/plugin-transform-typescript + babel-plugin-transform-typescript-metadata,或用 tsc 编译 |
| TypeORM 实体定义依赖类型推断 | 同上,design:type 元数据缺失导致列类型错误 |
显式声明所有列类型(@Column({ type: 'varchar' }))可以绕开,但改动量大 |
| class-validator / class-transformer 自动推断 | 运行时需要 design:type 判断目标类型 |
显式传入类型参数(plainToInstance(User, plain) 改为 plainToInstance(User, plain, { targetType: User })) |
| 旧版参数装饰器 + 方法装饰器组合 | esbuild 的 __param 生成顺序与 tsc 不一致 |
回退 tsc 或 Babel |
const enum 内联优化 |
esbuild 不内联,产物体积和运行时行为有差异 | 改用普通 enum 或字面量联合类型 |
| 需要类型检查的编译流程 | esbuild 不做类型检查 | 用 tsc --noEmit 单独跑检查,esbuild 只管转译 |
一个务实的工程方案
如果你已经用 esbuild 做构建,但项目里有 NestJS 或 TypeORM 的装饰器依赖,别硬改。最务实的方案是:esbuild 处理绝大部分 TS 转译,装饰器重灾区单独走 Babel 或 tsc。
具体操作是在 esbuild 的 loader 配置里对包含装饰器元数据依赖的文件做排除,让这些文件走 babel-loader(Webpack 场景)或者直接用 tsc 编译这些模块再让 esbuild 打包产物。Vite 生态里可以用 @vitejs/plugin-legacy 或者自定义插件做文件级别的编译分流。
另一个做法是放弃 emitDecoratorMetadata,手动注入元数据。NestJS 从 v8 开始支持 @Inject('TOKEN') 显式注入,TypeORM 支持显式列类型声明,class-validator 支持显式类型参数。改动量取决于项目规模,但换来的构建速度提升(esbuild 转译比 tsc 快 20-50 倍)在大型项目里是实打实的。
常见问题
esbuild 支持 experimentalDecorators 吗?
不支持完整语义。esbuild 能解析旧版装饰器语法并生成基本编译产物,但不支持 emitDecoratorMetadata,参数装饰器与 tsc 行为有差异。如果你的装饰器只做类级别操作(比如 Lit 的 @customElement),可以用;如果依赖元数据注入(NestJS、TypeORM),不能用。
用 esbuild 编译 TS 需要额外装类型检查工具吗?
需要。esbuild 只做转译不做类型检查,类型错误会在运行时才暴露。建议在 CI 或 pre-commit 阶段跑 tsc --noEmit 做类型检查,esbuild 负责快速转译。
esbuild 和 Babel 编译 TS 的速度差距有多大?
以 1000 个中等规模 TS 文件为例,esbuild 转译通常在 200-500ms 完成,Babel 单线程需要 5-15 秒,tsc 需要 20-60 秒。esbuild 的优势来自 Go 实现的并行解析和零 AST 转换开销,但代价就是不做类型信息处理。
const enum 在 esbuild 下会报错吗?
不会报错,会被当作普通 enum 编译。如果你在 isolatedModules 模式下用 tsc 检查,const enum 会触发警告,因为 tsc 知道 esbuild 无法内联。建议直接避免使用 const enum,改用字面量联合类型或普通对象常量。