文章

装饰器与元数据编程

面试向讲清 TypeScript 装饰器:legacy 与 Stage 3 两套实现的签名差异、context 上下文、accessor 自动访问器、执行顺序、emitDecoratorMetadata 与 Symbol.metadata,以及 NestJS/Angular 的迁移现状。

装饰器与元数据编程

一句话概括

装饰器(Decorator)就是一个在类定义时执行的函数,用来给类、方法、字段”贴标签、包一层、挂元数据”。它解决的是横切关注点的问题:日志、缓存、鉴权、埋点、依赖注入这些跟业务无关的事,用 @log @cache @auth 一行挂上去,比在方法体里手写一堆包装代码干净得多。

但这题在 2026 年有个大坑,也是面试官最爱追问的:TypeScript 里存在两套完全不同的装饰器实现。老的叫 legacy / experimental(靠 experimentalDecorators: true 开启,Angular、NestJS、TypeORM 都依赖它),新的叫 Stage 3 标准装饰器(TS 5.0 起默认支持,不用任何开关)。语法一模一样,底层调用签名完全不同,而且不能在同一份 tsconfig 里混用。

记住这条主线就能撑起整个回答:新项目用 Stage 3((value, context),不用开关);NestJS/Angular/TypeORM 项目必须留在 legacy(要 experimentalDecorators + emitDecoratorMetadata + import "reflect-metadata")。

核心知识点

1. 两套装饰器:一张表看清差异

对比项Legacy(实验性)Stage 3(标准,TS 5.0+)
开启方式experimentalDecorators: true无需开关,默认可用
函数签名(target, propertyKey, descriptor)(value, context)
返回值返回新的 PropertyDescriptor返回同类型的替换值,或 undefined 表示不替换
参数装饰器✅ 支持❌ 不支持(提案里被砍了)
自动类型元数据✅ emitDecoratorMetadata❌ 不支持,改用 context.metadata
accessor 关键字❌✅ 自动访问器
典型使用者Angular、NestJS、TypeORM、MobX 老版本新项目、新库

签名上的差异一眼就能看出来,写错了在运行前很难察觉——因为参数个数对不上时 TS 有时只能给你一个含糊的类型错误,有时干脆静默跑歪:

1
2
3
4
5
6
7
8
9
// ❌ 新项目里照着老教程写 legacy 签名:没开 experimentalDecorators 时参数全对不上
function log(target: any, key: string, descriptor: PropertyDescriptor) {
  const original = descriptor.value; // 标准装饰器下 descriptor 是 context,这里是 undefined
}

// ✅ 标准装饰器统一 (value, context),不需要任何 tsconfig 开关
function log(value: Function, context: ClassMethodDecoratorContext) {
  return function (this: any, ...args: any[]) { return value.apply(this, args); };
}

最关键的一条:experimentalDecorators: true 是全项目级别的开关。只要它开着,整个项目(包括你新写的标准装饰器)都会走 legacy 那套降级逻辑,很容易出现”我这装饰器怎么拿不到 context”的诡异问题。想两套共存只能拆成两个 tsconfig + 不同的 include 目录。

2. Legacy 装饰器签名:操作属性描述符

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 方法装饰器:拿到原型、方法名、属性描述符,改 descriptor 就生效
function log(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
): PropertyDescriptor {
  const original = descriptor.value;
  descriptor.value = function (...args: any[]) {
    console.log(`[log] ${propertyKey}`, args);
    return original.apply(this, args); // 别丢 this 和返回值
  };
  return descriptor;
}

class OrderService {
  @log
  place(orderId: string) { return "ok"; }
}

三种位置的入参要记住:类装饰器只收构造函数;方法/访问器装饰器收 (原型, 名字, descriptor);属性装饰器只收 (原型, 名字),没有 descriptor(这也是为什么属性装饰器在旧版里基本干不了实事,只能配合 reflect-metadata 挂类型)。

3. Stage 3 装饰器签名:value + context

标准装饰器统一是 (value, context),context 告诉你”你在装饰什么”:

1
2
3
4
5
6
7
8
9
10
11
12
function log(value: Function, context: ClassMethodDecoratorContext) {
  const name = String(context.name); // name 可能是 symbol,转字符串更稳
  return function (this: any, ...args: any[]) {
    console.log(`[log] ${name}`, args);
    return value.apply(this, args);
  };
}

class OrderService {
  @log
  place(orderId: string) { return "ok"; }
}

context 里这些字段建议背下来,面试追问高频:

字段含义
kind"class" / "method" / "getter" / "setter" / "field" / "accessor"
name成员名,可能是 string 或 symbol
static / private是否静态成员 / 是否 # 私有成员
access{ get, set },用于在实例上读写该成员的最终值
addInitializer(fn)注册回调,在每个实例构造时执行(this 是实例)
metadataTS 5.2+:同一个类内所有装饰器共享的元数据对象

addInitializer 是标准装饰器最重要的新增能力——legacy 时代想在构造时做点事得去改 descriptor 或 patch 构造函数,现在一句话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 经典手写题:@bound 自动绑定 this,解决"方法传出去后 this 丢失"
function bound(value: Function, context: ClassMethodDecoratorContext) {
  context.addInitializer(function (this: any) {
    this[context.name] = this[context.name].bind(this);
  });
  return value;
}

class Button {
  label = "Submit";
  @bound
  handleClick() { console.log(this.label); }
}

const fn = new Button().handleClick;
fn(); // ✅ "Submit",不写 @bound 这里 this 是 undefined

4. field 装饰器返回”初始化函数”,accessor 是新关键字

这两个点最容易答错,务必分清:

1
2
3
4
5
6
7
8
9
10
// 字段装饰器:value 恒为 undefined(类定义时字段还没值),
// 所以你不返回值,而是返回一个"每个实例构造时都跑一次"的初始化函数
function double(_value: undefined, context: ClassFieldDecoratorContext) {
  return (initial: number) => initial * 2;
}

class Box {
  @double count = 10;
}
new Box().count; // 20

accessor 是 Stage 3 新加的类成员关键字,声明”自动访问器”——背后是私有存储 + 自动生成的 getter/setter。普通 field 装饰器拦不到读写,只有 accessor 能:

1
2
3
4
5
6
7
8
9
class Counter {
  accessor count = 0;
}
// 等价于:
class Counter2 {
  #count = 0;
  get count() { return this.#count; }
  set count(v) { this.#count = v; }
}

访问器装饰器收到 { get, set },返回同形状的对象(还能加 init 处理初始值),这是做响应式、埋点、校验的标准姿势:

1
2
3
4
5
6
7
8
9
10
function observed(value: { get: () => any; set: (v: any) => void }, context: ClassAccessorDecoratorContext) {
  return {
    get(this: any) { return value.get.call(this); },
    set(this: any, v: any) {
      console.log(`${String(context.name)} 变为 ${v}`);
      value.set.call(this, v);
    },
    init(initial: any) { return initial; },
  };
}

5. 执行顺序:求值自上而下,调用自下而上

这一点两套装饰器是一致的,也是标准追问题:

1
2
3
4
5
6
7
8
9
10
11
function a(tag: string) {
  console.log(`求值 @${tag}`);
  return () => console.log(`调用 @${tag}`);
}

class C {
  @a("上")
  @a("下")
  method() {}
}
// 输出:求值 @上 → 求值 @下 → 调用 @下 → 调用 @上

装饰器工厂(外层函数)从上往下求值,返回的内层装饰器从下往上调用。所以最终效果是”上面的装饰器包在外层”:@上 包装了 @下 包装过的结果。多个装饰器叠加时想明白谁在外层,靠的就是这条。

跨成员类型的顺序是:实例成员 → 静态成员 → 类装饰器最后执行(legacy 里还多了参数装饰器优先于对应成员)。addInitializer 注册的回调同样按注册顺序在构造时执行。

6. 元数据:legacy 靠 reflect-metadata,标准靠 Symbol.metadata

这是”元数据编程”这个标题的另一半,也是 DI 框架的核心原理。

Legacy 路线:tsconfig 开 emitDecoratorMetadata(它依赖 experimentalDecorators,只开前者会报错),TS 会在编译产物里插入 Reflect.metadata(...) 调用,写入三个 key:

key内容
design:type属性的类型
design:paramtypes方法(含构造函数)的参数类型数组
design:returntype方法的返回值类型
1
2
3
4
5
6
7
import "reflect-metadata"; // 必须在入口最顶部引入,提供 Reflect.getMetadata 实现

class UserService {
  constructor(private db: Database) {}
}

Reflect.getMetadata("design:paramtypes", UserService); // [Database]

NestJS 的 DI 就是靠 design:paramtypes 知道”构造函数第一个参数要注入 Database 类”的。所以 emitDecoratorMetadata 一关,NestJS 的依赖注入、路由参数绑定、class-validator 会集体失效——这就是那篇著名事故文章的由来。

两个必背的边界:

  1. 元数据只在”有装饰器的声明”上生成。构造函数没加任何装饰器?那 design:paramtypes 就没有。所以 @Injectable() 这类”看起来啥也没干”的装饰器,真正的职责是触发元数据发射。
  2. 接口类型会被擦成 Object。TS 编译后接口不存在,design:type 只能拿到类/基本类型的构造函数,接口、类型别名、泛型参数一律变成 Object。这也是为什么 DI 必须注入 class 而不能注入 interface。

标准路线:Stage 3 没有 emitDecoratorMetadata,改用 TS 5.2 引入的 context.metadata——同一个类内所有装饰器共享一个对象,装饰器们往里写,最后通过 Symbol.metadata 挂在类上:

1
2
3
4
5
6
7
8
9
10
function serialize(_value: any, context: ClassFieldDecoratorContext) {
  context.metadata[context.name] = true; // 共享对象,直接当字典用
}

class Person {
  firstName = "";
  @serialize age = 0;
}

Person[Symbol.metadata]; // { age: true }

它比 reflect-metadata 简单得多(就是个普通对象,还能当 WeakMap 的 key),但只有你显式写进去的信息,没有编译器自动推导的类型。目前标准装饰器的元数据是”手动挡”,legacy 的 design:paramtypes 是”自动挡”——这也是 NestJS 暂时迁移不过去的核心原因。

7. 什么时候用什么:现状与迁移建议

  • 新起项目、不依赖 Angular/NestJS/TypeORM → 直接用 Stage 3,别加 experimentalDecorators。
  • NestJS(10/11)、TypeORM、class-validator、Angular 19 之前 → 必须保留 legacy 三件套:experimentalDecorators: true、emitDecoratorMetadata: true、入口 import "reflect-metadata"。别因为编辑器提示”该选项已过时”就手贱删掉,删了服务就起不来。
  • 想渐进迁移 → 拆两份 tsconfig,框架相关代码留在 legacy 目录下,新写的工具类装饰器用标准版,靠 references 拼起来。
  • 写库给别人用 → 现阶段最稳的是干脆不提供装饰器,让用户自己套;或者运行时判断第二个参数是不是带 kind 的 context 对象来分发(TS 自己的测试就这么干)。

8. 手写装饰器时的通用坑

  • 装饰器在类定义时执行一次,不是每次实例化执行。想在每次 new 的时候做点事,用 addInitializer。
  • 返回值的类型必须和 kind 匹配:方法装饰器返回函数、字段装饰器返回初始化函数、类装饰器返回类。返回类型不匹配在部分 target 下会静默失败,非常难查。
  • 类装饰器不能改变 class 的”种类”(不能把 class 变成别的),也不能用在 .d.ts 或 declare class 上。
  • 返回新类要自己维护原型链,运行时不会帮你接。
  • target 建议设成 ES2022 及以上,标准装饰器在过低 target 下降级产物会更复杂。
  • 别在渲染循环、每帧调用的热路径方法上挂装饰器,多一层函数调用是有成本的。

其实你每天都在用

  • NestJS 全家桶:@Controller()、@Get()、@Injectable()、@Body()——全靠 legacy 装饰器 + design:paramtypes 元数据。
  • TypeORM / Prisma 之前的实体定义:@Entity()、@Column(),列类型就是从 design:type 推出来的。
  • class-validator 的 @IsEmail() @MinLength(6):校验规则挂在元数据上,运行时再统一读。
  • Vue 生态的 vue-class-component / vue-property-decorator:@Component @Prop,老项目里还能见到。
  • MobX 的 @observable @action:字段/方法装饰器标记响应式与事务边界。
  • Angular 的 @Component({...}) @Input():整个框架都建立在装饰器 + 元数据之上。
  • 自己封装的 @debounce(300)、@throttle()、@catchError():横切逻辑外挂,方法体保持干净。
  • React 类组件时代的 @withRouter @connect:HOC 的装饰器写法。
  • Storybook、Jest 里的 @story、@test(配置启用后),也是同一套元数据思路。

常见误解(FAQ)

❌ 误区1:”装饰器是 TS 独占特性,JS 没有。” Stage 3 装饰器是 ECMAScript 提案(目前 Stage 3),已经是标准轨道上的 JS 特性,TS 5.0 只是提前实现。真正”TS 独占”的是 legacy 那套实验性实现。

❌ 误区2:”新项目直接删掉 experimentalDecorators,反正 TS 5 支持标准装饰器了。” 只要项目里用了 NestJS / TypeORM / class-validator / Angular(19 前),删掉这个开关会导致 DI 解析失败、路由参数绑定失效、校验静默通过。这个 flag 看起来过时,但生态还依赖它,官方也没宣布移除。

❌ 误区3:”emitDecoratorMetadata 在标准装饰器下也能用。” 不能,它是 legacy 专属(依赖 experimentalDecorators)。标准装饰器下 Reflect.getMetadata("design:type", ...) 只会拿到 undefined。想要元数据就用 context.metadata + Symbol.metadata。

❌ 误区4:”字段装饰器能拿到字段的值。” 拿不到。字段装饰器执行时类刚定义,实例都还没创建,所以 legacy 里属性装饰器拿不到 descriptor,标准里 value 恒为 undefined。想干预值就返回初始化函数(标准)或用 accessor(标准)。

❌ 误区5:”接口也能作为依赖注入的类型,因为元数据会记下来。” 不行。接口编译后不存在,design:paramtypes 里只会是 Object。DI 必须注入类(或有 InjectionToken 之类的显式令牌)。

❌ 误区6:”多个装饰器是从上往下依次生效的。” 工厂求值自上而下,装饰器调用自下而上。写在最上面的装饰器最终包在最外层,这个顺序在组合日志 + 计时 + 鉴权时非常关键。

❌ 误区7:”装饰器里拿不到实例,没法做实例级初始化。” 标准装饰器有 addInitializer,回调里的 this 就是正在构造的实例,@bound 就是靠它实现的。legacy 时代确实麻烦,这也是标准版的改进点之一。

❌ 误区8:”import "reflect-metadata" 可有可无,编译能过就行。” 不开 emitDecoratorMetadata 时确实无所谓;一旦开了而没引这个 polyfill,Reflect.getMetadata 就是 undefined,编译一切正常、运行时直接崩。它必须在入口文件最顶部、在所有装饰器被使用之前引入。

一句话总结

装饰器就是类定义时执行的包装函数——新项目用 Stage 3 的 (value, context),老框架留在 legacy 的 (target, key, descriptor);前者靠 context.metadata / Symbol.metadata 手动挂元数据,后者靠 emitDecoratorMetadata + reflect-metadata 自动发射 design:paramtypes;工厂求值自上而下、装饰器调用自下而上。

本文由作者按照 CC BY 4.0 进行授权