承接上篇 :上一篇《InjectionScopes》收口了 provider 的"份数"维度,结尾留了一个钩子:到此为止所有依赖都是构造期静态注入 的------Nest 在启动时沿依赖图把一切 new 好,你只管在构造器里伸手接。但如果想在运行期 按条件从容器里取一个实例呢?20 期讲循环依赖时预告过它是"环的替代方案",17 期讲 TRANSIENT 时它又露过脸(ResolveDependencyInterface)。 这篇拆它:ModuleRef 的 get/resolve/create 四个 API 各取什么、为什么 get 取不到瞬态、contextId 是"子树钥匙"的比喻、strict 边界。
定位:本篇讲运行期取货四 API 的语义差异(单例查表 / 子树现造 / 未登记类实例化)、contextId 与 ContextIdFactory、strict:false 的边界与代价。不讲 DI 基础(04/05 期)、不讲作用域机制(22 期)、不讲懒加载模块(24 期)。读完你能回答"运行期取实例该用哪个 API"。
一、一句话回答
ModuleRef 是 Nest 递给你的"容器取货窗口"------注入它,就能在运行期向容器要实例,而不用在构造期写死依赖。 四个 API 的语义严格分层:
get(token):查表取已缓存的单例 (默认态)。取瞬态/请求级会抛InvalidClassScopeException------它们没有"那一份"可给;resolve(token, contextId?):按 contextId 现造一棵独立 DI 子树,TRANSIENT 的正确取法;同一个 contextId 两次 resolve 拿到同一份,不同 contextId 各自独立;create(token):实例化容器里根本没登记的类(自己当一次 mini 容器);getByRequest(req, token)/registerRequestByContextId:把某个请求对象登记成 contextId 的请求载体,HTTP 上下文外的取货配套。记忆锚点:get 是"查缓存",resolve 是"造新的",create 是"无中生有"。 三者覆盖"实例存在性"的三个档位。
scss
实例从哪来?
get(token) ──► 容器缓存的那份单例(查表,快,取不到 scoped)
resolve(token, id) ──► 按 contextId 现造一棵子树(TRANSIENT/请求级的正确姿势)
create(token) ──► 这个类没进任何模块?我直接 new 一棵给你
前端移植锚点:构造注入 ≈ 顶层 import (模块加载时就绑定);ModuleRef ≈ 动态
import()/React.lazy------运行期才解析,换来按需与解耦,代价是丢掉编译期的那套静态检查。20 期说它是"环的替代方案"正是这个原理:依赖不在构造期发生,依赖图上就没有那条边。
二、get():查表取单例,取不到瞬态
ts
@Injectable()
export class CatsService {
constructor(private readonly moduleRef: ModuleRef) {}
findHelper() {
// 默认 strict 模式:只在本模块的注册范围内找
const helper = this.moduleRef.get(HelperService);
return helper;
}
}
get 的语义是读容器缓存 :token 对应的单例如果已实例化,直接返回那份;对 Scope.TRANSIENT / Scope.REQUEST 的类,get 直接抛异常------
sql
Nest cannot create the instance of the requested class by using the
"get" method. Use the "resolve" method instead.
为什么?回到 22 期的份数模型:瞬态"每个消费方一份"、请求级"每请求一份"------它们没有"容器那一份"可言 ,get 的查表语义落空。这不是缺陷,是 API 语义的刻意收窄:能查缓存的走 get,需要现造的走 resolve。
三、resolve():contextId 是"子树钥匙"
resolve 是这篇的主角,机制在contextId:
- 不传 contextId :
resolve(token)每次调用都ContextIdFactory.create()生成新 id、现造一棵新的 DI 子树------两次 resolve 拿到两个不同实例(同一个 TRANSIENT 服务也会 new 两次); - 传同一个 contextId :两次 resolve 命中同一棵子树缓存 ,拿到同一个实例(子树内的单例在该 contextId 下只造一次);
- 传不同 contextId:各自独立子树,互不共享。
一句话:contextId = "这次取货归属哪棵子树"的钥匙;同钥同树,异钥异树。
ts
const id = this.moduleRef.getContextId? // ❌ 不存在这个 API,别猜
const ctxId = ContextIdFactory.create(); // ① 自己造一把钥匙
const a = this.moduleRef.resolve(MyService, ctxId); // ② 首次:现造子树
const b = this.moduleRef.resolve(MyService, ctxId); // ③ 同钥:命中同一份
// a === b === true;换成两次不传 ctxId 的 resolve,a !== b
22 期讲 TRANSIENT 的"每次注入 new 一份"是构造期 语义;resolve 的"每次调用现造"是取货期语义------两者相乘才是全貌:TRANSIENT 类在 resolve 子树里,每个注入点一份、每棵子树一套、每个 contextId 一套。
配套的请求登记:HTTP 请求进来时,Nest 用 ContextIdFactory.getByRequest(req) 拿"这个请求专属"的 contextId;反过来,在非 HTTP 场景(CLI/定时任务/MCP)想给某请求挂上子树,用 moduleRef.registerRequestByContextId(fakeReq, ctxId)------把任意对象登记为该 contextId 的请求载体,子树里 @Inject(REQUEST) 的组件就能读到它。这是"请求级状态"脱离 HTTP 也能用的钥匙。
四、create():实例化未登记的类
ts
// DemoUnregisteredService 没有进任何模块的 providers
const instance = await this.moduleRef.create(DemoUnregisteredService);
create 不查表、不进子树缓存------它对着传入的类当场当一次 mini 容器 :解析构造依赖、new 出来给你,完事不登记。适合"工具类想享受 DI 解析、但不想占容器一个坑"的场景。注意它是一次性的:每次 create 都全新实例,别拿它当单例用。
五、strict 边界:模块私有性的最后一道闸
默认 get/resolve 是 strict 的:只在本模块(含 imports 链)注册范围内查找。跨模块取货要显式 { strict: false }:
ts
this.moduleRef.get(PeerService, { strict: false }); // 翻遍全容器
this.moduleRef.get(PeerService); // 只查本模块,找不到抛错
代价:strict:false 绕过了模块的封装边界 ------别人模块里没 export 的 provider 你也能掏出来。它能救急(20 期"环的替代方案"常配它),也让"哪些模块依赖哪些模块"从 Module 元数据变成隐式。判断线:strict 找得到,说明依赖关系合法,优先 strict;非要 false,先问这依赖是不是该显式 import。
六、什么时候才该用 ModuleRef(判断红线)
| 触发信号 | 用哪个 | 备注 |
|---|---|---|
| 运行期按配置/请求选实现(策略分发) | get(token, {strict:false}) 或 resolve |
前提:多实现都已在容器登记 |
| 处理循环依赖(20 期) | get + onModuleInit 里取 |
"一方向晚用"才值得 |
| 要 TRANSIENT/请求级的新实例 | resolve + contextId |
记住"同钥同树" |
| 工具类享受 DI 但不想占注册坑 | create |
一次性,别当单例 |
| 非 HTTP 场景要请求级子树 | registerRequestByContextId |
MCP/CLI/定时任务的钥匙 |
| 以上都没有 | 不用 | 构造注入永远第一优先 |
反向红线:拿 ModuleRef 规避模块封装(strict:false 掏私有)、拿 create 当单例工厂、拿 resolve 替代 useFactory(静态能定的别挪运行期)------这三类都是"用动态性买来一身隐式",账是负的。
七、常见坑
- get 取 TRANSIENT/REQUEST →
InvalidClassScopeException,文案本身指路 resolve; - 以为 resolve 会返回新实例 → 传了同一个 contextId 就是同一份,想每次新的就别传或每次造新钥;
- create 出的对象当单例共享 → 每次全新,当单例用 = 每处一份的假单例;
- strict:false 一把梭 → 模块边界失效,依赖变隐式,重构时找不到消费方;
- 忘了 resolve 是 async →
resolve()返回 Promise,不 await 拿到的是 thenable; - 在构造器里 get 依赖本模块还没实例化的东西 → 时序未定,放 onModuleInit 更稳(16 期钩子的通用价值);
- 把 ModuleRef 当万能服务定位器(Service Locator) → 满屏 moduleRef.get 是反模式,构造注入的显式依赖永远更可测。
八、前端心智对照 + 自测
| 前端概念 | 对应 | 本质 |
|---|---|---|
顶层 import vs 动态 import() |
构造注入 vs ModuleRef | 静态绑定换运行期按需 |
React.lazy + 缓存 Map |
resolve + contextId 缓存 | 同钥同树,异钥异树 |
new Chart(el, opts) 工具类 |
create | 用完即弃,不进注册表 |
| 深层 import 私有文件 | strict:false 掏隔壁模块 | 封装边界失效 |
| Service Locator(反模式) | 滥用 ModuleRef | 显式依赖 > 隐式查找 |
自测 3 题(先自己答,再看答案):
-
get/resolve/create 各从哪拿实例?get 取 TRANSIENT 会怎样? → get 查容器缓存的单例;resolve 按 contextId 现造/复用 DI 子树;create 实例化未登记类。get 取 TRANSIENT 抛 InvalidClassScopeException(瞬态没有"那一份",文案指路 resolve)。
-
同一个 contextId resolve 两次,拿到同一份还是两份?为什么? → 同一份。contextId 是子树钥匙:首次现造整棵子树并缓存,后续同钥命中缓存------子树内单例(含子树里的 TRANSIENT 注入点)在该 contextId 下只造一次。
-
什么时候才该
{ strict: false }?代价是什么? → strict 范围内找不到、且这个跨模块依赖确属合理(如 20 期环的替代方案)时。代价是绕过模块封装,没 export 的私有 provider 也能被掏走,依赖关系从 Module 元数据变隐式。
九、本篇收束与下一篇
ModuleRef 拆完,DI 进阶线把"取货方式"补上了:构造注入是静态接线,ModuleRef 是运行期取货,四 API 按实例存在性分层(查表 get / 现造子树 resolve / 无中生有 create)。没有运行期取货诉求时,构造注入永远第一优先------这份"默认不用"的克制,和四 API 的机制本身一样值得记住。
下一篇换"模块生命周期"这个维度:模块能不能不随应用启动而加载 ,等第一次真正用到才进容器?24 期《LazyLoadingModules 懒加载模块》讲 LazyModuleLoader 的机制、rag-server"真路径触发"的方案 A 落地,以及那个 NodeNext 编译期大坑------动态 import() 的相对路径必须显式带 .js。