循环依赖不是"能不能解析"的问题,而是"在什么时机解析"的问题。
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];
},
});
}
三个实现要点:
- getter 闭包捕获的是安装它的容器 (
self)------app 容器装的属性永远走 app 解析,ctx(请求级)容器装的走 ctx 解析; - 缓存槽是实例级普通对象
SymbolBeanInstancePropsLazy,第一次访问后属性退化成"伪静态",后续零查找成本; 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 | 环闭合,无重入 |
两个不变量保证环安全:
- 实例化阶段 getter 从不执行(互不触碰);
- 解析阶段容器缓存里被依赖方必然已登记 ("先入缓存再 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 的懒属性注入机制通过三个设计决策从架构层面消解了这个问题:
- 构造函数零依赖------实例化阶段不触碰任何依赖;
- 懒属性 getter------依赖解析推迟到首次访问时;
- 先入缓存再 init------保证被依赖方在解析时必然已就绪。
循环依赖在 Vona 中不需要 forwardRef、不需要特殊标注、不需要开发者识别循环------它从时机差异上天然安全。这不是更聪明的循环检测,而是让循环依赖根本没有可乘之机的架构选择。