本节目标:读完这一节,你能说清
reflect-metadata在运行时到底做了什么,知道emitDecoratorMetadata发射的三种元数据键各自存了什么、什么时候会退化成Object。你能从零手写一个可用的依赖注入容器,处理 Token 注册、递归解析、单例与瞬态作用域、循环依赖;也能判断哪些场景下「类型驱动注入」会失效,必须显式传 Token。
4.2 reflect-metadata 与依赖注入容器
上一节结尾留了一个缺口:标准装饰器的 context 里没有任何类型信息(回顾 4.1 标准装饰器(TS 5.x)
)。装饰器能告诉你「有个方法叫 increment」,但无法告诉你它的参数是 number 还是 UserService。而依赖注入、自动校验、序列化这三类框架,全部建立在这类信息之上。
类型擦除留下的缺口
先回到根源。TypeScript 的类型在编译后完全消失:
class UserService {
constructor(private db: Database, private logger: Logger) {}
}
编译产物里 Database 和 Logger 连影子都没有:
class UserService {
constructor(db, logger) {
this.db = db;
this.logger = logger;
}
}
所以运行时想问「UserService 的构造参数是什么类型」,源码里没有任何东西可以回答。关于擦除规则的完整讨论见 1.2 类型擦除与运行时边界
。
解决思路只有两条:要么编译器额外发射一份类型信息(emitDecoratorMetadata),要么显式声明 Token(手写注入元数据)。前者方便但脆弱,后者啰嗦但可靠,成熟框架通常两者结合。
reflect-metadata 做了什么
reflect-metadata 是一个 polyfill,它给 Reflect 对象补上一套元数据 API:
npm i reflect-metadata
import "reflect-metadata";
class Point {}
const p = new Point();
Reflect.defineMetadata("role", "admin", p);
Reflect.getMetadata("role", p); // "admin"
Reflect.hasMetadata("role", p); // true
Reflect.deleteMetadata("role", p); // true
Reflect.getMetadataKeys(p); // [ "role" ]
关键在于它不是把数据挂在对象自身上(那会污染 Object.keys、被序列化带走),而是维护一张内部的 WeakMap<object, Map<any, Map<any, any>>> 三级映射。用 WeakMap 意味着元数据不会阻止对象被 GC,这是它能安全用于长生命周期类的原因。
元数据的查找是沿原型链向上的:Reflect.getMetadata 会依次查实例、原型、基类原型。所以装饰在基类方法上的元数据,子类实例也能读到——这是继承式 DI 能工作的基础。
emitDecoratorMetadata 发射了什么
打开 "emitDecoratorMetadata": true 后,只要一个类至少有一个装饰器,编译器就会为它发射元数据:
@Injectable()
class OrderService {
constructor(private repo: OrderRepo, count: number) {}
async create(id: string): Promise<void> { console.log(id); }
}
编译产物(简化):
OrderService = __decorate([
Injectable(),
__metadata("design:paramtypes", [OrderRepo, Number])
], OrderService);
三种键的语义如下:
| 元数据键 | 存放内容 | 读取方式 |
|---|---|---|
design:type | 被装饰成员的声明类型 | Reflect.getMetadata("design:type", target, key) |
design:paramtypes | 构造器/方法的参数类型数组 | Reflect.getMetadata("design:paramtypes", target) |
design:returntype | 方法或构造器的返回类型 | Reflect.getMetadata("design:returntype", target, key) |
容器正是靠 design:paramtypes 拿到构造参数列表,再递归构造每一个依赖:
type Ctor<T = unknown> = new (...args: any[]) => T;
function resolve(ctor: Ctor) {
const params: unknown[] =
Reflect.getMetadata("design:paramtypes", ctor) ?? [];
const args = params.map((p) => resolve(p as Ctor));
return new ctor(...args);
}
二十行不到就完成了「按类型自动注入」。但它有三个致命前提,必须逐一看清。
design:type 的退化规则
前提一:运行时值必须存在。 design:paramtypes 存的是值,不是类型。number 会变成全局的 Number 构造函数,string 变成 String,boolean 变成 Boolean。而以下情况会退化成 Object:
| 源码写法 | 元数据值 | 说明 |
|---|---|---|
number / string / boolean | Number / String / Boolean | 包装构造函数 |
Foo(类) | Foo | 可用 |
interface Foo | Object | 接口无运行时值 |
type Foo = {...} | Object | 类型别名无运行时值 |
A | B(联合) | Object | 联合无单一值 |
Foo[] | Array | 丢失元素类型 |
Promise<Foo> | Promise | 丢失泛型参数 |
T(泛型参数) | Object | 擦除 |
结论:接口、类型别名、联合、泛型一律不可靠。 这就是为什么 NestJS 要求「接口必须配 @Inject("TOKEN")」。一旦你写 constructor(private svc: MyInterface) 而不给 Token,容器会尝试 new Object(),报错信息通常是:
TypeError: Object is not a constructor
at resolve (container.ts:8:16)
前提二:装饰器必须存在。 emitDecoratorMetadata 只为至少有一个装饰器的类发射元数据。裸类不会生成 __metadata 调用,于是 design:paramtypes 为 undefined,容器拿到的参数列表是空数组,注入静默失败——构造函数收到了 undefined,直到业务代码访问 this.repo.find() 才炸。
前提三:循环引用会让元数据变成 undefined。 两个模块互相 import 时,__metadata 求值那一刻另一个类可能还是 undefined。这是 DI 里最难定位的一类问题。
手写一个迷你 DI 容器
把上面的碎片拼起来,做一个支持 Token 的容器。设计目标:
- 用类本身或字符串/符号 Token 注册
- 支持单例与瞬态两种作用域
- 支持值注册(已有实例直接塞进去)
- 解析时优先读显式注入元数据,其次读
design:paramtypes
type Token = string | symbol | Ctor;
type Scope = "singleton" | "transient";
interface Registration {
scope: Scope;
factory: (c: Container) => unknown;
}
const INJECT_TOKENS = "di:inject-tokens";
export class Container {
private registrations = new Map<Token, Registration>();
private instances = new Map<Token, unknown>();
register<T>(token: Token, factory: (c: Container) => T, scope: Scope = "singleton") {
this.registrations.set(token, { scope, factory });
return this;
}
registerValue<T>(token: Token, value: T) {
this.registrations.set(token, { scope: "singleton", factory: () => value });
return this;
}
resolve<T>(token: Token): T {
const reg = this.registrations.get(token);
if (!reg) throw new Error(`未注册的依赖:${String(token)}`);
if (reg.scope === "singleton" && this.instances.has(token)) {
return this.instances.get(token) as T;
}
const value = reg.factory(this) as T;
if (reg.scope === "singleton") this.instances.set(token, value);
return value;
}
}
容器本身只是查表。真正的魔法在「如何自动构造一个类」,这部分做成独立函数:
export function resolveClass<T>(ctor: Ctor<T>, container: Container): T {
const explicit: Token[] | undefined =
Reflect.getMetadata(INJECT_TOKENS, ctor);
const implicit: unknown[] =
Reflect.getMetadata("design:paramtypes", ctor) ?? [];
const tokens: Token[] = explicit ?? (implicit as Token[]);
const args = tokens.map((t) => container.resolve(t));
return new ctor(...args);
}
接着是注入装饰器。这里必须用 legacy 语法——标准装饰器没有参数装饰器,无法把 Token 标到某个具体参数上。所以这类项目通常仍保留 experimentalDecorators: true:
export function Inject(token: Token): ParameterDecorator {
return (target, _key, index) => {
const existing: Token[] =
Reflect.getMetadata(INJECT_TOKENS, target) ?? [];
existing[index] = token;
Reflect.defineMetadata(INJECT_TOKENS, existing, target);
};
}
export function Injectable(): ClassDecorator {
return () => {};
}
Inject 把「第 index 个参数要用哪个 Token」记在类上,resolveClass 优先读它。注意这里用数组下标写入而不是 push,因为装饰器执行顺序是从右到左(参数装饰器按参数倒序触发),用 push 会把顺序弄反。
最后把类注册进容器:
const container = new Container();
container.register("DB", () => new Database("postgres://localhost/app"));
container.register(Logger, (c) => new Logger(c.resolve("DB")));
@Injectable()
class OrderService {
constructor(
@Inject("DB") private db: Database,
private logger: Logger,
) {}
}
container.register(OrderService, (c) => resolveClass(OrderService, c));
const svc = container.resolve(OrderService);
console.log(svc instanceof OrderService); // true
循环依赖与懒解析
OrderService 依赖 PaymentService、PaymentService 又依赖 OrderService,直接递归会栈溢出:
RangeError: Maximum call stack size exceeded
at Container.resolve (container.ts:31:11)
两种解法。其一是前向引用(forwardRef),用一个惰性 Token 包一层:
export function forwardRef(fn: () => Token): Token {
return { toString: () => `forwardRef(${String(fn())})`, __forwardRef: fn } as unknown as Token;
}
容器在解析时识别 __forwardRef 并调用它取真实 Token,从而把「求值时机」推迟到解析阶段。其二是属性注入,把构造器注入改成装饰器在实例构造后赋值:
export function LazyInject(token: Token): PropertyDecorator {
return (target, key) => {
Object.defineProperty(target, key, {
get(this: unknown) {
return container.resolve(token);
},
enumerable: true,
configurable: true,
});
};
}
属性注入的本质是「用 getter 把解析推迟到第一次访问」,天然打破构造期的环。代价是每次访问都要查表,且失去了「构造即完全就绪」的保证——this.svc 在被访问前一直是未定义状态,这一点必须在文档里讲清楚。
作用域与生命周期
| 作用域 | 实例个数 | 适用场景 | 注意 |
|---|---|---|---|
| singleton | 每容器一个 | 无状态服务、连接池 | 跨请求共享,切勿存请求数据 |
| transient | 每次解析新建 | 有状态对象、DTO | 注意循环依赖会无限递归 |
| request | 每请求一个 | 用户上下文、事务 | 需要 AsyncLocalStorage 支撑 |
请求级作用域在 Node 里靠 AsyncLocalStorage 实现:容器维护一个以请求上下文为键的实例表,resolve 时先查当前上下文。它比单例复杂得多,但也是唯一能同时满足「同一请求内共享」和「不同请求隔离」的方案。
关于 DI 在服务端的完整工程实践,可以延伸阅读 TypeScript 微服务与 NestJS 与 Node.js NestJS 实战指南 ;设计模式层面的对照见 Node.js 设计模式 。想把容器的能力做得更通用,还可以参考 TypeScript 设计模式实战 。
标准装饰器下这条路为何更窄
回到第 4.1 节的问题:标准装饰器没有参数装饰器,所以「把 Token 标到第 N 个参数」这件事在纯标准语法下做不到。你只剩下三种选择:
- 继续用 legacy 装饰器(NestJS 等框架的选择),换取参数装饰器与自动元数据。
- 改用类装饰器声明依赖清单,例如
@Inject({ db: "DB" })挂在类上,参数顺序靠约定。 - 彻底放弃装饰器,用显式工厂函数注册,容器只负责查表与作用域。
方案 3 最啰嗦但最稳,适合基础设施代码;方案 1 最方便但被锁在 legacy 语义上。这也是为什么「升级到标准装饰器」在框架层面至今没有大规模落地——生态依赖的正是标准提案删掉的那部分能力。
常见坑与报错对照
| 报错 / 现象 | 根因 | 处理 |
|---|---|---|
Object is not a constructor | 注入的是接口/类型别名,退化成 Object | 补 @Inject("TOKEN") |
构造参数为 undefined | 类上没有装饰器,未发射元数据 | 加 @Injectable() |
Maximum call stack size exceeded | 循环依赖 | forwardRef 或属性注入 |
| 参数注入顺序错乱 | 参数装饰器倒序执行 | 用下标写入,不要 push |
| 单例里出现跨请求数据 | 作用域选错 | 改 request/transient |
design:paramtypes 为 undefined | 模块循环引用 | 拆分模块或用字符串 Token |
| 打包后元数据丢失 | 类名被压缩,且元数据依赖引用 | 保留类名或显式 Token |
最后一行在构建产物里尤其常见:design:paramtypes 存的是类引用,只要类还在就没问题;但一旦用 emitDecoratorMetadata + 字符串类名做映射,压缩后类名改变就会失效。显式 Token 是唯一对压缩免疫的方案,这也是为什么大型项目最终都会给关键依赖配 Token。
小结
这一节把标准装饰器缺失的类型信息补了回来:
- 擦除是根因:
interface、type、泛型、联合在运行时都不存在,元数据只能退化。 reflect-metadata用 WeakMap 三级映射存元数据,查找沿原型链向上,不影响 GC。emitDecoratorMetadata发射design:type/design:paramtypes/design:returntype,但要求类至少有一个装饰器。- 容器三件套:Token 注册表、递归解析、作用域缓存。二十行核心代码即可跑通。
- 循环依赖靠
forwardRef或属性注入(getter 延迟解析)打破。 - 标准装饰器无参数装饰器,所以类型驱动的 DI 至今仍绑在 legacy 语义上。
DI 解决的是「对象从哪来」。但装饰器还有另一半价值——在不修改业务代码的前提下,给方法套上日志、缓存、事务、重试。这就是面向切面编程,也是 4.3 AOP 与运行时类型信息
的主题。我们会看到标准装饰器与 Proxy 各自的适用边界,以及运行时类型信息在数据校验场景里的真实用法。
阅读导航:上一节:4.1 标准装饰器(TS 5.x) · 下一节:4.3 AOP 与运行时类型信息 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。