InversifyJS:懒注入与循环依赖(lazyInject 的利与弊)

前面在“类作为标识符”和“作用域”那几篇里已经提到过循环依赖,这篇就专门把这个话题拎出来,
聊聊 InversifyJS 的懒注入(lazyInject)是怎么运作的、它在哪些情况下能帮你缓解循环依赖,以及在哪些情况下你宁可重构也别硬上。

最典型的场景:

1@injectable()
2class A {
3  constructor(public b: B) {}
4}
5
6@injectable()
7class B {
8  constructor(public a: A) {}
9}

如果直接这样绑定:

1container.bind(A).toSelf().inSingletonScope();
2container.bind(B).toSelf().inSingletonScope();
3
4const a = container.get(A); // BOOM

问题在于:

  • 创建 A 时,需要先创建 B
  • 创建 B 时,又需要先创建 A
  • 如果没有特殊处理,就会引发“鸡生蛋 / 蛋生鸡”的循环。

Java 里的 Spring/Guice 也都有类似问题,只是解决策略(代理对象、提前暴露引用等)略有不同。
InversifyJS 的做法之一,就是提供一个**懒注入(lazy injection)**的机制。

lazyInject 不在 inversify 主包里,而是在 inversify-inject-decorators 这个独立包中:

1import 'reflect-metadata';
2import { Container, injectable } from 'inversify';
3import getDecorators from 'inversify-inject-decorators';
4
5const container = new Container();
6const { lazyInject } = getDecorators(container);

getDecorators(container) 会基于当前容器返回一组装饰器,其中最常用的是 lazyInject

 1@injectable()
 2class ServiceA {
 3  @lazyInject(ServiceB) public b!: ServiceB;
 4}
 5
 6@injectable()
 7class ServiceB {
 8  @lazyInject(ServiceA) public a!: ServiceA;
 9}
10
11container.bind(ServiceA).toSelf().inSingletonScope();
12container.bind(ServiceB).toSelf().inSingletonScope();
13
14const a = container.get(ServiceA);
15console.log(a.b);      // 访问时才真正从容器里取 ServiceB
16console.log(a.b.a);    // 再次访问时,ServiceB 再去取 ServiceA

核心区别在于:

  • 普通构造函数注入:在创建对象时就要把所有依赖都准备好;
  • lazyInject:在第一次访问对应属性时,才去容器里拿依赖(“懒加载”)。

这就给打破循环提供了一个切入点:
A 可以先被创建出来,等真正用到 B 时,再从容器获取 B;反过来亦然。

参考你之前在“类作为 ID”文档里的例子,我们用稍微简化的版本来说明:

 1import 'reflect-metadata';
 2import { Container, injectable } from 'inversify';
 3import getDecorators from 'inversify-inject-decorators';
 4
 5const container = new Container();
 6const { lazyInject } = getDecorators(container);
 7
 8@injectable()
 9class Dom {
10  constructor(public domUi: DomUi) {}
11}
12
13@injectable()
14class DomUi {
15  @lazyInject(Dom) public dom!: Dom;
16}
17
18container.bind(Dom).toSelf().inSingletonScope();
19container.bind(DomUi).toSelf().inSingletonScope();
20
21const dom = container.get(Dom);
22console.log(dom.domUi.dom === dom); // true

这里的关键是:

  • Dom 需要 DomUi,通过构造函数注入;
  • DomUi 需要 Dom,但用 @lazyInject(Dom) 延后到属性访问时才取;
  • 容器创建 Dom 的过程:
    1. 创建 DomUi 实例(此时 domUi.dom 还没被访问);
    2. DomUi 传给 Dom 构造函数;
    3. 返回 Dom
    4. 当你访问 dom.domUi.dom 时,lazyInject 才真正去容器里拿 Dom 实例,并缓存起来。

这相当于在依赖图中“迟了一步”去解析某条边,从而避免了构造时的死循环。

虽然 lazyInject 在一些场景下很方便,但有几个需要非常小心的点:

  1. 它隐藏了依赖

    • 构造函数注入时,类的依赖一目了然;
    • lazyInject 时,依赖藏在类体内部,不看源码很难知道它依赖了谁。
  2. 容易被滥用来“修补坏设计”

    • 真正健康的架构应该尽量避免强互相依赖(尤其是服务层);
    • 如果发现自己到处用 lazyInject 才能跑起来,很可能说明模块划分出了问题。
  3. IDE/类型推断的体验稍差一些

    • 属性上加 ! 非空断言,初学者容易被绕晕;
    • 对某些静态分析工具来说也不如构造函数注入清晰。

所以对我来说,lazyInject 更像是一个“在特殊情况下用的应急工具”,而不是日常推荐的注入方式。

Theia 自己的源码里,主流还是构造函数注入 + 接口/符号绑定这一路:

  • 服务/贡献点之间尽量通过抽象接口解耦;
  • 把确实难以打散的“互相关联”对象,设计成由第三方管理的更高层对象(比如 Manager/Coordinator 模式),而不是让两边互相硬依赖。

如果在 Theia 扩展里考虑用 lazyInject,我个人会建议:

  • ✅ 可以考虑用的场景:

    • 某两个类之间确实有“互相持有引用”的需求,并且已经尽量收敛在某个子模块内部;
    • 为了避免改动太大,先用 lazyInject 做一个“折中方案”,再择机重构。
  • ❌ 尽量避免的场景:

    • 核心服务层之间大量互相 lazyInject
    • lazyInject 当成解决所有循环依赖的常用手段,而不是先思考模块拆分。

在大型前端/工具型应用里,一个不错的经验是:
如果你经常需要用懒注入,可能暗示你的依赖图有点“纠缠不清”,值得退后一步重想一下边界。

这一篇想强调几个简单的点:

  • 循环依赖在 DI 世界里是常见难题,懒注入提供了一种“通过延后访问时机来打破死循环”的技术手段;
  • inversify-inject-decorators 里的 lazyInject 可以在某些场景(尤其是 UI 组件互相持有)下派上用场;
  • 但从架构角度看,它更像是“修补缝隙的胶水”,而不是应该大量铺开的日常模式。

对我自己来说,掌握 lazyInject 的最大价值在于:
遇到循环依赖时有一个临时的安全阀可以用,同时也能提醒自己——是不是该停下来审视一下整体依赖图的设计了。