TypeScript 装饰器与元数据 — 给类织入横切能力,一次讲透
总计划第 19 周的必修内容今天补上。想象你要给 20 个服务类的方法都加"调用日志"——复制粘贴 20 遍?日志、权限、缓存、重试、性能打点……这些横切关注点(cross-cutting concerns)散落在业务代码里就是灾难。装饰器就是 TS 生态的"AOP 方案":
@Log()、@Permission("admin")、@Cache(60)一行注解,能力自动织入。NestJS、Angular、TypeORM 的整个架构都建立在它之上。今天讲透四类装饰器的签名与执行顺序、装饰器工厂、reflect-metadata 元数据反射,并亲手实现三个工业级装饰器。
目录
- 一、装饰器是什么:类的注解系统
- 二、前置配置与两个版本的装饰器
- 三、类装饰器
- 四、方法装饰器
- 五、属性装饰器与参数装饰器
- 六、装饰器工厂:带参数的注解
- 七、执行顺序:一张必须记住的时序图
- 八、reflect-metadata:元数据的钥匙
- 九、工业实战:日志/权限/缓存三连
- 十、类比记忆:手术与病历
- 十一、常见坑点与最佳实践
- 十二、自测挑战
- 十三、总结与知识图谱
一、装饰器是什么:类的注解系统
// 问题的起点:横切关注点的重复
// ❌ 没有装饰器的世界(每个方法都手动织入):
class DeviceService {
async getTemp(deviceId: string): Promise<number> {
const start = Date.now(); // 打点开始
try {
if (!currentUser.hasRole("operator")) { // 权限检查
throw new Error("forbidden");
}
const cached = cache.get(`temp:${deviceId}`); // 查缓存
if (cached) return cached;
const result = await plc.read(deviceId); // ← 真正的业务(3 行之外全是噪音)
cache.set(`temp:${deviceId}`, result, 60);
return result;
} finally {
logger.info(`getTemp 耗时 ${Date.now() - start}ms`); // 日志
}
}
// 另外 19 个方法,同样的样板再来 19 遍……
}
// ✅ 装饰器的世界:
class DeviceService {
@Log()
@Permission("operator")
@Cache(60)
async getTemp(deviceId: string): Promise<number> {
return plc.read(deviceId); // ← 只剩业务本身
}
}
一句话:装饰器是"编译期注册 + 运行时织入"的函数——它拿到类/方法/属性的定义权,在不动业务代码的前提下改造它们。
二、前置配置与两个版本的装饰器
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true, // 启用 Legacy 装饰器
"emitDecoratorMetadata": true // 自动注入类型元数据(第八节)
}
}
// ⚠ 版本认知(2024+ 的重要背景):
// 1. Legacy 装饰器(experimentalDecorators: true)
// → NestJS/Angular/TypeORM 当前主流,生态最全 ← 今天主讲这个
// 2. TC39 Stage 3 标准装饰器(TS 5.0+,不开 experimentalDecorators)
// → 语言标准,但签名不同(accessor 关键字等),生态迁移中
//
// 求职/实战建议:先精通 Legacy(面试和现有项目都是它),
// 知道标准版存在即可 —— 迁移是框架的事,不是你的事
三、类装饰器
3.1 签名与基本用法
/**
* 类装饰器:接收类的构造函数,返回新构造函数(或 void 表示不改)
*/
function Reportable<T extends new (...args: any[]) => object>(Base: T): T {
// ↑ 约束:必须是可以 new 的东西
return class extends Base {
// 用"匿名子类"包装原类
createdAt = new Date().toISOString();
report() {
console.log(`实例创建于 ${this.createdAt}`);
}
};
}
@Reportable
class DeviceService {
constructor(public name: string) {}
}
const svc = new DeviceService("plc-reader");
console.log(svc.createdAt); // ✅ 织入的属性
svc.report(); // ✅ 织入的方法
3.2 典型场景:单例模式装饰器
/** 把任何类变成单例 —— 类装饰器的经典应用 */
function Singleton<T extends new (...args: any[]) => object>(Base: T) {
let instance: object | undefined;
return class extends Base {
constructor(...args: any[]) {
if (instance) return instance; // 已有实例 → 直接返回(JS 特性:构造器可返回对象)
super(...args);
instance = this;
}
};
}
@Singleton
class HubRegistry {
private topics = new Map<string, unknown>();
constructor() { console.log("HubRegistry 初始化(只会打印一次)"); }
}
new HubRegistry(); // 打印初始化
new HubRegistry(); // 不打印 —— 同一个实例
四、方法装饰器
4.1 签名解剖(今天最重要的 10 行代码)
/**
* 方法装饰器的三个参数:
* 1. target —— 对于静态方法是【类本身】,对于实例方法是【原型】
* 2. key —— 方法名
* 3. descriptor —— 属性描述符(value 就是方法本体,可替换!)
*/
function Log(
target: object,
key: string,
descriptor: PropertyDescriptor
) {
const original = descriptor.value; // ① 存住原方法
descriptor.value = function (...args: unknown[]) { // ② 替换成包装版
const start = performance.now();
const result = original.apply(this, args); // ③ this 透传给原方法
const ms = (performance.now() - start).toFixed(2);
console.log(`[${key}] 耗时 ${ms}ms,参数:`, args);
return result; // ④ 返回值透传
};
return descriptor; // 返回新描述符(不返回则用改后的原描述符)
}
class DeviceService {
@Log
getTemp(deviceId: string): number {
return heavyRead(deviceId);
}
}
4.2 为什么必须 apply(this, args)?
// ❌ 常见新手错误:丢 this
descriptor.value = function (...args: unknown[]) {
return original(...args); // this 变成 undefined!
}; // 原方法里的 this.cache、this.logger 全炸
// ✅ this 必须透传:
original.apply(this, args);
// 记忆:装饰器是"代理",代理的职责是【转发身份】,不是【偷换身份】
五、属性装饰器与参数装饰器
5.1 属性装饰器
/**
* 属性装饰器只有两个参数(⚠ 没有 descriptor!)
* 1. target —— 原型(实例属性)或类(静态属性)
* 2. key —— 属性名
* 注意:此时属性还没初始化(定义阶段),拿不到值 —— 只能"登记信息"
*/
function Required(target: object, key: string) {
// 登记到类的元数据里(配合第八节的 reflect-metadata)
const list = Reflect.getMetadata("required", target) ?? [];
list.push(key);
Reflect.defineMetadata("required", list, target);
}
class CreateDeviceDto {
@Required
deviceId!: string; // 必填校验交给统一的 validate() 做
@Required
host!: string;
note?: string; // 可选
}
// 这是 NestJS @IsNotEmpty()、class-validator 的原理雏形
5.2 参数装饰器
/**
* 参数装饰器的三个参数:
* 1. target —— 原型
* 2. key —— 所在方法名
* 3. index —— 参数的位置(第几个)
* 用途极窄:登记"第几个参数是特殊的"(DI 注入标记)
*/
function Inject(token: string) {
return function (target: object, key: string, index: number) {
Reflect.defineMetadata(`inject:${index}`, token, target, key);
};
}
class ReportService {
generate(@Inject("MQTT_CLIENT") client: unknown, format: string) {
// 框架读元数据 → 把 MQTT 实例注入第 0 个参数
}
}
// NestJS 构造器注入的底层就是这套机制
六、装饰器工厂:带参数的注解
// 直接用 @Log 不能传参。想写 @Cache(60)?需要"工厂":
/**
* 装饰器工厂 = 返回装饰器的函数
* 调用时机:@Cache(60) 先执行 Cache(60) → 拿到真正的装饰器 → 再应用到方法上
*/
function Cache(seconds: number) { // ← 外层:接收配置
return function ( // ← 内层:真正的装饰器
target: object,
key: string,
descriptor: PropertyDescriptor
) {
const original = descriptor.value;
const store = new Map<string, { value: unknown; expire: number }>();
descriptor.value = function (this: unknown, ...args: unknown[]) {
const cacheKey = JSON.stringify(args);
const hit = store.get(cacheKey);
// 缓存有效 → 直接返回
if (hit && hit.expire > Date.now()) {
console.log(`[${key}] 缓存命中`);
return hit.value;
}
// 未命中 → 执行并写入缓存
const result = original.apply(this, args);
store.set(cacheKey, { value: result, expire: Date.now() + seconds * 1000 });
return result;
};
};
}
class DeviceService {
@Cache(60) // ← 先执行 Cache(60),60 秒后过期
getThreshold(deviceKind: string): number {
return loadFromRemote(deviceKind); // 假设是慢查询
}
}
// 工厂 vs 普通装饰器的调用链区别:
// @Cache → Cache(target, key, descriptor) 一层
// @Cache(60) → Cache(60)(target, key, descriptor) 两层(先配置后装饰)
// ⚠ 由此产生的经典坑:
// @Log 普通装饰器,直接用
// @Log() 工厂形式 —— 如果 Log 不是工厂,等于"装饰器的结果"当装饰器用
// 写库的时候统一用工厂(哪怕暂时不需要参数),未来加参数不破坏调用方 —— NestJS 的风格
七、执行顺序:一张必须记住的时序图
// 实验:多个装饰器叠罗汉,执行顺序是什么?
function A() { console.log("A 工厂"); return () => console.log("A 应用"); }
function B() { console.log("B 工厂"); return () => console.log("B 应用"); }
class Service {
@A()
@B()
method() {}
}
// 输出:
// B 工厂 ← 工厂按【从下往上】执行(先求值 B(),再求值 A())
// A 工厂
// A 应用 ← 装饰器应用按【从上往下】执行
// B 应用
// 记忆口诀:工厂自下而上求值,应用自上而下执行
// 类比:洋葱 —— B 先被"求值"(内层先准备),A 后应用(外层先包裹)
// 效果:A 包在 B 外面 → 运行时先经过 A 的逻辑,再进 B,最后到方法本体
完整的多目标执行顺序(类 + 属性 + 方法 + 参数):
1. 实例属性装饰器(按声明顺序)
2. 静态属性装饰器
3. 参数装饰器(方法内按参数顺序)
4. 方法装饰器(实例方法 → 静态方法)
5. 类装饰器(最后执行,此时类已"装修完毕")
// 面试高频:类装饰器最后执行(因为它包装的是"装修后的成品")
八、reflect-metadata:元数据的钥匙
8.1 为什么需要它?
// 装饰器遇到的共同难题:
function Log(target: object, key: string) {
// 我想记录"key 属于哪个类"、"key 的参数类型"……
// 但 JS 运行时拿不到【类型信息】(类型在编译期就被擦除了!)
}
// 解法:TS 的 emitDecoratorMetadata 让编译器把类型信息
// 作为元数据塞进运行时 —— 但读它需要 polyfill:reflect-metadata
8.2 三个内置元数据键
import "reflect-metadata"; // ⚠ 必须最先导入一次(全局 polyfill)
class DeviceService {
constructor(client: MQTTClient, registry: Map<string, unknown>) {}
getTemp(deviceId: string): number { return 0; }
threshold: number = 60;
}
const designKeys = {
"design:type": Reflect.getMetadata("design:type", DeviceService.prototype, "getTemp"),
// → Number(方法的"类型"即返回值类型)
"design:paramtypes": Reflect.getMetadata("design:paramtypes", DeviceService.prototype, "getTemp"),
// → [String](参数类型数组)
"design:returntype": Reflect.getMetadata("design:returntype", DeviceService.prototype, "getTemp"),
// → Number
};
// 构造器参数(依赖注入的根基):
Reflect.getMetadata("design:paramtypes", DeviceService);
// → [MQTTClient, Map] —— 框架由此知道要 new 什么、注入什么
8.3 自定义元数据 API
// 存/取自定义元数据:
Reflect.defineMetadata("role", "admin", DeviceService.prototype, "getTemp");
Reflect.getMetadata("role", DeviceService.prototype, "getTemp"); // "admin"
// 这就是 @Permission("admin") 的实现基础:
function Permission(role: string) {
return function (target: object, key: string) {
Reflect.defineMetadata("role", role, target, key);
};
}
// 运行时统一拦截器读元数据 → 决定放行或拒绝(NestJS Guard 的原理)
九、工业实战:日志/权限/缓存三连
三个装饰器合体,组成一个微型的"服务层框架"(总计划要求的产出):
import "reflect-metadata";
// ===== 1. 日志装饰器(工厂版:可配置日志级别)=====
function Log(level: "info" | "debug" = "info") {
return function (target: object, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (this: unknown, ...args: unknown[]) {
const start = performance.now();
const result = original.apply(this, args);
const ms = (performance.now() - start).toFixed(2);
console[level](`[${String(target.constructor.name)}.${key}] ${ms}ms args=%o`, args);
return result;
};
};
}
// ===== 2. 权限装饰器(元数据登记 + 统一校验入口)=====
function Permission(role: string) {
return function (target: object, key: string) {
Reflect.defineMetadata("role", role, target, key);
};
}
/** 统一权限拦截器(在方法装饰器链里包一层) */
function Guard(target: object, key: string, descriptor: PropertyDescriptor) {
const original = descriptor.value;
descriptor.value = function (this: unknown, ...args: unknown[]) {
const required = Reflect.getMetadata("role", target, key);
if (required && !currentUser().roles.includes(required)) {
throw new Error(`403: 需要 ${required} 权限`);
}
return original.apply(this, args);
};
}
// ===== 3. 缓存装饰器(第六节的 Cache 完整版)=====
function Cache(seconds: number) { /* ...第六节实现... */ }
// ===== 组装:业务类干净得像一首诗 =====
class DeviceService {
@Log("debug")
@Guard
@Permission("operator")
@Cache(60)
getThreshold(deviceKind: string): number {
return REMOTE_TABLES[deviceKind];
}
}
// 执行链(自上而下应用 → 运行时从外到内):
// Log → Guard(读 Permission 的元数据) → Cache → 业务方法
十、类比记忆:手术与病历
装饰器 = 医院的分诊与手术室体系
- 装饰器签名(target/key/descriptor)= 手术同意书上的三个字段:
给谁做(target)、做哪里(key)、怎么做(descriptor——手术方案可以改写)
- 装饰器工厂 = 挂号选套餐:@Cache(60) 先"选套餐"(Cache(60))
再进手术室(返回的装饰器)
- 执行顺序 = 洋葱式接台:工厂自下而上求值(备术),
应用自上而下执行(外层先包裹)
- reflect-metadata = 病历本:类型信息编译期擦除,
emitDecoratorMetadata 提前抄进病历,运行时翻病历(design:paramtypes)
- NestJS = 整栋住院大楼:Controller/Injectable/Guard
全是今天这些原语的宏
十一、常见坑点与最佳实践
坑点 1:装饰器丢 this(最高频翻车点)
// ❌ descriptor.value = (...args) => original(...args);
// 箭头函数绑死了定义时的 this(模块级 this)—— 原方法里 this.xxx 全挂
// ✅ descriptor.value = function (...args) { return original.apply(this, args); };
坑点 2:忘了导入 reflect-metadata
症状:Reflect.getMetadata is not a function
修复:入口文件第一行 import "reflect-metadata"(只导一次,全局生效)
依赖:npm i reflect-metadata
坑点 3:emitDecoratorMetadata 没开
症状:design:paramtypes 拿到的是 undefined(或全是 Object)
排查:tsconfig 的 emitDecoratorMetadata: true 是否开启
注意:只有【装饰器标注过】的声明才生成元数据(没标注的拿不到)
坑点 4:类装饰器改了类型不匹配
// 类装饰器返回的匿名子类若加了新成员,TS 类型上【看不到】:
@Singleton
class HubRegistry {}
new HubRegistry().createdAt;
// ❌ 类型错误:HubRegistry 上没有 createdAt
// 解法:声明合并补类型(联系 Day 24 的 interface merging):
declare class HubRegistry {
createdAt: string;
}
坑点 5:缓存装饰器用在异步方法上缓存了 Promise
// ❌ Cache 里存的是 Promise 对象:
// 第二次"命中缓存"返回的是【已 settled 的旧 Promise】
// 若第一次请求失败,失败的 Promise 被缓存 60 秒!
// ✅ 异步版要在 .then 里存【结果】,失败时删除缓存条目
十二、自测挑战
挑战 1(基础):签名默写
不看资料,默写四类装饰器的参数签名:
- 类装饰器:(?)
- 方法装饰器:(?)
- 属性装饰器:(?)
- 参数装饰器:(?)
并说出属性装饰器比方法装饰器少了什么、为什么。
挑战 2(进阶):手写 Retry 装饰器
// 实现 @Retry(3, 100):失败自动重试 3 次,间隔 100ms(指数退避加分)
class SyncService {
@Retry(3, 100)
async pullData(): Promise<DeviceData[]> {
return flakyRemote(); // 30% 概率抛错
}
}
// 提示:只对 Promise 生效(isThenable 判断)
挑战 3(进阶):执行顺序实验
// 写 4 个装饰器分别打 console.log,叠放成:
// @A @B @C @D method() {}
// 预测输出顺序 → 运行验证 → 把"工厂 vs 应用"的顺序规律写进博客
挑战 4(论文级):讲透 NestJS 的最小原理
给你一段 NestJS 代码:
@Injectable() class DbService {}
@Controller() class DeviceController {
constructor(private db: DbService) {}
@Get("/temp") @UseGuards(AdminGuard) getTemp() {}
}
不看资料,指出其中每一行的底层机制分别对应今天的哪个知识点。
十三、总结与知识图谱
装饰器与元数据(Day 25)
│
├── 本质:横切关注点的注解化(AOP)
│ └── 日志/权限/缓存/重试 → 一行注解织入
│
├── 四类装饰器
│ ├── 类 —— (Base) => 新构造函数(单例/注册)
│ ├── 方法 —— (target, key, descriptor)(包装替换 value)⭐ 核心
│ ├── 属性 —— (target, key)(无 descriptor,只能登记)
│ └── 参数 —— (target, key, index)(DI 注入标记)
│
├── 装饰器工厂
│ └── @Cache(60) = Cache(60)(target, key, descriptor) 两层调用
│
├── 执行顺序
│ ├── 工厂自下而上求值,应用自上而下执行(洋葱模型)
│ └── 类装饰器最后(包装"装修完的成品")
│
├── reflect-metadata
│ ├── design:type / design:paramtypes / design:returntype
│ ├── emitDecoratorMetadata 编译期抄进"病历"
│ └── 自定义元数据 = 权限/配置的登记处
│
└── 工业三连(本日产出)
├── @Log —— 性能打点
├── @Permission + Guard —— 方法级权限
└── @Cache —— 结果记忆化(注意异步陷阱)
一句话总结:装饰器把"给方法包一层"从复制粘贴变成声明式注解——方法装饰器是骨架(descriptor.value 替换术),工厂是参数化,reflect-metadata 是跨装饰器的通信总线;三者合一,你就摸到了 NestJS 这类框架的地基。
明日预告:Day 26 类型测试——类型也会"悄悄坏掉"(重构时返回值从 T[] 变成 readonly T[]),tsd 和 vitest 的 expectTypeOf 让类型拥有自己的测试用例,类型覆盖率成为可度量的工程指标。