Cabloy/Vona 如何避开 NestJS 的循环依赖问题:懒属性注入机制源码解析

循环依赖不是"能不能解析"的问题,而是"在什么时机解析"的问题。

NestJS 的循环依赖是一个高频讨论话题:两个 Service 在构造函数中互相注入对方,实例化瞬间陷入"先有鸡还是先有蛋"的困境,只能靠 forwardRef() 打破解析顺序。Cabloy/Vona 从架构层面就不存在这个问题------不是因为它做了更聪明的循环检测,而是因为它把依赖解析的时机推迟到了首次访问时。

本文以 Cabloy/Vona 的 BeanContainer 和 @Use 装饰器源码为线索,拆解懒属性注入机制的完整实现。


先看结论

维度 NestJS 构造函数注入 Vona 懒属性注入
注入时机 实例化瞬间同步完成 首次访问属性时延迟完成
循环依赖 需要 forwardRef() + 延迟循环解析 天然安全,无需特殊处理
构造函数参数 依赖通过构造函数参数传入 构造函数零依赖,只挂 getter
解析入口 容器在 resolve() 时递归解析 getter 触发时从容器缓存查找
缓存策略 实例化后存入容器 先入容器缓存,再执行 __init__

一句话:NestJS 是"构造时注入、急切解析",循环会卡在实例化;Vona 是"声明时登记、访问时解析",实例化阶段互不触碰,循环依赖被时机差异天然消解。


NestJS 循环依赖的根源

先看 NestJS 的问题出在哪里。

构造函数注入的时机困境

NestJS 的依赖注入发生在构造函数阶段。当容器解析 A 时:

ts 复制代码
@Injectable()
class ServiceA {
  constructor(private readonly b: ServiceB) {}  // 构造函数参数 = 依赖
}

容器需要先创建 B 才能创建 A。但如果 B 的构造函数又依赖 A:

ts 复制代码
@Injectable()
class ServiceB {
  constructor(private readonly a: ServiceA) {}
}

解析链条变成:new A() → 需要 B → new B() → 需要 A → A 还没建完 → 死循环。

forwardRef 只是变通,不是消除

NestJS 用 forwardRef() 和 InjectionToken 延迟解析顺序,让一边先完成实例化,再回头注入另一边。但这只是变通手段:

  • 需要开发者手动识别循环并标注 forwardRef;
  • 循环链条过长时仍可能遇到实例未就绪的问题;
  • 增加了代码的隐式复杂度。

Vona 的设计选择了不同的路径:从架构上就不让循环依赖有可乘之机。


Vona 的三层防线

Vona 从架构上不存在"实例化期"循环依赖问题,核心区别是把依赖解析从实例化时 推迟到了首次访问时。由三层机制保证:

1. 没有构造函数注入

Vona 的 Service/Bean 一律继承 BeanBase,构造函数不接收任何依赖。实例创建完成后容器才补上 app / ctx 引用,依赖改用 @Use(...) 装饰器声明在属性上。

2. 懒属性注入(关键机制)

@Use 声明的依赖并不是在 Bean 创建时就注入实例,而是用 Object.defineProperty 挂一个 getter。首次访问属性时,getter 才从容器解析目标 Bean 并缓存结果。

3. 容器本身是运行时查找

BeanContainer.create 返回的是懒 Proxy,this.bean.xxxService、this.scope('module-x') 都是访问时才按 bean 全名从容器解析,不产生任何静态接线。


源码拆解:五个环节的完整实现

下面按"声明 → 存储 → 挂载 → 解析 → 缓存"五个环节拆解源码。

环节 1:声明期------@Use 装饰器只做两件事

ts 复制代码
// use.ts
export function Use(options?: IDecoratorUseOptions | string): PropertyDecorator & MethodDecorator {
  return function (target: object, prop: MetadataKey, descriptor?: PropertyDescriptor) {
    // ① 从 TS design:type 元数据读出属性类型
    const beanClass = appMetadata.getDesignType(target, prop) as Constructable;

    // ② 类 → bean 全名映射
    let beanFullName: string | undefined = options.beanFullName;
    if (!beanFullName) {
      beanFullName = appResource.getBeanFullName(beanClass);
    }

    // ③ 登记元数据,到此为止,没有任何实例化
    appResource.addUse(target, { ...options, prop, beanFullName, descriptor });
  };
}
  • 从 TypeScript emitDecoratorMetadata 生成的 design:type 元数据里读出属性类型,所以 @Use() 不传参也能知道目标 Bean;
  • 类 → bean 全名(module-name.scene.name)的映射必须存在,否则直接抛错;
  • 调用 appResource.addUse 登记元数据,没有任何实例化发生。

环节 2:存储期------以属性名为键的元数据 Map,沿原型链继承

ts 复制代码
// resource.ts
addUse(target: object, options: IDecoratorUseOptionsBase) {
  registerMappedClassMetadataKey(target, SymbolDecoratorUse);
  const uses = appMetadata.getOwnMetadataMap(true, SymbolDecoratorUse, target);
  uses[options.prop] = options;
}

getUses(target: object): Record<MetadataKey, IDecoratorUseOptionsBase> | undefined {
  return appMetadata.getMetadata(SymbolDecoratorUse, target);  // 注意是 getMetadata
}

写入用 getOwnMetadataMap(挂在类 prototype 上、以 prop 为键),读取用 getMetadata(沿原型链向上查)------这意味着子类自动继承父类声明的所有 @Use 依赖。

环节 3:挂载期------实例化后只装 getter,不解析

Bean 实例创建流程中的严格顺序是:

ts 复制代码
// beanContainer.ts
private _createBeanInstance(...) {
  const beanInstance = this._prepareBeanInstance(...);          // new + 挂 app/ctx
  this[SymbolBeanContainerInstances][key] = beanInstance;       // ① 先入容器缓存
  this._initBeanInstance(beanFullName, beanInstance, args);     // ② 注入 getter → __init__
}

这个顺序保证了:即使某个 Bean 在 __init__ 里就访问依赖属性,触发了反向解析,容器也能直接命中缓存返回已登记的实例。

注入本身的核心实现:

ts 复制代码
// beanContainer.ts
private _injectBeanInstancePropLazy(beanInstance, prop, targetBeanFullName, useOptions) {
  const self = this;
  Object.defineProperty(beanInstance, prop, {
    enumerable: true,        // 属性可枚举(序列化/展开时可见)
    configurable: true,      // HMR 时可重定义
    get() {
      if (!beanInstance[SymbolBeanInstancePropsLazy])
        beanInstance[SymbolBeanInstancePropsLazy] = {};
      if (!beanInstance[SymbolBeanInstancePropsLazy][prop]) {
        // 首次访问:真正解析,然后缓存到实例私有槽位
        beanInstance[SymbolBeanInstancePropsLazy][prop] =
          self._injectBeanInstanceProp(beanInstance, targetBeanFullName, useOptions);
        // app 级容器额外向 HMR 登记这条依赖边
        self.containerType === 'app' &&
          self.app.meta.hmr.addBeanInstanceProp(beanInstance, prop, targetBeanFullName);
      }
      return beanInstance[SymbolBeanInstancePropsLazy][prop];
    },
  });
}

三个实现要点:

  1. getter 闭包捕获的是安装它的容器 (self)------app 容器装的属性永远走 app 解析,ctx(请求级)容器装的走 ctx 解析;
  2. 缓存槽是实例级普通对象 SymbolBeanInstancePropsLazy,第一次访问后属性退化成"伪静态",后续零查找成本;
  3. enumerable: true / configurable: true 的组合是刻意为之:可枚举保证实例导出行为一致,可配置让 HMR 拆掉重装。

环节 4:解析期------三种注入域

首次访问触发 _injectBeanInstanceProp:

ts 复制代码
// beanContainer.ts
private _injectBeanInstanceProp(
  beanInstance,
  targetBeanFullName: string,
  useOptions: IDecoratorUseOptionsBase,
) {
  const injectionScope = useOptions.injectionScope ?? 'app';

  if (injectionScope === 'app') {
    targetInstance = this.app.bean._getBeanSelectorInner(...);  // 应用级单例
  } else if (injectionScope === 'ctx') {
    targetInstance = this._getBeanSelectorInner(...);           // 请求级单例
  } else if (injectionScope === 'new') {
    targetInstance = this._newBeanInner(...);                   // 每次新实例
  }
  return targetInstance;
}
  • 'app' / 'ctx' 落到 _getBeanSelectorInner:容器实例缓存键为 beanFullName 或 beanFullName#selector,=== undefined 才创建;
  • 'new' 虽然每次解析都 new,但外层 getter 已缓存,所以单个属性生命周期内仍只解析一次;
  • selector 参数从三个来源按优先级取:装饰属性的 descriptor getter → useOptions.init 静态参数 → useOptions.selector 字符串表达式(求值上下文含 self/app/ctx)。

环节 5:缓存期------容器实例缓存

_getBeanSelectorInner 的缓存逻辑:

ts 复制代码
// beanContainer.ts
_getBeanSelectorInner<T>(
  beanFullName: Constructable<T> | string,
  withSelector?: boolean,
  ...args
): T {
  const beanOptions = appResource.getBean(beanFullName as any);
  const fullName = beanOptions.beanFullName;
  const key = __getSelectorKey(fullName, withSelector, args[0]);
  if (this[SymbolBeanContainerInstances][key] === undefined) {
    this._newBeanInner(true, fullName, withSelector, ...args);
  }
  return this[SymbolBeanContainerInstances][key] as T;
}

缓存键的生成规则:

ts 复制代码
function __getSelectorKey(beanFullName: string, withSelector?: boolean, selector?: any) {
  if (!withSelector) return beanFullName;
  const isSelectorValid = !isNilOrEmptyString(selector);
  return !isSelectorValid ? beanFullName : `${beanFullName}#${selector}`;
}

A↔B 循环依赖的完整时序

以 A、B 两个 Service 互相 @Use 对方为例,走一遍完整时序:

时刻 动作 结果
装饰期 @Use 登记 A→B、B→A 两条映射 无实例交互
业务触发 _getBean('A') new A(),挂懒 getter,入容器缓存 A 完整存在
首次访问 a.serviceB getter 执行 → 容器创建 B(B 的构造和 getter 安装同样不碰 A),入缓存 B 完整存在
B 内部访问 b.serviceA getter 执行 → 容器缓存命中 → 返回现成的 A 环闭合,无重入

两个不变量保证环安全:

  1. 实例化阶段 getter 从不执行(互不触碰);
  2. 解析阶段容器缓存里被依赖方必然已登记 ("先入缓存再 init"的顺序,甚至覆盖了 __init__ 期间就访问依赖的激进场景)。

实际代码示例

Vona 的 @Use 用法

ts 复制代码
import { BeanBase, Use } from 'zova';
import { Service } from 'zova-module-a-bean';

@Service()
export class BeanRouter extends BeanBase {
  @Use()
  $$sysRouter: SysRouter;

  @Use()
  $$modelPageData: ModelPageData;
}

注意:构造函数零参数,依赖全部通过 @Use 声明在属性上。访问 this.$$sysRouter 时才触发解析。

Vona 后端业务代码的等价路径

当前仓库后端业务代码更常见的写法其实不是属性级 @Use,而是 this.scope('module-x').service.yyy 和 this.bean.xxx------它们走的是同一容器的运行时查找路径:

ts 复制代码
// BeanContainer.create 返回的 Proxy
static create(app: VonaApplication, ctx: VonaContext | undefined) {
  const beanContainer = new BeanContainer(app, ctx);
  return new Proxy(beanContainer, {
    get(obj, prop) {
      if (typeof prop === 'symbol') return obj[prop];
      if (obj[prop]) return obj[prop];
      return obj._getBean(prop as any);  // 访问时才解析
    },
  });
}

本质上是同一套懒解析的"方法调用版"。两条路径的差异只在声明方式(装饰器元数据 vs 字符串查找),解析与缓存机制完全共用。


失效边界与 HMR

懒属性注入并非没有需要关注的边界:

_removeBean 只会销毁容器缓存里的实例并调用其 __dispose__,其他 Bean 实例上 SymbolBeanInstancePropsLazy 里已缓存的那份引用不会被自动清空------这正是 HMR 需要 addBeanInstanceProp 登记依赖边来重接线的原因。

所以在热更新场景下,框架能通过登记的依赖边找到所有引用了被替换 Bean 的属性,拆掉旧 getter、重装新 getter、清空缓存槽,完成依赖重接线。


模块层面的循环依赖

除了实例层面的循环依赖,Vona 在模块层面也做了设计。按文档约定:

使用 this.$scope.<fixedModule>、app.scope(...) 或 this.app.scope(...) 本身不要求将目标模块添加到调用者的 vonaModule.dependencies 中。查找不创建模块依赖边,因此单独的查找不能创建循环依赖。

这意味着模块层面的循环依赖也被设计性地避免了:跨模块 lookup 不产生依赖声明,不构成模块依赖边,所以模块层面也不会因"用了对方的服务"而形成循环依赖。


总结

NestJS 的循环依赖问题根源在于"构造函数注入 + 急切解析"的设计选择:依赖必须在实例化瞬间完成解析,循环引用必然导致死锁,只能靠 forwardRef 延迟解析顺序。

Vona 的懒属性注入机制通过三个设计决策从架构层面消解了这个问题:

  1. 构造函数零依赖------实例化阶段不触碰任何依赖;
  2. 懒属性 getter------依赖解析推迟到首次访问时;
  3. 先入缓存再 init------保证被依赖方在解析时必然已就绪。

循环依赖在 Vona 中不需要 forwardRef、不需要特殊标注、不需要开发者识别循环------它从时机差异上天然安全。这不是更聪明的循环检测,而是让循环依赖根本没有可乘之机的架构选择。


参考资料与源码出处

NestJS

CabloyJS / Vona

相关推荐
月走乂山8 小时前
DSH 桌面版用 DeepSeek 报 REQUEST_EXTENSION 的根因定位与修复
electron·node.js·插件系统·deepseek·bug排查·dsh·deepseek harness
斯晨的AI笔记20 小时前
在服务器上跑 npm ci 四十分钟没装完,我打包把 node_modules 扔了过去
服务器·node.js
cpolar技术支持21 小时前
第三方平台回调总说收不到?把本机 Webhook 收单页跑通,用 cpolar 固定二级子域名给对面看实时请求
node.js·内网穿透·webhook·cpolar·回调对接
雨雨雨雨雨别下啦1 天前
【OpenHarmony北向开发】ArkTS基础教学
typescript·arkts·openharmonys
ZoeLandia1 天前
TypeScript 01:类型注解与类型推断
前端·javascript·typescript
ZoeLandia1 天前
TypeScript 02:interface、type 与类型收窄
前端·typescript
行者-全栈开发1 天前
华为云码道 CodeArts 实测:让 AI 用纯 TypeScript 写一个确定性回放的塔防小游戏「熵减特勤局」
typescript·小游戏·canvas·ecs·ai 编程·华为云码道·codearts 代码智能体
damoluomu1 天前
Node.js CMS 的官网和仓库,22 个一次收齐(五)
node.js