承接上篇 :上一篇《@Global 落地账》收口了模块层的"怎么装、装多广";但 DI 依赖图还有一个所有图问题的起点------环 。05 期讲容器"反射 → 查表 → 递归 new"那条链时,隐含了一个前提:依赖图是无环的(DAG)。A 依赖 B、B 又依赖 A,递归 new 就会永远等下去。 这篇拆它:报错三兄弟各在说什么、
forwardRef到底 forward 了什么(11.2.3 源码级)、以及什么信号说明这是设计问题该重构而不是补丁问题。
定位:本篇讲 provider 级/模块级两种环与救法、forwardRef 的"两阶段实例化"源码机制、ModuleRef 延迟取这个替代方案。不讲 ModuleRef 四 API 细节(23 期)、不讲事件发射器机制(掘金已独立成篇)。读完你能分清"真环"与"文件 import 假环",并解释 Object.assign 补齐的原因。
一、一张表先立判断
| 问题 | 本质 | 该怎么做 |
|---|---|---|
| 两个 class 构造互相注入 → 容器造不出来 | DI 依赖图成环(DAG 被打破) | 先想重构;绕不开才 forwardRef / ModuleRef |
两个 module 的 imports 互相引用 → 启动失败 |
模块图成环(扫描顺序无起点) | 两侧 forwardRef(() => XxxModule) |
报错里出现 (OrderService, ?, ...) |
依赖列表里该参数位置解析失败 的标记,常见诱因是 barrel / import type / 循环文件 import 让类型变 undefined |
先查 import 写法,通常根本不用 forwardRef |
@Injectable() 但构造参数类型 undefined |
TS 只把类引用 写进 design:paramtypes;接口/字面量/循环加载期的未定义类都拿不到 |
@Inject(token) 显式给 token |
一句话:forwardRef 是"容器先把两边的占位对象造好、真正实例化后原地补齐"的机制;而你遇到的大部分"循环依赖"其实是文件 import 层的坑------先分清再动手。
二、报错三兄弟:各说各的话
构造一个最小环:OrderService 要扣库存(注入 InventoryService),InventoryService 补货后要通知订单(注入 OrderService)。不加处理启动,你会撞上三兄弟之一:
1.1 容器实例化层(provider 环):
vbnet
Nest can't resolve dependencies of the InventoryService (?). Please make sure that the
argument at index [0] is available in the current module.
? 不是"神秘类型",是 Nest 拼错误信息时在解析失败的那个参数位打的标记(源码 messages.js:dependenciesName[index] = '?')。
1.2 模块图扫描层(module imports 环):
sql
Nest cannot create the module instance. Often, this is because of a circular dependency
between modules. Use forwardRef() to avoid it.
Scope [OrderModule -> InventoryModule -> OrderModule]
1.3 真正的环检测(CircularDependencyException)------注意最后一句:
sql
A circular dependency has been detected inside "OrderService". ...
Note that circular relationships between custom providers (e.g., factories) are not
supported since functions cannot be called more than once.
useFactory 这类工厂 provider 之间不允许环------函数没法调两次来兜底(17 期提过的边界,出处在这)。
真实调试顺序:先查是不是文件 import/barrel 的锅(第五节),再查模块图,最后才是 provider 实例化环。
三、Provider 级环:两侧都要 @Inject(forwardRef(...))
ts
// order.service.ts
@Injectable()
export class OrderService {
constructor(
@Inject(forwardRef(() => InventoryService))
private readonly inventory: InventoryService,
) {}
}
// inventory.service.ts ------ 另一侧同样加
constructor(
@Inject(forwardRef(() => OrderService))
private readonly order: OrderService,
) {}
三条纪律:
- 环的两侧都要加------容器按 DFS 实例化,无法预知先撞到 A 还是 B;
- 加在构造参数的
@Inject里,不是只加在模块imports------前者管 provider 环,后者管模块图环,两层各管各的。只改 imports 不动构造函数照样报 1.1 的错,这是 StackOverflow 最高频误区; - 同一模块内两个 provider 互相注入也算环,同样两侧标记,不需要动模块 imports。
模块级环则是在两侧 imports: [forwardRef(() => XxxModule)]------扫描器遇到 forwardRef 先不取类,登记"待定引用",等图真正消费时才取(18 期讲过编译器对 forwardRef 的拆盒)。
四、源码:forwardRef 到底 forward 了什么
4.1 本体:没有魔法,是个"打包盒"
@nestjs/common/utils/forward-ref.util.js 全文:
js
const forwardRef = (fn) => ({ forwardRef: fn });
把"延迟取类"的函数塞进一个带标记的对象。不是 Proxy,戏法在消费端拆盒。
4.2 拆盒:注入器盖的章是后面一切的核心开关
TS 只在 design:paramtypes 里写类引用 ;循环加载时先被 require 的类还在 TDZ 期,反射出来的类型是坏的。@Inject(forwardRef(() => X)) 的本质是绕开 TS 反射,把"盒子"当显式 token 交给容器 。注入器 injector.js 的 resolveParamToken:
js
resolveParamToken(wrapper, param) {
if (typeof param === 'object' && 'forwardRef' in param) {
wrapper.forwardRef = true; // ① 给消费方 wrapper 盖章
return param.forwardRef(); // ② 此刻类已全部加载完,安全取真类当查找 token
}
return param;
}
4.3 两阶段实例化:先占位,后 Object.assign 原地补齐
instance-loader.js 启动时先跑 createPrototypes,给每个 provider 造一个"只有原型、没跑构造器"的占位对象(Object.create(metatype.prototype));然后才 DFS 真实例化。DFS 撞到环上还没造完的一边时,取到的就是那个占位对象 。关键收尾在 instantiateClass:
js
instanceHost.instance = wrapper.forwardRef
? Object.assign(instanceHost.instance, new metatype(...instances)) // 环成员:原地升级
: new metatype(...instances); // 普通:正常 new
为什么必须 Object.assign 而不是重新赋值? 因为环的另一边早已把占位对象注入进自己的构造器并持有它的引用 。若 instance = new ...,只是给容器换个新对象,对方手里还是残缺占位;Object.assign 把真实例的自有属性抄进那个共享占位对象------对方持有的同一个对象就地变成完整实例。
less
startup
├─ [phase1] createPrototypes: A、B 各得占位对象
├─ [phase2] DFS 实例化
│ A 需要 B → B 未真造,返回 B 占位给 A 的构造器 ← A 拿到 B 的壳
│ A 构造完成
│ DFS 到 B:需要 A → A 已 resolved,给 A 真身
│ B.instance = Object.assign(B占位, new B(...)) ← B 占位补齐成真身
│ 此时 A 里持有的 B 占位 = B 真身(同一个对象)
前端移植锚点:这就是 JS 模块循环加载的
live binding隐喻的"对象版"------ESM 里import的是引用,模块加载完后引用自动指向最新值;Nest 用 Object.assign 在对象引用层面实现了同款"先给壳、后填肉"。区别:ESM 靠语言机制,Nest 靠容器两阶段 + 显式标记。
4.4 源码行为派生的三条警告
- 别在构造器里"用"循环依赖对象 ------实例化顺序不确定(官方原话 indeterminate),构造器里
this.inventory.reserve()时占位壳还没被补齐,大概率 undefined。真正的方法调用放onModuleInit或更晚(16 期钩子的又一用处); - REQUEST/TRANSIENT 作用域 + 环 → undefined------请求级每次重新实例化,不走静态单例那套"占位后补齐",官方明确警告。尽量别让 REQUEST 作用域的 provider 互相依赖(22 期会再遇到这条);
- 工厂 provider 之间不支持环(报错原文写了)------函数不能调两次,没有占位对象可依赖。
五、架构规避:让依赖图保持 DAG(先做这个,forwardRef 是兜底)
循环依赖绝大多数是设计信号,不是 Nest 缺陷。优先顺序:
- 抽公共:A、B 互相调用的那块逻辑抽到第三个 service,A、B 只依赖它;
- 事件驱动解耦 (
@nestjs/event-emitter,A 只 emit、B 只 @OnEvent,互不 import):适合通知式双向------消息的环发生在运行时,构造期依赖图仍是单向; - Facade/编排层:两域确实要互调业务方法,建一个编排层 import 两侧,让两域保持单向;
- 上工具提前检测 :CI 跑
npx madge --circular dist/**/*.js;模块/服务别从 barrel(index.ts)引,直接引具体文件------能消掉一大批"玄学循环"。
判定阈值:
| 信号 | 结论 |
|---|---|
| 从 barrel 引了类、删掉 barrel 就好 → 不是真环 | 修 import,别加 forwardRef |
报错 (X, ?, Y) + undefined dependency → 元数据层丢失 |
查 import type/循环文件 import |
| 抽公共/事件化改动大、两边就是强双向领域依赖 | 少数该用 forwardRef/ModuleRef 的真场景 |
| 依赖里掺了 useFactory 又成环 | 无解,必须重构 |
六、替代方案:ModuleRef 延迟取(23 期预告)
官方的"重构式"替代:环的一侧不注入对方,注入 ModuleRef,等容器全部就绪再取:
ts
@Injectable()
export class OrderService implements OnModuleInit {
private inventory: InventoryService;
constructor(private readonly moduleRef: ModuleRef) {}
onModuleInit() {
// 此刻双方都已实例化完毕
this.inventory = this.moduleRef.get(InventoryService, { strict: false });
}
}
| 方案 | 代价 | 适合 |
|---|---|---|
forwardRef |
wrapper 走"占位→补齐"路径,三条警告,类上不留显式依赖声明 | 环小、双向刚需、重构成本高 |
ModuleRef.get() |
丢构造期类型安全;跨模块要 { strict: false };要 OnModuleInit 兜底 |
只在一方向晚用 |
| 重构消除环 | 要动领域边界 | 永远的第一优先 |
记忆钩子:forwardRef 是"让容器机制上 允许环";ModuleRef.get 是"我在应用侧绕过去,不麻烦容器"。
七、常见坑
- 只加一侧 forwardRef → 另一侧照样报错;环的两侧都要;
- 只改模块 imports、不动构造参数 → provider 环没解,1.1 报错照旧;
- 构造器里调用环对象方法 → 占位壳未补齐,undefined;
- 工厂 provider 环上加 forwardRef → 无解,直接重构;
- REQUEST 作用域互相依赖 → 官方警告 undefined,能避则避;
- barrel 引发假环直接上 forwardRef → 掩盖了 import 写法问题,越补越乱。
八、前端心智对照 + 自测
| 前端概念 | 对应 | 本质 |
|---|---|---|
| ESM 循环 import 的 live binding | 占位对象 + Object.assign 补齐 | 先给壳后填肉 |
| barrel 文件循环引用的坑 | index.ts 假环 | 文件层,非容器层 |
| 事件总线替代互调(props 双向) | event-emitter 解环 | 运行时环 ≠ 构造期环 |
useEffect 里才取 ref 不在构造取 |
onModuleInit 才用环对象 | 时机让位于实例化顺序 |
自测 4 题(先自己答,再看答案):
-
forwardRef(() => X)返回什么?容器在哪里拆盒、盖的章有什么用? →{ forwardRef: fn }的打包盒;injector.js的resolveParamToken检测'forwardRef' in param时拆盒取真类当 token,并给消费方 wrapper 盖forwardRef = true章------这个章决定收尾时走Object.assign原地补齐而非普通 new。 -
为什么收尾必须 Object.assign 而不能替换引用? → 环的另一边早已持有占位对象引用;替换只换容器手里的,对方还是残缺壳;Object.assign 把真实例自有属性抄进共享占位对象,对方持有的同一对象就地完整。
-
"下单要通知库存"这类通知式双向依赖,为什么优先用事件而不是 forwardRef? → A 只 emit、B 只 @OnEvent,两者互不 import,构造期依赖图仍是单向------消息的环发生在运行时,不发生在构造期,环根本不进入依赖图。
-
报错
(OrderService, ?, ...)里的?一定意味着循环依赖吗? → 不。它只是"该参数位解析失败"的标记,barrel /import type/ 循环文件 import 让类型变 undefined 都会打?------先查 import 写法,通常不用 forwardRef。
九、本篇收束与下一篇
环拆完,DI 的"图问题"三件套齐了两件:05 期讲了容器怎么沿 DAG 递归造,本期讲了 DAG 被打破时容器怎么自救(占位 + 补齐)与不该自救时怎么重构。核心结论:最好的循环依赖处理,是让它不发生。
下一篇换一个维度继续 DI 进阶:时间 。provider 都是启动期同步 new 的吗?如果造一个依赖需要 await(等连接、等远端配置),容器怎么办?21 期《AsyncProviders 异步提供者》讲 useFactory 返回 Promise 之后容器发生了什么。