23 · NestJs ModuleRef 模块引用:容器递给你的"取货窗口",四个 API 四种语义

承接上篇 :上一篇《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(静态能定的别挪运行期)------这三类都是"用动态性买来一身隐式",账是负的。

七、常见坑

  1. get 取 TRANSIENT/REQUEST → InvalidClassScopeException,文案本身指路 resolve;
  2. 以为 resolve 会返回新实例 → 传了同一个 contextId 就是同一份,想每次新的就别传或每次造新钥;
  3. create 出的对象当单例共享 → 每次全新,当单例用 = 每处一份的假单例;
  4. strict:false 一把梭 → 模块边界失效,依赖变隐式,重构时找不到消费方;
  5. 忘了 resolve 是 async → resolve() 返回 Promise,不 await 拿到的是 thenable;
  6. 在构造器里 get 依赖本模块还没实例化的东西 → 时序未定,放 onModuleInit 更稳(16 期钩子的通用价值);
  7. 把 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 题(先自己答,再看答案):

  1. get/resolve/create 各从哪拿实例?get 取 TRANSIENT 会怎样? → get 查容器缓存的单例;resolve 按 contextId 现造/复用 DI 子树;create 实例化未登记类。get 取 TRANSIENT 抛 InvalidClassScopeException(瞬态没有"那一份",文案指路 resolve)。

  2. 同一个 contextId resolve 两次,拿到同一份还是两份?为什么? → 同一份。contextId 是子树钥匙:首次现造整棵子树并缓存,后续同钥命中缓存------子树内单例(含子树里的 TRANSIENT 注入点)在该 contextId 下只造一次。

  3. 什么时候才该 { strict: false }?代价是什么? → strict 范围内找不到、且这个跨模块依赖确属合理(如 20 期环的替代方案)时。代价是绕过模块封装,没 export 的私有 provider 也能被掏走,依赖关系从 Module 元数据变隐式。

九、本篇收束与下一篇

ModuleRef 拆完,DI 进阶线把"取货方式"补上了:构造注入是静态接线,ModuleRef 是运行期取货,四 API 按实例存在性分层(查表 get / 现造子树 resolve / 无中生有 create)。没有运行期取货诉求时,构造注入永远第一优先------这份"默认不用"的克制,和四 API 的机制本身一样值得记住。

下一篇换"模块生命周期"这个维度:模块能不能不随应用启动而加载 ,等第一次真正用到才进容器?24 期《LazyLoadingModules 懒加载模块》讲 LazyModuleLoader 的机制、rag-server"真路径触发"的方案 A 落地,以及那个 NodeNext 编译期大坑------动态 import() 的相对路径必须显式带 .js。

相关推荐
captain3761 小时前
Maven
java·后端·idea
hsfxuebao1 小时前
Loop Engineering 已死? 一文带你了解Graph Engineering
人工智能·后端
码林鼠1 小时前
dart语言教学
前端
光影少年1 小时前
如果 React 组件的属性没有传值,它的默认值是什么?
前端·javascript·react.js
Carson带你学Android1 小时前
Gemini 4 Argon 发布:对 Android 开发者意味着什么?
android·ai编程
风骏时光牛马1 小时前
AI_Coding:智能代码生成与工程实践
前端
全栈弄潮儿1 小时前
给中级开发者的 AI 能力升级路线图
aigc·openai·ai编程
颜进强2 小时前
22 · NestJs InjectionScopes 注入作用域:默认单例不是偷懒,是最优——以及何时才该打破
前端·后端·ai编程
liangshanbo12152 小时前
Web Worker 在 AI 前端应用中有什么具体使用场景?
前端·人工智能