TypeScript 装饰器与元编程:reflect-metadata 反射、手写 DI 容器与 NestJS 对照

从 TS 5.0 标准装饰器语法出发,详解类/方法/参数装饰器、reflect-metadata 设计时反射,手写一个轻量依赖注入容器,并与 NestJS 装饰器体系逐项对照。

装饰器(Decorator)是 TypeScript 中最接近"语言反射"的特性:它允许在类、方法、属性、参数声明的挂载点上注入逻辑,让框架在运行时读取声明期附加的元数据。NestJS 正是靠装饰器 + reflect-metadata 建立了整套依赖注入体系。

在 TS 5.0 之前,装饰器被称为"实验性"特性,需要开启 experimentalDecorators;TS 5.0 正式实现了 ECMAScript 标准装饰器(Stage 3),其上下文对象与旧式装饰器有显著差异。本文以 TS 5.0 标准装饰器为主,同时给出旧式装饰器的对照,帮助你阅读存量代码。


1. 装饰器基础:从实验性到标准

1.1 标准装饰器语法与上下文

TS 5.0+ 的标准装饰器接收 (value, context) 两个参数,context 携带 kind、name、static、private、addInitializer 等字段。以方法装饰器为例:

function logged(value: Function, context: ClassMethodDecoratorContext) {
  const methodName = String(context.name);

  return function (this: unknown, ...args: unknown[]) {
    console.log(`[LOG] 调用 ${methodName},参数:`, args);
    const result = value.apply(this, args);
    console.log(`[LOG] ${methodName} 返回:`, result);
    return result;
  };
}

class Calculator {
  @logged
  add(a: number, b: number): number {
    return a + b;
  }
}

new Calculator().add(1, 2);
// [LOG] 调用 add,参数: [1, 2]
// [LOG] add 返回: 3

关键差异:标准装饰器的返回值会替换原始成员(方法装饰器返回新函数),而旧式装饰器没有返回值语义。ClassMethodDecoratorContext 还提供 addInitializer,可用于在类实例化时执行初始化逻辑。

1.2 类装饰器

类装饰器在类定义后运行,常被用来"包装"或"标记"一个类:

type Constructor<T = object> = new (...args: any[]) => T;

function seal<T extends Constructor>(value: T, context: ClassDecoratorContext) {
  return class extends value {
    constructor(...args: any[]) {
      super(...args);
      // 冻结实例,防止运行时被随意扩展
      Object.seal(this);
    }
  };
}

@seal
class User {
  constructor(public name: string) {}
}

context.kind 此时为 'class',ClassDecoratorContext 提供了 addInitializer 用于在类创建后立即执行的钩子。

1.3 属性、访问器与参数装饰器

标准装饰器对属性只做标记(不能替换值,因为还没有值),对访问器可以替换 getter/setter,对参数则只能读取索引与上下文:

// 属性装饰器:只能做元数据标记
function required(value: undefined, context: ClassFieldDecoratorContext) {
  // context.kind === 'field'
}

// 参数装饰器:记录参数位置,供 DI 容器收集
function inject(token: symbol) {
  return function (_value: undefined, context: ClassParameterDecoratorContext) {
    const paramIndex = context.index; // 参数位置
    const ctor = context;             // kind === 'parameter'
    // 把 (token, index) 记录下来,供构造时注入
  };
}

class Service {
  constructor(@inject(DB_TOKEN) private db: Database) {}
}

注意:参数装饰器本身拿不到构造函数,通常需要配合 reflect-metadata 的 design:paramtypes,或由容器在扫描类时统一收集。


2. reflect-metadata 与元数据反射

2.1 设计时类型信息

reflect-metadata 提供了 Reflect.metadata(key, value) 与 Reflect.getMetadata(key, target) API。TS 编译器在开启装饰器后,会自动为被装饰成员注入三条"设计时"元数据:

Metadata Key含义举例
design:type属性/参数声明的类型Number
design:paramtypes构造器/方法参数的构造器类型数组[Database, Logger]
design:returntype方法返回类型Promise
import 'reflect-metadata';

class Database {}
class Logger {}

class Service {
  constructor(
    private db: Database,
    private logger: Logger,
  ) {}

  fetch(id: number): Promise<Database> {
    return Promise.resolve(this.db);
  }
}

const paramTypes = Reflect.getMetadata('design:paramtypes', Service);
// => [Database, Logger](构造函数,而非字符串)
const retType = Reflect.getMetadata('design:returntype', Service.prototype.fetch);
// => Promise(泛型参数在运行时被擦除,只有 Promise 构造器)

这就是依赖注入的根基:框架不靠约定,而是从 design:paramtypes 读出构造器参数的类型,再去容器里查找对应实例。

2.2 自定义元数据键

除了设计时类型,装饰器经常需要写入业务元数据:

const ROUTE_META = Symbol('route');

function route(path: string) {
  return function (value: Function, context: ClassDecoratorContext) {
    Reflect.defineMetadata(ROUTE_META, path, value);
  };
}

@route('/api/users')
class UserController {
  // ...
}

const p = Reflect.getMetadata(ROUTE_META, UserController);
// => '/api/users'

使用 Symbol 作为 key 可以避免与库之间的 key 冲突,这是框架作者的标准做法。

2.3 参数装饰器的收集模式

标准参数装饰器拿不到类,因此 DI 容器需要在类装饰器里统一读取 design:paramtypes,再结合参数装饰器记录的注入 token 做映射:

const INJECT_TOKENS = Symbol('inject_tokens');

function inject(token: unknown) {
  return function (_v: undefined, context: ClassParameterDecoratorContext) {
    const ctor = context;
    // 找到类之后写入的数组:容器在类装饰器阶段初始化
    // 这里通过静态字段模拟收集
  };
}

实践中更常见的方案是:参数装饰器把 (index, token) 通过 globalThis 的弱引用暂存,类装饰器再"认领"。NestJS 使用的就是类似的"metadata 双阶段"收集模式。


3. 手写轻量 DI 容器

3.1 Token 设计与注册表

DI 容器的核心是一个"类型 → 实例"的注册表。Token 可以是构造函数、Symbol 或字符串:

type Token<T = unknown> = Constructor<T> | symbol | string;
type Factory<T = unknown> = (container: Container) => T;

class Container {
  private registry = new Map<Token, { factory: Factory; singleton: boolean }>();
  private instances = new Map<Token, unknown>();

  bind<T>(token: Token<T>, factory: Factory<T>, singleton = true): this {
    this.registry.set(token, { factory, singleton });
    return this;
  }

  resolve<T>(token: Token<T>): T {
    // 单例命中缓存
    const cached = this.instances.get(token);
    if (cached !== undefined) return cached as T;

    const entry = this.registry.get(token);
    if (!entry) {
      throw new Error(`DI: 找不到 token 的注册,token=${String(token)}`);
    }

    const instance = entry.factory(this);
    if (entry.singleton) this.instances.set(token, instance);
    return instance as T;
  }
}

3.2 构造器注入:自动解析参数

配合 reflect-metadata,容器可以读取 design:paramtypes 自动完成构造器注入——这是手写 DI 最核心的一步:

import 'reflect-metadata';

type Constructor<T> = new (...args: any[]) => T;

class Container {
  private registry = new Map<Constructor, { ctor: Constructor; singleton: boolean }>();
  private instances = new Map<Constructor, unknown>();

  register<T>(ctor: Constructor<T>, singleton = true): this {
    this.registry.set(ctor, { ctor, singleton });
    return this;
  }

  resolve<T>(ctor: Constructor<T>): T {
    const cached = this.instances.get(ctor);
    if (cached !== undefined) return cached as T;

    const entry = this.registry.get(ctor);
    if (!entry) throw new Error(`DI: ${ctor.name} 未注册`);

    // 反射构造器参数类型
    const paramTypes =
      (Reflect.getMetadata('design:paramtypes', ctor) as Constructor[]) ?? [];
    const deps = paramTypes.map((dep) => this.resolve(dep));

    const instance = new entry.ctor(...deps) as T;
    if (entry.singleton) this.instances.set(ctor, instance);
    return instance;
  }
}

// 使用
class Database { ping() { return 'pong'; } }
class UserService {
  constructor(private db: Database) {}
  async list() { return this.db.ping(); }
}

const container = new Container();
container.register(Database);
container.register(UserService);

const svc = container.resolve(UserService);
// UserService 的构造函数参数 Database 被自动从容器解析注入

3.3 接口注入:用 Token 桥接抽象与实现

design:paramtypes 只能读取到构造器类型,接口在运行时不存在。因此面向接口编程时,需要注册"接口 Token"而非接口本身:

interface Logger { log(msg: string): void; }

const LoggerToken = Symbol('Logger');

class ConsoleLogger implements Logger {
  log(msg: string) { console.log(msg); }
}

class OrderService {
  constructor(private logger: Logger) {}
  create() { this.logger.log('order created'); }
}

这里容器无法自动注入——design:paramtypes 读到的是接口(运行时为 Object)。解决方案是把 design:paramtypes 读到的顺序与显式注入 token 对齐:

const INJECT_PARAMS = Symbol('inject_params');

function injectParam(token: symbol) {
  return function (_v: undefined, context: ClassParameterDecoratorContext) {
    // 收集 (构造器, index, token)
    Reflect.defineMetadata(
      INJECT_PARAMS,
      { ...(Reflect.getMetadata(INJECT_PARAMS, context) ?? {}), [context.index]: token },
      context,
    );
  };
}

容器在 resolve 时,若存在 INJECT_PARAMS 元数据就优先用其中的 token 解析,否则回退到 design:paramtypes。这就是 NestJS 中 @Inject(LoggerToken) 参数装饰器的原理。


4. 与 NestJS 装饰器体系对照

4.1 类级装饰器对照

NestJS 的 @Injectable()、@Controller()、@Module() 本质上是"写元数据"的类装饰器,底层是 reflect-metadata:

// NestJS 内部的核心调用(简化)
function Injectable(options?: InjectableOptions): ClassDecorator {
  return (target: Function) => {
    Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
    Reflect.defineMetadata(IS_ENDPOINT_METADATA, true, target); // 标记"可被容器扫描"
    return target;
  };
}
NestJS 装饰器作用目标底层行为
@Injectable()类写入 scope 元数据,注册进 DI
@Controller(prefix)类写入路由前缀元数据
@Get() / @Post()方法写入 HTTP 方法与路径元数据
@Param(key) / @Body()参数写入参数提取元数据
@Inject(token)构造器参数覆盖 design:paramtypes 的注入 token

4.2 参数装饰器与请求映射

NestJS 的参数装饰器把"从请求里取哪一段"编码成元数据,框架在运行时拼装:

// 模拟 NestJS 的参数解析
const PARAM_METADATA = Symbol('params');

function Param(propertyKey?: string) {
  return (target: object, propertyKeyMethod: string, parameterIndex: number) => {
    // 旧式装饰器签名:target / method / index
    const existing =
      (Reflect.getMetadata(PARAM_METADATA, target, propertyKeyMethod) ?? {}) as Record<number, unknown>;
    existing[parameterIndex] = { type: 'param', propertyKey };
    Reflect.defineMetadata(PARAM_METADATA, existing, target, propertyKeyMethod);
  };
}

class UserController {
  async get(@Param('id') id: string) {
    // 运行时由框架读取 PARAM_METADATA,
    // 从 request.params 中取出 id 传入方法
  }
}

NestJS 的 RouterExplorer 正是这样:扫描控制器类的元数据,把路由与方法绑定,再在每个参数位上注入对应的请求提取器。

4.3 从元数据到路由注册的完整闭环

一个迷你框架可以完整演示"装饰器写元数据 + 启动时读取元数据并注册":

import 'reflect-metadata';

const ROUTES = Symbol('routes');

function Get(path: string) {
  return function (target: object, propertyKey: string, descriptor: PropertyDescriptor) {
    const existing = (Reflect.getMetadata(ROUTES, target.constructor) ?? []) as Array<{
      method: string; path: string; handler: string;
    }>;
    existing.push({ method: 'GET', path, handler: propertyKey });
    Reflect.defineMetadata(ROUTES, existing, target.constructor);
  };
}

function Controller(prefix: string) {
  return function <T extends Constructor>(target: T) {
    Reflect.defineMetadata('prefix', prefix, target);
  };
}

// ---- 业务代码 ----
@Controller('/users')
class UserController {
  @Get('/:id')
  getUser(id: string) {
    return { id };
  }
}

// ---- 框架侧:注册路由 ----
function mountRoutes(ctrlClass: Constructor) {
  const prefix: string = Reflect.getMetadata('prefix', ctrlClass);
  const routes: Array<{ method: string; path: string; handler: string }> =
    Reflect.getMetadata(ROUTES, ctrlClass);

  for (const route of routes) {
    const fullPath = `${prefix}${route.path}`;
    console.log(`注册 ${route.method} ${fullPath} -> ${route.handler}`);
    // router.get(fullPath, (req, res) => handler(instance, ...))
  }
}

mountRoutes(UserController);
// 注册 GET /users/:id -> getUser

这个闭环就是 NestJS 路由系统的缩小版,理解它之后再读 NestJS 源码会轻松很多。


5. 标准装饰器 vs 旧式装饰器

存量代码(NestJS 7 及更早的基于 experimentalDecorators 的项目)仍在大量使用旧式装饰器,需要能读懂并迁移:

维度旧式(experimental)标准(TS 5.0)
启用方式"experimentalDecorators": true默认支持,无开关
参数(target, key, descriptor) 等(value, context)
方法替换修改 descriptor.value直接返回新函数
参数装饰器能拿 target 与 index只能拿 context
与 reflect-metadata深度集成需自行 defineMetadata
兼容性Node 12+ / 全框架Node 16+ / 新框架

tsconfig 中如果同时出现 experimentalDecorators 与标准装饰器混用,TS 会报错。迁移路径通常是:先升级到 TS 5.0,再逐文件把旧式签名改写为标准上下文签名,最后移除 experimentalDecorators。


6. 最佳实践与陷阱

6.1 生产实践清单

  • 装饰器保持无状态、纯标记:不要在装饰器里执行业务逻辑,它只在类定义时运行一次;副作用逻辑放到框架的"读取元数据"阶段。
  • 统一使用 Symbol 作元数据 key:字符串 key 容易与第三方库冲突。
  • 给 DI 容器做循环依赖检测:resolve 时维护"解析中"栈,发现循环依赖立即抛错,否则会栈溢出。
  • 单例 vs 瞬态的权衡:单例省内存但会跨请求共享状态,HTTP 场景的请求级服务应注册为瞬态或使用作用域。
  • 标准装饰器下慎用 design:paramtypes:TS 5.0 标准装饰器默认不再自动生成设计时类型元数据,需要开启 emitDecoratorMetadata 兼容。NestJS 10+ 已对此做了适配。

6.2 常见陷阱

// 陷阱 1:装饰器求值顺序是"从下到上、方法先于类"
// 方法装饰器先执行,类装饰器后执行(若同层则从下往上)

// 陷阱 2:design:returntype 对泛型会丢失
// function fetch<T>(): Promise<T> 的 returntype 是 Promise(无 T)

// 陷阱 3:标准装饰器对属性无法替换值
// 属性在定义时尚无值,只能做元数据标记

6.3 与类型系统的协同

装饰器元编程天然与类型体操互补:用类型工具把"被装饰的类"的元数据推导出来,让框架层获得类型安全:

type ControllerMeta<T> = {
  [K in keyof T as T[K] extends (...args: any[]) => any ? `on${Capitalize<string & K>}` : never]: T[K];
};
// 把控制器方法映射成带前缀的回调签名,供路由表消费

想深入了解类型层能力,可参考 https://plumephp.com/typescript-type-level-programming/;想把这些模式放入生产项目的工程结构中,可参考 https://plumephp.com/typescript-project-architecture-tsconfig/ 的 tsconfig 分层配置;NestJS 作为后端容器时,其整体工程化实践详见 https://plumephp.com/typescript-nodejs-backend/。


7. 总结

装饰器 + reflect-metadata 是 TypeScript 元编程的一对核心组合:装饰器在声明期写入元数据,reflect-metadata 在运行期读取它们。掌握这一模式,你不仅能读懂 NestJS 的路由与依赖注入机制,还能为自己的框架、库或大型应用构建类似的"声明式基础设施"。

手写一个百行 DI 容器,是理解容器化思想最好的方式——它让你看清 @Injectable() 背后真正发生的事情,而不是把框架当作黑盒。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「frontend」更多文章

  1. CSS 架构与样式方案:从方法论到现代 CSS 新特性
  2. 可访问性与国际化:WCAG 2.2、ARIA 与 i18n 工程实践
  3. SSR/SSG 渲染模式全景:Next.js App Router、流式渲染与岛屿架构