装饰器是一个用 @name 附加到类或类成员上的函数。它接收原始的方法(或类、字段)以及一个描述它的上下文对象,并且可以返回一个替代品。TypeScript 无需任何编译器开关就支持标准装饰器。
@logged 只在类定义时运行一次,用它返回的包装函数替换掉 add。之后每次调用都会经过这个包装函数。泛型参数保留了方法的 this、参数和返回值类型,所以 add 仍然接收两个数字并返回一个数字。
装饰器如何编译
标准装饰器来自 JavaScript 的一项 TC39 提案,TypeScript 从 5.0 版本起实现了它。这项提案还不是 JavaScript 标准的一部分,Node 24 也无法解析 @ 语法,所以当目标是 ES2022 时(本站页面就是如此),编译器会把每个被装饰的类改写成调用辅助函数的普通 JavaScript(辅助函数 __esDecorate 和 __runInitializers 会生成在文件顶部)。输出可以在任何支持 ES2022 的环境中运行。
由此带来一个后果:含有装饰器的文件无法在 Node 内置的类型剥离下运行(node file.ts),因为它只删除类型,会把 @ 原样留下。Node 会报 SyntaxError: Invalid or unexpected token 并停止。请先用 tsc 或打包工具编译。
装饰器的种类和签名
每个标准装饰器的形式都是 (value, context) => replacement | void。value 是什么、可以返回什么,取决于装饰的对象:
| 装饰对象 | value | 上下文类型 | 返回值 |
|---|---|---|---|
| 类 | 类本身 | ClassDecoratorContext | 替代的类,或不返回 |
| 方法 | 方法本身 | ClassMethodDecoratorContext | 替代的方法 |
| getter / setter | getter 或 setter | ClassGetterDecoratorContext / ClassSetterDecoratorContext | 替代的 getter 或 setter |
| 字段 | undefined | ClassFieldDecoratorContext | 一个映射初始值的函数 |
accessor 字段 | { get, set } | ClassAccessorDecoratorContext | { get?, set?, init? } |
每个上下文对象都有 kind、name 和 addInitializer。类成员的上下文还有 static、private,以及一个用于从实例读取该成员的 access 对象。装饰器同样适用于静态成员和 #private 成员。
装饰器工厂
要传入选项,就写一个返回装饰器的函数,并在 @ 处调用它。这就是装饰器工厂:
@retry(3) 会先调用 retry,它返回的函数才是真正的装饰器。多个装饰器可以叠加:在 @a @b method() 中,b 先被应用,a 再包装它的结果。
类装饰器与 addInitializer
类装饰器接收类本身。它可以返回一个子类来替换原来的类,也可以什么都不返回,只是在某处把它记录下来。context.addInitializer 注册一段在特定时刻运行的代码:对于类装饰器,是在类完全定义之后立即运行;对于方法装饰器,是在构造每个实例时运行。
类装饰器写在类的上方(或 export 之后)。如果没有 @bound,调用 loose() 会抛出错误,因为 this 会是 undefined。
字段装饰器与访问器装饰器
字段装饰器无法看到或拦截之后的赋值:它的 value 是 undefined,唯一能返回的是一个转换字段初始值的函数。要拦截读写,就用 accessor 关键字声明字段,它会把字段变成一对由私有存储支撑的 getter 和 setter,然后再装饰它:
accessor 属于同一项提案。它会在一个 #private 字段之上生成真正的 getter 和 setter,所以 p.price = -5 会经过装饰器的 set。
标准装饰器与旧版 experimentalDecorators
在 TypeScript 5.0 之前,TypeScript 唯一的装饰器是这项提案的早期版本,需要用 experimentalDecorators 开启。这个开关至今仍然存在,它会把编译器切换到旧版模型,签名和语义都不同:
| 标准(无需开关) | 旧版(experimentalDecorators) | |
|---|---|---|
| 签名 | (value, context) | (target, propertyKey, descriptor) |
| 如何修改方法 | 返回一个新函数 | 修改 descriptor.value |
| 参数装饰器 | 不支持(TS1206) | 支持 |
emitDecoratorMetadata | 不支持 | 支持(通过 reflect-metadata 提供运行时类型信息) |
accessor 装饰器({ get, set, init })、addInitializer | 支持 | 不支持 |
| 基于 | TC39 提案 | 提案的早期草案 |
为一种模型编写的装饰器在另一种模型下无法通过类型检查。下面是在没有开启该开关的项目中使用旧版风格的装饰器:
index.ts(11,5): error TS1241: Unable to resolve signature of method decorator when called as an expression.
The runtime will invoke the decorator with 2 arguments, but the decorator expects 3.
修复方法有两种:一是像本页第一个示例那样,用标准的 (value, context) 形式重写它;二是为整个项目开启旧版模型:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Angular、NestJS 和 TypeORM 生成的配置中仍然设置了 experimentalDecorators,它们的文档也要求这样做。NestJS 和 TypeORM 还需要 emitDecoratorMetadata,因为它们在运行时读取类型:NestJS 用它来注入构造函数参数,TypeORM 用它把属性映射到列:
// Legacy model: needs experimentalDecorators (and emitDecoratorMetadata for DI).
@Injectable()
class UsersService {
constructor(@Inject(DB) private db: Database) {}
}
如果你在使用这些框架之一,就按旧版方式编写装饰器,并遵循框架的文档。对于不依赖这类框架的新代码,请使用标准装饰器。
何时使用装饰器
装饰器适合那些否则会在许多方法中重复出现的横切行为:日志、计时、缓存、重试、权限检查、校验,以及把类注册到容器或路由中。它们也会隐藏控制流,因为读代码的人得先查清 @retry 做了什么,才能知道这个方法做了什么。如果只用一两次,普通的高阶函数(const fetchData = retry(3, rawFetch))更简单,而且在类之外也能用。
常见问题
TypeScript 中的装饰器是什么?
装饰器是一个用 @name 语法应用到类或类成员上的函数。它接收被装饰的东西和一个上下文对象,并且可以返回一个替代品:包装后的方法、一个新类,或者一个转换字段初始值的函数。常见用途有日志、校验、缓存和注册类。
在 TypeScript 中使用装饰器需要开启 experimentalDecorators 吗?
不需要。从 TypeScript 5.0 开始,标准(TC39)装饰器无需任何开关即可使用。experimentalDecorators 会把编译器切换到更早的旧版装饰器模型,Angular 和 NestJS 等框架就是基于它构建的。两者的函数签名不同,不能互换。
标准装饰器和 experimentalDecorators 有什么区别?
标准装饰器接收 (value, context) 并返回一个替代品。旧版装饰器接收 (target, propertyKey, descriptor) 并修改属性描述符。只有旧版模型支持参数装饰器和 emitDecoratorMetadata;只有标准模型有返回 { get, set, init } 的 accessor 装饰器和 context.addInitializer。
TypeScript 支持参数装饰器吗?
只有开启 experimentalDecorators 时才支持。在标准模式下,参数上的装饰器会报 TS1206: Decorators are not valid here 错误,因为 TC39 提案不包含参数装饰器。因此,需要装饰构造函数参数的依赖注入框架必须使用旧版开关。
多个装饰器按什么顺序应用?
装饰器表达式从上到下求值,但从下到上应用:在 @a @b method() 中,b 先包装方法,a 再包装它的结果。所以调用方法时,a 在最外层执行。