24 · NestJs LazyLoadingModules 懒加载模块:你在前端天天 `import()` 懒路由,但 Nest 偏偏不能懒加载路由

项目开源地址(本专栏实证代码的教程仓) 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 这没问题------启动一次、常驻运行,启动成本摊薄到无限长的生命周期里。痛点只在两类场景:

  1. Serverless / FaaS(冷启动敏感):每次函数调用可能是新进程,启动耗时直接算进请求延迟------全量加载 = 白加载一堆这次用不到的模块;
  2. 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 题(先自己答,再看答案):

  1. Nest 懒加载能像前端那样按需加载路由吗?为什么? → 不能。Express/Fastify 不允许启动后再注册路由;微服务要连接前订阅;GraphQL schema 启动期全量生成。Nest 懒加载的是"provider 子树",路由启动期焊死。

  2. load(() => Module) 为什么传函数不传类?第二次 load 会怎样? → 惰性求值------真正加载那一刻才取模块引用。第二次命中缓存(官方实测 ~2.4ms → ~0.3ms),返回同一 moduleRef;懒加载模块与 eager 模块共享同一张模块图。

  3. rag-server 是 monolith,为什么还落了懒加载?三条边界怎么对的? → 真路径触发(挂 reindex 真实业务,非演示端点)的机制演示------索引重量在 core per-call 函数,省的只是 provider 子树实例化时机,非性能优化。边界:模块无 controller(路由是既有的 reindex 端点)/非 @Global(guard 不覆盖但 handler 内调用已过 guard,ALS 上下文照常可读)/钩子不执行(显式 run() 初始化)。

  4. 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 泄漏反例)。

相关推荐
JavaEdge.1 小时前
Dify入门
ai编程
Mav1 小时前
[个人学习记录]从零构建高性能 LLM 推理网关:为什么普通轮询会击穿显存?Nginx 平滑加权轮询(SWRR)在 Go 中的 57ns 零分配实战
后端
大哥43091 小时前
改了 ≠ 生效了:为什么"改完了"不是完成判据
后端
imDwAaY1 小时前
Spring中过滤器和拦截器的区别是什么?
spring boot·后端·spring
传人1 小时前
页面中心圆圈放大效果如何写
前端·css
liangshanbo12151 小时前
前端面试题:微信小程序怎么优化性能?
前端·微信小程序·notepad++
anew___1 小时前
《从零手写操作系统 (29):管道与重定向进阶——命名管道、Here Document与Shell语法扩展》
java·开发语言·前端·javascript·网络
M1A11 小时前
VS Code 拉取 Gitee 代码全攻略:从零到一,新手也能轻松搞定!
后端