《TypeScript高级编程》4.1 标准装饰器(TS 5.x)

本节讲 TypeScript 5.x 原生支持的标准(Stage 3)装饰器:说清它与旧版 experimentalDecorators 的语义差异,逐个拆解类、方法、访问器、字段的签名与执行顺序,重点讲 addInitializer 初始化钩子。读完你能用标准语法写不依赖第三方库的包装、注册与校验逻辑,看懂 useDefineForClassFields 等开关如何改变装饰器行为。

本节目标:读完这一节,你能说清 TypeScript 5.x 里「标准装饰器」与旧版 experimentalDecorators 的本质差异,能默写类、方法、访问器、字段、参数五类装饰器的精确签名与执行顺序,会用 addInitializer 在实例构造完成后做初始化,并能判断什么时候只能用访问器装饰器而不能用字段装饰器。最后你会知道 useDefineForClassFields 与 emitDecoratorMetadata 这两个开关是如何悄悄改变装饰器行为的。

4.1 标准装饰器(TS 5.x)

前几章我们一直在编译期打转:类型只存在于源码里,擦除之后什么都不剩(关于这条边界的细节见 1.2 类型擦除与运行时边界 )。从这一章开始,我们转向另一个方向——在运行时改变类与成员的行为。装饰器是这条路径上最成熟的工具,也是最容易被误用的一件。

两个时代:experimentalDecorators 与标准装饰器

TypeScript 从 1.5 起就支持装饰器,但那是基于一份从未定稿的提案(俗称 legacy 或 experimental)。直到 ECMAScript 装饰器提案进入 Stage 3,TypeScript 5.0 才在不加任何编译选项的前提下支持标准语法。两者的差别不是「新旧写法」,而是语义不同:

维度legacy(experimentalDecorators: true)标准(TS 5.x 默认)
提案状态已废弃,永不标准化Stage 3,TC39 推进中
字段装饰器拿不到原型,只能改描述符返回一个初始化函数替换初值
方法装饰器签名(target, key, descriptor)(value, context)
返回值描述符替换值或 void
初始化钩子无context.addInitializer()
元数据反射依赖 emitDecoratorMetadata标准下不再自动发射

如果你在 tsconfig.json 里看到 "experimentalDecorators": true,说明这个项目仍然跑在 legacy 语义上。两者不能混用:同一个项目里同时开启 experimentalDecorators 和标准语法,编译器会以 legacy 为准,而标准装饰器的 context 参数会变成 undefined,报错长得像这样:

// tsconfig.json 里开了 experimentalDecorators: true
function log(value: unknown, context: ClassMethodDecoratorContext) {
  // 💥 TypeError: Cannot read properties of undefined (reading 'kind')
  console.log(context.kind);
}

错误信息是运行时的 Cannot read properties of undefined,而不是编译期报错——这类问题最难查,因为类型检查全绿。迁移的第一条规则就是:一旦决定用标准装饰器,就把 experimentalDecorators 从配置里删掉。

五类装饰器的签名

标准装饰器一共五类,签名高度统一:第一个参数是被装饰的对象,第二个参数是 context(一个携带元信息的对象)。

装饰器位置第一个参数context.kind可返回
类类本身"class"替换类或 void
方法方法函数"method"替换方法或 void
getter / setter访问器函数"getter" / "setter"替换访问器或 void
字段undefined"field"初始化函数或 void
参数(accessor)访问器函数"accessor"替换访问器或 void

注意最后一行不是参数装饰器——标准提案里没有参数装饰器,这是与 legacy 最大的能力缺口之一,做依赖注入时会明显感受到(详见 4.2 reflect-metadata 与依赖注入容器 )。

先看最基础的方法装饰器:

function logCall(value: Function, context: ClassMethodDecoratorContext) {
  if (context.kind !== "method") return;
  return function (this: unknown, ...args: unknown[]) {
    console.log(`调用 ${String(context.name)}`, args);
    return value.apply(this, args);
  };
}

class Counter {
  private count = 0;

  @logCall
  increment(step = 1) {
    this.count += step;
    return this.count;
  }
}

const c = new Counter();
c.increment(3);
// 调用 increment [ 3 ]
// 返回 3

context.name 在标准装饰器里始终是 string 或 symbol,即使成员是私有字段(#count)也会给出 "#count";而 legacy 里私有成员根本装饰不到。

执行顺序:从外到内、从下到上

装饰器的执行顺序是面试与实战的双重高频考点,规则可以压缩成两句话:

  1. 同一个类里:实例成员先于静态成员,字段先于方法,同类成员按定义顺序。
  2. 多个装饰器叠加:求值自上而下,调用自下而上。
function tag(name: string) {
  return (value: unknown, context: ClassMethodDecoratorContext) => {
    console.log(`evaluate ${name} on ${String(context.name)}`);
    return function (this: unknown, ...args: unknown[]) {
      console.log(`run ${name}`);
      return (value as Function).apply(this, args);
    };
  };
}

class Order {
  @tag("A")
  @tag("B")
  method() {
    console.log("body");
  }
}

new Order().method();
// evaluate A on method
// evaluate B on method
// run A
// run B
// body

求值顺序 A → B 是源码书写顺序,而包装顺序是 A 在外、B 在内,所以运行时 A 先打印。把这一点记牢,写日志、埋点、事务装饰器时就不会把嵌套顺序搞反。

装饰器工厂:带参数的装饰器

上面的例子都是「裸装饰器」,直接写 @logCall。但工程里更常见的是装饰器工厂——一个返回装饰器的函数,用来接收配置:

function retry(times: number, delayMs = 0) {
  return function (value: Function, context: ClassMethodDecoratorContext) {
    if (context.kind !== "method") return;
    return async function (this: unknown, ...args: unknown[]) {
      let last: unknown;
      for (let i = 0; i < times; i++) {
        try {
          return await (value as Function).apply(this, args);
        } catch (err) {
          last = err;
          if (delayMs > 0) await new Promise((r) => setTimeout(r, delayMs));
        }
      }
      throw last;
    };
  };
}

class Api {
  @retry(3, 200)
  async fetchUser(id: string) {
    return fetch(`/api/users/${id}`).then((r) => r.json());
  }
}

关键区别:工厂函数在类定义时求值一次,返回的装饰器再参与正常的求值与调用链。所以工厂里做的参数校验、配置解析只跑一次,别把每次调用都要执行的开销放进工厂。还有一个易错点:工厂返回的函数如果是 async,被装饰的方法也会变成返回 Promise 的异步方法,即使原方法是同步的——这会让调用方的返回类型推断发生变化,务必用 Promise<T> 显式标注。

字段装饰器:拿不到原型是刻意的

标准字段装饰器的第一个参数是 undefined,这是提案有意为之——字段在类定义时还不存在,它是在构造实例时逐个初始化的。所以字段装饰器唯一能做的事,就是返回一个「初始化函数」:

function upperCase(value: undefined, context: ClassFieldDecoratorContext) {
  return function (this: unknown, initialValue: string) {
    return initialValue.toUpperCase();
  };
}

class Config {
  @upperCase
  name = "plumephp";
}

console.log(new Config().name); // "PLUMEPHP"

返回的函数会在字段初始化那一刻被调用,this 指向正在构造的实例,参数是字段的初值。这带来一个能力:你可以基于其他字段计算当前字段——前提是字段顺序正确(初始化按声明顺序执行)。

如果你需要「读时拦截」而不是「初始化时改写」,字段装饰器做不到,必须改用访问器装饰器:

function clamp(max: number) {
  return function (value: { get: () => number; set: (v: number) => void }, context: ClassGetterDecoratorContext) {
    return function (this: { _n: number }) {
      return Math.min(value.get.call(this), max);
    };
  };
}

一句话总结选型:改初值用字段装饰器,改读写行为用访问器装饰器。

addInitializer:标准装饰器独有的钩子

context.addInitializer 是标准提案相对 legacy 最大的增强。它允许装饰器注册一个回调,在「实例构造完成后」或「类定义完成后」执行,这正好补上了字段装饰器无法访问实例方法的时间差:

function autoBind(value: Function, context: ClassMethodDecoratorContext) {
  if (context.kind !== "method") return;
  context.addInitializer(function (this: any) {
    this[context.name] = this[context.name].bind(this);
  });
}

class Button {
  label = "ok";

  @autoBind
  onClick() {
    console.log(this.label);
  }
}

const btn = new Button();
const handler = btn.onClick;
handler(); // "ok" —— 没加 @autoBind 时会报 this 为 undefined

对静态成员调用 addInitializer,回调会在类定义完成之后执行;对实例成员调用,则在每次构造时执行。React 类组件时代常见的自动绑定、事件注册、订阅初始化,都可以用它替代构造函数里的样板代码。

参数装饰器与 accessor 关键字

标准装饰器不支持参数装饰器;accessor 面向类成员访问器,与构造器参数注入不是同一种机制。accessor x = 1 会生成一对真正的 getter/setter,并允许被装饰:

function trace<This, Value>(
  value: ClassAccessorDecoratorTarget<This, Value>,
  context: ClassAccessorDecoratorContext<This, Value>,
): ClassAccessorDecoratorResult<This, Value> {
  return {
    get(this: This) {
      return value.get.call(this);
    },
    set(this: This, v: Value) {
      console.log(`set ${String(context.name)} = ${String(v)}`);
      value.set.call(this, v);
    },
  };
}

class Store {
  @trace
  accessor count = 0;
}

const s = new Store();
s.count = 42;
// set count = 42

注意 accessor 装饰器返回的是对象(可含 get / set / init),而方法装饰器返回的是函数——这是两种不同的替换协议,别混淆。

常见坑与报错对照

现象原因处理
context 为 undefinedtsconfig 开了 experimentalDecorators删掉该选项
字段装饰器里读 this.name 报 undefined字段尚未初始化改用返回初始化函数
装饰器写在参数上编译报错标准语法无参数装饰器改装饰类成员或方法
字段初值被覆盖useDefineForClassFields 语义差异明确该开关,或用访问器
私有字段 #x 装饰不到(legacy)legacy 不支持私有成员升级到标准装饰器
元数据为 Object类型擦除导致 design:type 退化显式传 token,见下一节

其中 useDefineForClassFields 最隐蔽:它为 true(ES2022 及以上 target 的默认值)时,字段用 Object.defineProperty 定义,会遮蔽基类同名访问器;为 false 时用赋值语义,会触发 setter。同一个装饰器在两套语义下的行为可能完全不同,所以升级 target 时必须回归测试。

为什么不用第三方库

在标准装饰器普及之前,社区普遍依赖 reflect-metadata + experimentalDecorators 的组合,这套组合在 NestJS、TypeORM 里运转良好,但也把项目锁死在 legacy 语义上。标准装饰器的优势是:

  • 零运行时依赖:context 由引擎提供,不需要 polyfill。
  • 可被引擎原生优化:V8 对标准装饰器有专门的内联路径,装饰器不再是纯 JS 函数调用开销。
  • 面向未来:TC39 定稿后不会被废弃。

代价是没有参数装饰器和自动元数据。这正好是下一节的主题:当标准语法不够用时,如何用 reflect-metadata 手工把类型信息带到运行时。想先看工程化全景的读者,可以延伸阅读 TypeScript 装饰器与元编程 ,以及 TypeScript 进阶工程实践 里关于编译选项组合的讨论。

小结

这一节我们完成了从 legacy 到标准装饰器的切换:

  • 语义差异:标准装饰器用 (value, context) 两参数签名替代 (target, key, descriptor),且不能与 experimentalDecorators 共存。
  • 五类装饰器:类、方法、访问器、字段、accessor,各自的第一个参数与返回协议都不同。
  • 执行顺序:求值自上而下,包装自下而上;字段先于方法,实例先于静态。
  • addInitializer:标准装饰器独有的钩子,用于自动绑定、订阅注册等构造后初始化。
  • 两个开关:experimentalDecorators 决定语义,useDefineForClassFields 决定字段的读写行为。

但标准装饰器缺了关键一环:它拿不到类型信息。装饰器只知道「有个方法叫 increment」,不知道它的参数是 number 还是 UserService。而依赖注入、自动校验、序列化框架全部建立在这类信息之上。下一节我们就把这缺失的一块补上——用 reflect-metadata 把编译期的类型搬到运行时,并亲手写一个 DI 容器。

阅读导航:上一节:3.3 复杂泛型的重构手法 · 下一节:4.2 reflect-metadata 与依赖注入容器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

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