【TS】day25-decorators-metadata

作者:mario 发布时间: 2026-09-01 阅读量:4 评论数:0

TypeScript 装饰器与元数据 — 给类织入横切能力,一次讲透

总计划第 19 周的必修内容今天补上。想象你要给 20 个服务类的方法都加"调用日志"——复制粘贴 20 遍?日志、权限、缓存、重试、性能打点……这些横切关注点(cross-cutting concerns)散落在业务代码里就是灾难。装饰器就是 TS 生态的"AOP 方案":@Log()@Permission("admin")@Cache(60) 一行注解,能力自动织入。NestJS、Angular、TypeORM 的整个架构都建立在它之上。今天讲透四类装饰器的签名与执行顺序、装饰器工厂、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 让类型拥有自己的测试用例,类型覆盖率成为可度量的工程指标。

评论