Menu

TypeScript 装饰器:方法、类、字段与访问器装饰器

装饰器是用 @ 语法包装或替换类成员的函数。本文讲解 TypeScript 无需任何开关就支持的标准装饰器(类、方法、getter、字段和 accessor)、装饰器工厂、addInitializer,以及它们与 Angular 和 NestJS 使用的旧版 experimentalDecorators 有何不同。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

装饰器是一个用 @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 / settergetter 或 setterClassGetterDecoratorContext / ClassSetterDecoratorContext替代的 getter 或 setter
字段undefinedClassFieldDecoratorContext一个映射初始值的函数
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 在最外层执行。

Coddy 编程语言插图

和 Coddy 一起学编程

开始