前端知库

装饰器元数据怎样支撑依赖注入?NestJS 和 Angular 是怎么做的?

开启 experimentalDecorators 和 emitDecoratorMetadata 后,TypeScript 会给带装饰器的类生成 design:paramtypes 元数据,记录构造函数参数的类型,配合 reflect-metadata 在运行时读出来,容器就能按类型自动创建依赖,NestJS 正是这样按类型解析依赖。接口在编译后不存在,要用 @Inject(token) 指定。TS 5.0 的标准装饰器不生成这种元数据,5.2 起可以通过 context.metadata 和 Symbol.metadata 自己记录;Angular 推荐用 inject() 函数获取依赖。

参考答案

简答

比如 NestJS 里写 constructor(private logger: Logger),什么都不用配,框架就能自动传进来一个 Logger 实例。它靠的是装饰器元数据:开启 experimentalDecorators 和 emitDecoratorMetadata 以后,TypeScript 会给带装饰器的类多生成一行 design:paramtypes,把构造函数参数的类型记成运行时能读到的数组,比如 [Logger]。

依赖注入容器在创建类的时候读这个数组,先把每个依赖创建出来(依赖还有依赖就继续往下创建),再传给构造函数,并把实例缓存起来复用。读写元数据的 API 通常由 reflect-metadata 这个库提供,它不是 JavaScript 标准的一部分。

这套机制有两个限制。接口和类型别名在编译后会被擦掉,元数据里只剩 Object,容器不知道该注入什么,所以 NestJS 要求用 @Inject(token) 显式指定。另外 TS 5.0 的标准装饰器不会生成 design:paramtypes,也没有参数装饰器,依赖这套机制的框架不能直接切换。

标准装饰器也有自己的元数据:TS 5.2 起,装饰器可以往 context.metadata 里写数据,类定义完以后从 类[Symbol.metadata] 读出来,但写什么要自己决定,编译器不会替你记类型。Angular 现在推荐用 inject() 函数获取依赖,不必依赖构造函数参数的类型元数据。

emitDecoratorMetadata 到底生成了什么

先看编译器做了什么。下面的类有一个装饰器,构造函数要一个 Logger:

TypeScript
function Injectable(): ClassDecorator {
  return () => {};
}

class Logger {
  log(message: string) {
    console.log(message);
  }
}

@Injectable()
class UserService {
  constructor(private logger: Logger) {}
}

用 TypeScript 7.0.2 加 --experimentalDecorators --emitDecoratorMetadata 编译,输出的 JavaScript 末尾是这样的:

JavaScript
UserService = __decorate([
    Injectable(),
    __metadata("design:paramtypes", [Logger])
], UserService);

多出来的 __metadata("design:paramtypes", [Logger]) 就是关键。类型注解 Logger 本来在编译后会消失,现在被写成了一个真实的数组,里面放的是 Logger 这个类本身。__metadata 会调用 Reflect.metadata,这个函数由 reflect-metadata 库提供。TypeScript 手册说明,开启 emitDecoratorMetadata 并导入 reflect-metadata 以后,设计时的类型信息就能在运行时拿到。

为什么类上要有装饰器?去掉 @Injectable() 再用同样的选项编译,输出里就没有 __metadata 了,编译器只给带装饰器的声明生成元数据。NestJS 文档对 @Injectable() 的说明是,它给类附加元数据,声明这个类可以交给 Nest 的 IoC 容器管理。

用元数据写一个最小的 DI 容器

知道了元数据长什么样,容器的逻辑就清楚了:读 design:paramtypes,递归创建每个依赖,再 new 出目标类。下面不装 reflect-metadata,手写一个简化的元数据容器代替它,只为看清机制:

TypeScript
// 需要 "experimentalDecorators": true 和 "emitDecoratorMetadata": true
type Constructor<T = unknown> = new (...args: any[]) => T;

// 手写的简化元数据容器,代替 reflect-metadata 的 Reflect.defineMetadata / getMetadata。
const metadataStore = new WeakMap<object, Map<string, unknown>>();

function defineMetadata(key: string, value: unknown, target: object) {
  let map = metadataStore.get(target);
  if (!map) metadataStore.set(target, (map = new Map()));
  map.set(key, value);
}

function getMetadata(key: string, target: object): unknown {
  return metadataStore.get(target)?.get(key);
}

// 编译器生成的代码会调用 Reflect.metadata,这里提供一个最小实现。
(Reflect as any).metadata = (key: string, value: unknown) => (target: object) =>
  defineMetadata(key, value, target);

function Injectable(): ClassDecorator {
  return () => {};
}

class Container {
  private instances = new Map<Constructor, unknown>();

  resolve<T>(token: Constructor<T>): T {
    const cached = this.instances.get(token);
    if (cached) return cached as T;
    const deps = (getMetadata('design:paramtypes', token) ?? []) as Constructor[];
    const instance = new token(...deps.map((dep) => this.resolve(dep)));
    this.instances.set(token, instance);
    return instance;
  }
}

@Injectable()
class Logger {
  log(message: string) {
    console.log(`[log] ${message}`);
  }
}

@Injectable()
class UserService {
  constructor(private logger: Logger) {}

  greet(name: string) {
    this.logger.log(`你好,${name}`);
  }
}

const container = new Container();
container.resolve(UserService).greet('小明'); // 输出:[log] 你好,小明
console.log(container.resolve(Logger) === container.resolve(Logger)); // true

这段代码用 TypeScript 7.0.2 在 --strict --experimentalDecorators --emitDecoratorMetadata 下编译通过,用 Node.js 运行输出注释里的两行。

resolve(UserService) 读到 [Logger],先去 resolve(Logger);Logger 的构造函数没有参数,直接创建。然后把 Logger 实例传给 UserService。创建过的实例放进 instances,所以第二次取 Logger 拿到的是同一个,这就是单例作用域。

NestJS 文档描述的过程和这个一致:它按类型解析依赖,实例化 CatsController 时先找出它的依赖 CatsService,在默认的单例作用域下创建一个实例、缓存起来再返回;依赖分析是传递的,依赖的依赖也会被解析。真实框架还要处理循环依赖、不同作用域、模块边界,这里都省略了。

为什么接口不能直接注入

把构造函数参数换成接口,看看元数据变成什么:

TypeScript
function Injectable(): ClassDecorator {
  return () => {};
}

interface KeyValueStore {
  get(key: string): string | undefined;
}

@Injectable()
class ConfigService {
  constructor(private store: KeyValueStore) {}
}

同样用 TypeScript 7.0.2 编译,生成的是 __metadata("design:paramtypes", [Object])。接口只存在于类型系统里,编译后什么都不剩,编译器只能写一个 Object。容器拿到 Object,根本不知道该创建什么。

NestJS 文档也写明,类型和接口在编译时被擦除,Nest 在运行时引用不到它们,接口可以描述依赖的形状,但不能单独作为 DI 的 token。解决办法是用一个运行时存在的值做 token,比如字符串或 Symbol,注册 provider 时写 { provide: CACHE, useValue: ... } 或者 useClass、useFactory,注入时用 @Inject(token) 告诉框架要哪一个。

@Inject 是一个参数装饰器,它做的事情就是把 token 记到“第几个参数”上。下面手写一个简化版:

TypeScript
// 需要 "experimentalDecorators": true
const INJECT_TOKENS = new WeakMap<object, unknown[]>();

function Inject(token: unknown): ParameterDecorator {
  return (target, _key, index) => {
    const tokens = INJECT_TOKENS.get(target) ?? [];
    tokens[index] = token;
    INJECT_TOKENS.set(target, tokens);
  };
}

interface Cache {
  get(key: string): string | undefined;
}

const CACHE = Symbol('CACHE');

class ProfileService {
  constructor(@Inject(CACHE) private cache: Cache) {}
}

console.log(INJECT_TOKENS.get(ProfileService)); // 输出:[ Symbol(CACHE) ]

构造函数参数上的装饰器,target 就是类本身,index 是参数位置。容器创建 ProfileService 时,先看这个位置有没有显式 token,有就按 token 找 provider,没有再用 design:paramtypes 里的类型。NestJS 文档还提到,抽象类和接口不同,它在运行时存在,可以直接当 token,不需要 @Inject()。

标准装饰器怎样记录元数据

TypeScript 5.0 起,不开 experimentalDecorators 时用的是 TC39 标准装饰器。它不支持 emitDecoratorMetadata,也没有参数装饰器,所以上面这套“按构造函数参数类型注入”的做法用不了,两套装饰器的区别见《TypeScript 新装饰器与 experimentalDecorators 有何区别?》。

TypeScript 5.2 实现了装饰器元数据提案:每个装饰器的 context 上多了一个 metadata 对象,同一个类上的所有装饰器拿到的是同一个对象;类定义完成后,这个对象挂在 类[Symbol.metadata] 上。和旧方案不同,里面放什么完全由装饰器自己决定,编译器不会自动写入类型。

下面用字段装饰器 @inject(Logger) 把依赖记进元数据,再由 create 读出来填进实例:

TypeScript
// 标准装饰器,不开 experimentalDecorators;lib 需要包含 esnext.decorators
(Symbol as { metadata: symbol }).metadata ??= Symbol('Symbol.metadata');

type Constructor<T = unknown> = new () => T;
const INJECT = Symbol('inject');

function inject(token: Constructor) {
  return function (_value: undefined, context: ClassFieldDecoratorContext) {
    const fields = (context.metadata[INJECT] ??= {}) as Record<PropertyKey, Constructor>;
    fields[context.name] = token;
  };
}

function create<T>(cls: Constructor<T>): T {
  const instance = new cls();
  const fields = (cls[Symbol.metadata]?.[INJECT] ?? {}) as Record<PropertyKey, Constructor>;
  for (const [name, token] of Object.entries(fields)) {
    (instance as Record<string, unknown>)[name] = create(token);
  }
  return instance;
}

class Logger {
  log(message: string) {
    console.log(`[log] ${message}`);
  }
}

class UserService {
  @inject(Logger) logger!: Logger;
}

create(UserService).logger.log('已注入'); // 输出:[log] 已注入

这段代码用 TypeScript 7.0.2 在 --strict --target es2022 --lib es2022,esnext.decorators,dom 下编译通过,运行输出注释里的一行。

第一行是 TypeScript 5.2 发布说明给出的 polyfill,因为大多数运行时还没有原生的 Symbol.metadata;发布说明还要求 target 设为 es2022 或更低,lib 包含 esnext 或 esnext.decorators。token 要在 @inject(...) 里显式写出来,因为标准装饰器拿不到字段的类型注解。按 TC39 提案,子类的元数据对象以父类的元数据对象为原型,所以子类能读到父类记录的内容。

Angular 现在怎样注入

Angular 文档推荐用 inject() 函数获取依赖,可以写在字段初始化里,也可以写在构造函数体里:

TypeScript
@Service()
export class UserService {
  private http = inject(HttpClient);
}

inject(HttpClient) 直接把类当参数传进去,依赖是谁写在代码里,不需要从构造函数参数的类型元数据里推断。文档写明,@Service() 是 @Injectable({ providedIn: 'root' }) 的简写,会把服务注册成全应用共享的单例。注入器的层级和作用域见《Angular 依赖注入的层级和作用域怎样理解?》。

常见追问

reflect-metadata 是什么?不装会怎样?

它是一个 polyfill 库,提供 Reflect.defineMetadata、Reflect.getMetadata、Reflect.metadata 这些 API。TypeScript 手册说明它还不是 ECMAScript 标准的一部分。编译器生成的 __metadata 会先检查 Reflect.metadata 是否存在,不存在就什么也不做,所以不导入它,代码不会报错,但类型元数据不会被记下来,依赖它的容器就解析不出依赖。

为什么 NestJS 项目还不能换成标准装饰器?

因为 NestJS 的构造函数注入依赖 design:paramtypes,@Inject() 又是参数装饰器。标准装饰器既不生成这种类型元数据,也不支持参数装饰器,所以这类项目要继续开 experimentalDecorators 和 emitDecoratorMetadata。

多个装饰器写在一起,执行顺序是怎样的?

TypeScript 手册的说法是:装饰器表达式从上往下求值,得到的函数从下往上调用,像数学里的函数组合 f(g(x))。所以 @a() @b() method,先求值 a() 再求值 b(),调用时先调 b 返回的装饰器,再调 a 返回的。