项目开源地址(本专栏实证代码的教程仓)
https://gitee.com/yanjinqiang/corp-rag-tutorial(复制到浏览器打开)
承接上篇 :上一篇《ModuleRef 模块引用》讲的是运行期取实例 ------模块早已在图里,只是取货方式变了。这篇把"运行期"再推一步:模块本身能不能运行期才进图? 前端同学看到 "lazy-loading modules" 八个字,本能反应是"路由按需加载"------这是本篇第一个要泼的冷水:Nest 的懒加载和前端同源(都是 dynamic import),但切的维度完全不同,而且 controller(路由)恰恰不能懒加载。 这篇拆它:LazyModuleLoader 三步用法、三条硬边界(controller/@Global/生命周期钩子)、rag-server "真路径触发"的落地方案 A,以及那个 NodeNext 编译期大坑------动态import()的相对路径必须显式带.js。
定位 :本篇讲懒加载模块的触发场景(serverless/worker)、三条硬边界的原因、rag-server IndexingModule 落地的完整对照(含"机制演示非性能优化"的诚实标注),与前端 code splitting 的三差异对照。不讲 ModuleRef 四 API(23 期)、不讲动态模块 forRoot(18 期------forRoot 是"启动期选配方",懒加载是"运行期进图",两码事)。读完你能回答"我的服务该不该用懒加载",并复述那个.js扩展名坑。
一、一句话回答
Nest 的懒加载 = 用
await import()+LazyModuleLoader.load(...)把"一整棵 provider 模块子树"延迟到业务代码真正触发时才塞进模块图。 默认情况下 Nest 是 eager :应用一启动,所有@Module({ imports })里的模块全部实例化(管你现在用不用);懒加载让你推迟这一步。三条立刻要知道的硬边界:
- 懒加载模块不能含 controller / resolver / gateway(HTTP 路由表启动期就焊死,运行期加不了);
- 懒加载模块不能
@Global,全局 enhancer(APP_GUARD 等)对它不生效;- 懒加载模块/服务里,生命周期钩子(OnModuleInit 等)不会执行------它错过了启动那一轮仪式(16 期边界三)。
前端懒加载切"视图",Nest 懒加载切"服务";前端路由能懒加载,Nest 路由不能。 两套心智,别套用。
javascript
默认(eager) lazy 加载
───────────────── ─────────────────
app 启动 → 所有 imports 模块 app 启动 → 只加载核心模块
全部 new 好、全就绪 │
某个分支触发:
await import('./indexing.module.js')
loader.load(() => IndexingModule)
此时才实例化 IndexingService ✓(首载后缓存)
记忆锚点:04 期的 providers 是登记表 ,22 期管"一份活多久",懒加载管的是这张登记表要不要拆成"先登记一部分、到点了再补登记另一部分"------但补登记只对 provider 有意义,路由这类"启动期焊死"的东西不归它管。
二、什么时候才有这个需求
Nest 模块默认 eager,对 monolith 这没问题------启动一次、常驻运行,启动成本摊薄到无限长的生命周期里。痛点只在两类场景:
- Serverless / FaaS(冷启动敏感):每次函数调用可能是新进程,启动耗时直接算进请求延迟------全量加载 = 白加载一堆这次用不到的模块;
- Worker / Cron / Webhook:按入参触发不同分支的服务逻辑,没必要启动就把所有分支实例化好。
官方本页自己都说:monolithic 应用启动时间无所谓,懒加载意义不大。 那为什么 rag-server 还是落了一个?------见 §五,答案是"机制演示"。
三、最简用法三步
前提:被懒加载的模块,不能出现在任何 @Module({ imports }) 里 ------静态 import 过就是 eager,懒加载对它毫无意义。懒加载模块 = "不 import、只 import()"的模块。
ts
// ① 注入 LazyModuleLoader(来自 @nestjs/core)
constructor(private readonly lazyModuleLoader: LazyModuleLoader) {}
// ② 业务分支里按需加载
const { IndexingModule } = await import("../indexing/indexing.module.js");
const moduleRef = await this.lazyModuleLoader.load(() => IndexingModule);
// ③ 按 token 取 provider(23 期的 get:查表取单例)
const { IndexingService } = await import("../indexing/indexing.service.js");
await moduleRef.get(IndexingService).run(system);
两个值得注意的点:
load()收的是"返回模块类的函数"() => IndexingModule,不是类本身------loader 要在"真正加载那一刻"才求值模块引用(惰性求值);- 首载后缓存 :官方实测首次 load ~2.4ms,之后 ~0.3ms------模块第一次 load 后被缓存,后续 load 直接返回同一个 moduleRef;且懒加载模块与 eager 模块共享同一张模块图,不是另一套容器。
前端直觉:这三步 ≈
React.lazy(() => import('./Home'))后注册进渲染树。区别是前端 import 完就能渲染;Nest import 完还要load()入图、再moduleRef.get(token)取服务------多了"入图"和"按 token 取"两步,因为你要的不是视图而是 DI 容器里的 provider(23 期的 API 在这里复用)。
四、三条硬边界(本篇的灵魂)
边界一:不能含 controller / resolver / gateway
最反直觉的一条。为什么?Express/Fastify 不允许应用启动、开始监听之后再注册路由 ------就算懒加载模块里有 controller,路由也注册不进去,等于静默无效(官方原话 "will not behave as expected")。微服务(Kafka/RabbitMQ)要在连接前订阅好 topic;GraphQL code-first 的 schema 启动时全量生成。Nest 的路由是"进程启动时的一次性注册表",运行期不能加号。
边界二:不能 @Global,全局 enhancer 不生效
模块"按需才注册",注册时静态模块早实例化完了,全局共享没有意义;同理 APP_GUARD/APP_PIPE 这类全局 enhancer(08 期绑定表)不覆盖懒加载模块里的 provider。
边界三:生命周期钩子不执行
OnModuleInit/OnApplicationBootstrap 的语义是"应用启动完成时回调"------懒加载模块在应用已经启动之后 才加载,错过了那一轮(16 期讲钩子时收过这条边界的预告)。别指望懒加载服务里用 OnModuleInit 初始化,初始化只能靠调用方显式触发 (rag-server 的答案就是 run())。
前端直觉:边界一最值得反复品------前端懒加载的路由,懒的是"视图",而"要不要这条路由"发生在导航时,天然支持按需;Nest 的路由表启动期焊死。Nest 懒加载能切的,只有"同一批已注册端点背后的服务逻辑",不是"动态长出新端点"。
五、rag-server 实证:真路径触发的方案 A
rag-server 是 PM2 常驻 monolith,按 §二的判断标准"用不上"懒加载------但项目还是落了一个真路径触发的落地(不是造 demo 端点,而是挂在真实业务 reindex 上),选型时对过三案:
| 方案 | 内容 | 结论 |
|---|---|---|
| A(落地) | reindex handler 内动态 import() IndexingModule + load |
真实业务路径触发,机制可验证 |
| B | 造 /v1/demo/lazy 专门触发端点 |
纯演示端点,学习模块已有先例但本主题适合挂真路径 |
| C | 不落地只写笔记 | 与"机制要跑过才算"(23 期口径)冲突 |
落地形态 :src/indexing/ 两文件------IndexingModule(providers/exports 各一行,无 controller、非 @Global、不进任何静态 imports)+ IndexingService(编排 core 的 indexSystem/indexAllSystems)。触发点在 RagService.reindex(rag.service.ts):
ts
// rag.service.ts(真实代码,节选)
constructor(private readonly lazyModuleLoader: LazyModuleLoader) {}
async reindex(system?: System) {
const { IndexingModule } = await import("../indexing/indexing.module.js"); // ← .js!
const { IndexingService } = await import("../indexing/indexing.service.js");
const moduleRef = await this.lazyModuleLoader.load(() => IndexingModule);
await moduleRef.get(IndexingService).run(system);
return system ? { indexed: [system] } : { indexed: [...SYSTEMS] };
}
boot 冒烟已证:IndexingModule 不出现在 eager InstanceLoader 清单里,启动日志干净;首次 reindex 才实例化。
5.1 三条边界在真实代码里的对应
indexing.service.ts 头注释把三条边界逐条对齐了现况(这段注释本身就是 16 期"边界三"的现成证据):
- 无 controller ✓(reindex 是 RagController 的既有端点,懒加载的只是它背后的服务);
- 非 @Global ✓,全局 APP_GUARD 不覆盖 IndexingService------但它由已过 guard 的 handler 内主动调用,且仍在 RequestContextMiddleware 的
AsyncLocalStorage.run()内,getRequestId()照常可读(09 期 ALS 的隐形通道在这里救了场:enhancer 不生效,上下文传播不受影响); - 钩子不执行 ✓,初始化由调用方显式
run()。
5.2 NodeNext 大坑:动态 import() 必须显式 .js
编译期规则:module NodeNext 下,动态 import() 按 ESM 语义解析,相对路径必须显式带 .js 扩展名 (TS2835 报错提示补 .js,tsc 会把 .js 映射回同名 .ts 做类型检查);静态 import 才允许裸写扩展名。dist 产物保留原生 await import(),named export 走 CJS-ESM interop。这是本篇最"值钱"的工程坑------不踩一次很难想到动态和静态 import 的扩展名规则是不对称的。
5.3 诚实结论:机制演示,非性能优化
源码头注释原话级别的诚实:索引的重量(embedding/切分/LanceDB 写入)在 core 的 per-call 函数里,懒加载省的只是"IndexingService provider 子树"的实例化时机,不是启动毫秒/内存 ------它是 monolith 里"低频、一次调用只跑一段"分支的机制演示。monolith 该不该用懒加载的判断没有因此改变;变的是这个判断被跑过、被验证过,和 23 期 module-ref-demo 同一个姿态。
六、判断红线 & 常见坑
触发信号(才考虑用): ① serverless/FaaS 冷启动敏感;② worker/cron/webhook 按入参分支;③ 想"热"进程空闲时后台补齐模块(deferred registration)。rag-server 三条都不占,落地是学习投资。
| 坑 | 说明 |
|---|---|
| 想懒加载"路由/新接口" | 做不到(边界一),Express/Fastify 启动后不能注册路由 |
| 懒加载模块里放 controller | 静默无效,路由注册不进去 |
| 指望全局守卫保护它 | APP_GUARD 等 enhancer 不生效(边界二) |
| 懒加载服务里用 OnModuleInit | 不执行(边界三),得调用方手动触发 |
| 模块还被静态 imports 引了 | 早就 eager 加载,懒加载白写 |
| 想靠它"省常驻内存" | 只延迟实例化,用过就缓存常驻;省内存要靠少加载 |
| NodeNext 下动态 import 裸写路径 | 编译报 TS2835,必须显式 .js(§5.2) |
| 以为 load 每次都重实例化 | 首载后缓存 moduleRef,二次 load ~0.3ms |
七、前端心智对照 + 自测
| 前端概念 | 对应 | 差异点(别套错) |
|---|---|---|
React.lazy(() => import('./Home')) |
loader.load(() => IndexingModule) |
前端切"视图",Nest 切"服务子树" |
| 路由懒加载(命中才下包) | controller 不能懒加载 | Nest 路由启动期焊死 |
| 首屏只加载首屏 chunk | serverless 冷启动只加载本次需要的模块 | 都是"用到才付" |
| 打包器 chunk 缓存 | LazyModuleLoader 首载后缓存 moduleRef | 第二次极快 |
| 全局 Store/样式不受懒加载影响 | 全局 enhancer 对懒加载模块不生效 | Nest 的全局增强有范围限制 |
| 组件 lazy 后 useEffect 照常跑 | 懒加载服务钩子不执行 | 时机错过启动那一轮 |
自测 4 题(先自己答,再看答案):
-
Nest 懒加载能像前端那样按需加载路由吗?为什么? → 不能。Express/Fastify 不允许启动后再注册路由;微服务要连接前订阅;GraphQL schema 启动期全量生成。Nest 懒加载的是"provider 子树",路由启动期焊死。
-
load(() => Module)为什么传函数不传类?第二次 load 会怎样? → 惰性求值------真正加载那一刻才取模块引用。第二次命中缓存(官方实测 ~2.4ms → ~0.3ms),返回同一 moduleRef;懒加载模块与 eager 模块共享同一张模块图。 -
rag-server 是 monolith,为什么还落了懒加载?三条边界怎么对的? → 真路径触发(挂 reindex 真实业务,非演示端点)的机制演示------索引重量在 core per-call 函数,省的只是 provider 子树实例化时机,非性能优化。边界:模块无 controller(路由是既有的 reindex 端点)/非 @Global(guard 不覆盖但 handler 内调用已过 guard,ALS 上下文照常可读)/钩子不执行(显式 run() 初始化)。
-
NodeNext 下动态 import 有什么编译期坑? → 动态
import()按 ESM 解析,相对路径必须显式.js(TS2835);tsc 把.js映射回.ts做类型检查,dist 保留原生await import()。静态 import 才允许裸写------动静不对称。
八、本篇收束与下一篇
懒加载拆完,"模块进图的时机"三个档位齐了:静态 imports(启动即 eager)→ forRoot(启动期选配方,18 期)→ LazyModuleLoader(运行期进图,本期) 。rag-server 的答案是诚实双面:判断上 monolith 用不上,行动上真路径落地跑过------判断与验证分离,是这个专栏 demo 线一贯的姿态。顺带 .js 扩展名这个坑,是全专栏最可能直接帮你省一次编译报错的知识。
下一篇是 DI 进阶线的最后一块硬骨头,22 期和 23 期都预告过它:durable provider 。REQUEST scope 每请求一份意味着每请求实例化+GC,多租户场景下同一租户的子树能不能复用?25 期《DurableProviders》讲 ContextIdFactory.apply + 自定义 ContextIdStrategy、rag-server durable-demo 的三探针对照(每租户一份 vs 每请求一份 vs 泄漏反例)。