装饰器元数据怎样支撑依赖注入?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:
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 末尾是这样的:
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,手写一个简化的元数据容器代替它,只为看清机制:
// 需要 "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,在默认的单例作用域下创建一个实例、缓存起来再返回;依赖分析是传递的,依赖的依赖也会被解析。真实框架还要处理循环依赖、不同作用域、模块边界,这里都省略了。
为什么接口不能直接注入
把构造函数参数换成接口,看看元数据变成什么:
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 记到“第几个参数”上。下面手写一个简化版:
// 需要 "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 读出来填进实例:
// 标准装饰器,不开 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() 函数获取依赖,可以写在字段初始化里,也可以写在构造函数体里:
@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返回的。