装饰器与元数据编程
面试向讲清 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 是实例) |
metadata | TS 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 会集体失效——这就是那篇著名事故文章的由来。
两个必背的边界:
- 元数据只在”有装饰器的声明”上生成。构造函数没加任何装饰器?那
design:paramtypes就没有。所以@Injectable()这类”看起来啥也没干”的装饰器,真正的职责是触发元数据发射。 - 接口类型会被擦成
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;工厂求值自上而下、装饰器调用自下而上。