《TypeScript高级编程》4.2 reflect-metadata 与依赖注入容器

本节解决标准装饰器拿不到类型信息的问题:reflect-metadata 如何为对象挂上元数据表,再拆解 emitDecoratorMetadata 发射的 design:type、design:paramtypes、design:returntype 三项与退化规则。再手写一个支持 Token 注册、递归解析与作用域的迷你依赖注入容器,并给出循环依赖、接口无法作 Token 等真实坑的解法。

本节目标:读完这一节,你能说清 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 / booleanNumber / String / Boolean包装构造函数
Foo(类)Foo可用
interface FooObject接口无运行时值
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 个参数」这件事在纯标准语法下做不到。你只剩下三种选择:

  1. 继续用 legacy 装饰器(NestJS 等框架的选择),换取参数装饰器与自动元数据。
  2. 改用类装饰器声明依赖清单,例如 @Inject({ db: "DB" }) 挂在类上,参数顺序靠约定。
  3. 彻底放弃装饰器,用显式工厂函数注册,容器只负责查表与作用域。

方案 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 与运行时类型信息 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes