1. 项目概述:为什么 Angular 的懒加载路由不是“锦上添花”,而是“生死线”
你刚接手一个中型 Angular 企业后台系统,首页加载时间 4.2 秒,FMP(首次内容绘制)指标在 Lighthouse 里红得刺眼。打开 DevTools 的 Network 面板,一眼扫过去:main.js2.8MB、vendor.js3.1MB、polyfills.js1.2MB——三个文件加起来快 7MB,全在首屏就一股脑儿砸给浏览器。用户点开“客户管理”模块前,得先下载完“库存报表”“财务对账”“权限审计”所有模块的代码。这不是优化,这是自残。
这就是没用懒加载路由的真实代价。Angular 的懒加载(Lazy Loading)根本不是什么高级技巧,它是现代单页应用(SPA)的生存底线。它让RouterModule不再把整个应用的路由配置一次性编译进主包,而是按需动态加载模块——用户点“订单中心”,才去拉orders.module.ts及其依赖;点“商品库”,才加载products.module.ts。核心逻辑就一条:把 7MB 的初始包,拆成 300KB 的壳 + 若干个 200–500KB 的功能模块包,由路由触发加载。
关键词Angular、Lazy Loading、Routes、loadChildren、Angular CLI全部指向同一个实操闭环:用 Angular CLI 创建模块 → 在app-routing.module.ts中配置loadChildren→ 编译时自动切分代码块 → 运行时按需 fetch。而网络热词里反复出现的vue2 routes后加载、vue2 routes远程加载,恰恰反向印证了这个问题的普适性——Vue 2 时代靠import()动态导入实现类似效果,但 Angular 把这套机制深度集成进路由系统和 CLI 工具链,原生支持、零配置、强类型、可预测。我去年帮一家做医疗 SaaS 的客户重构路由,把 12 个业务模块全部懒加载后,首屏 JS 体积从 6.9MB 降到 1.1MB,TTFB(首字节时间)不变的前提下,FCP(最大内容绘制)从 3.8s 缩短到 0.9s,用户跳出率直降 37%。这不是理论,是压在生产环境上的真实水位线。
2. 核心设计思路:为什么必须用loadChildren而不是component?背后的编译器真相
2.1 懒加载的本质:不是“延迟渲染”,而是“延迟编译”
很多新手误以为懒加载就是“等用户点进来再渲染组件”,这是致命误解。真正的懒加载发生在AOT(Ahead-of-Time)编译阶段,而非运行时。Angular CLI 在执行ng build --prod时,会扫描所有loadChildren配置项,识别出哪些模块被标记为异步加载,然后启动 Webpack 的代码分割(Code Splitting)机制,将这些模块及其所有依赖(组件、服务、管道、指令)单独打包成独立的 chunk 文件,比如orders-1a2b3c4d.js、reports-5e6f7g8h.js。当用户导航到/orders时,Angular Router 才会动态import()这个 chunk,执行其中的模块定义,最后实例化组件。
而如果用component直接配置路由,比如:
{ path: 'orders', component: OrdersComponent }OrdersComponent及其整个依赖树(OrdersService、OrderTableComponent、CurrencyPipe等)会被强制打入main.js,因为 AOT 编译器需要在构建时就确定所有静态引用关系。此时OrdersComponent是“已知的、确定的、必须存在的”,无法被分割出去。
提示:
loadChildren的值必须是一个返回Promise<NgModuleFactory>的函数,Angular 8+ 后推荐使用() => import('./orders/orders.module').then(m => m.OrdersModule)这种基于import()的动态导入语法,它明确告诉 Webpack:“这个模块可以独立打包”。
2.2loadChildren的两种写法:从 Angular 7 到 Angular 17 的演进陷阱
Angular 7 引入loadChildren字符串写法('./orders/orders.module#OrdersModule'),但这种写法在 Angular 8+ 中已被废弃,且存在严重隐患:
- 类型不安全:字符串
'./orders/orders.module#OrdersModule'完全绕过 TypeScript 编译检查。如果OrdersModule类名拼错,或路径写成./order/orders.module(少了个s),编译器不会报错,只有运行时import()失败才抛Error: Cannot find module,调试成本极高。 - IDE 支持差:VS Code 无法跳转到该模块,无法进行重命名重构(Rename Refactor),一旦模块改名,所有字符串引用全得手动改,极易遗漏。
- Tree-shaking 失效:Webpack 无法静态分析字符串路径,可能保留未使用的模块代码。
所以,必须用函数式写法:
// ✅ 正确:TypeScript 可检查、IDE 可跳转、Webpack 可分析 { path: 'orders', loadChildren: () => import('./orders/orders.module').then(m => m.OrdersModule) } // ❌ 错误:已废弃、无类型检查、调试地狱 { path: 'orders', loadChildren: './orders/orders.module#OrdersModule' }我踩过最深的坑是在一个 Angular 12 项目里混用两种写法:主路由用函数式,但某个子路由忘了改,还留着字符串写法。上线后一切正常,直到某天 CI/CD 流水线升级了 Webpack 版本,那个字符串路由突然 404,监控告警炸了。查了 3 小时才发现是废弃语法被新 Webpack 彻底移除。从此我的团队立下铁规:git grep "loadChildren.*#" -n成为每次 PR 的必检命令。
2.3 为什么不能把懒加载逻辑塞进component?——模块边界与依赖注入的硬约束
有人会想:“既然OrdersComponent是个组件,我直接在app-routing.module.ts里配component: OrdersComponent,然后在OrdersComponent的ngOnInit里手动import('./orders.service')行不行?” 答案是:技术上可行,但工程上自杀。
原因有三:
- 服务注入失效:
OrdersService如果在OrdersModule的providers数组中声明,它只对该模块内的组件有效。若OrdersComponent被直接放在AppModule下(即非懒加载),它就无法获得OrdersModule提供的OrdersService实例,只能注入AppModule的全局服务,导致业务逻辑错乱。 - 样式隔离崩溃:
OrdersComponent的styleUrls或styles依赖OrdersModule中引入的第三方 UI 库(如@angular/material的主题 CSS)。如果模块未加载,这些样式根本不会注入 DOM,组件会显示为裸 HTML。 - 变更检测失序:懒加载模块有自己的
NgModuleRef和独立的ChangeDetectorRef实例。手动import()组件类,但不加载其所属模块,Angular 的变更检测机制无法正确挂载,async管道、OnPush策略等全部失效。
懒加载的最小单元是NgModule,不是Component。这是 Angular 设计哲学的硬性边界——模块是依赖注入、组件注册、样式作用域、变更检测的原子容器。试图绕过它,等于在沙上建塔。
3. 实操全流程:从零创建一个可验证的懒加载路由,含 CLI 命令、配置细节与编译产物分析
3.1 第一步:用 Angular CLI 创建懒加载模块(带路由)
别手写orders.module.ts,CLI 会帮你生成标准结构,包括路由模块、声明组件、导出模块——这一步省掉 80% 的配置错误:
# 在项目根目录执行 ng generate module orders --route orders --module app.module这条命令做了五件事:
- 创建
src/app/orders/orders.module.ts:空的NgModule,declarations为空; - 创建
src/app/orders/orders-routing.module.ts:包含{ path: '', component: OrdersComponent }的子路由; - 创建
src/app/orders/orders.component.ts及配套 HTML/CSS/Spec 文件; - 修改
src/app/app-routing.module.ts,自动添加一条路由:{ path: 'orders', loadChildren: () => import('./orders/orders.module').then(m => m.OrdersModule) }; - 修改
src/app/app.module.ts,不将OrdersComponent加入declarations,也不导入OrdersModule—— 这是关键!懒加载模块绝不能在根模块中声明。
注意:
--route orders参数指定了子路由路径为orders,所以最终访问地址是/orders,而非/orders/orders。CLI 会自动在orders-routing.module.ts中设置path: '',确保子路由相对路径正确。
3.2 第二步:填充业务逻辑并验证路由跳转
现在OrdersComponent是空的,我们加点真实内容来验证:
// src/app/orders/orders.component.ts import { Component, OnInit } from '@angular/core'; @Component({ selector: 'app-orders', template: ` <h2>订单中心 (v{{ version }})</h2> <p>当前时间:{{ now | date:'yyyy-MM-dd HH:mm:ss' }}</p> <button (click)="refresh()">刷新时间</button> `, styles: [`h2 { color: #1976d2; }`] }) export class OrdersComponent implements OnInit { version = '1.0.0'; now = new Date(); ngOnInit(): void { console.log('[OrdersModule] 已加载'); } refresh(): void { this.now = new Date(); } }启动开发服务器:ng serve,打开浏览器访问http://localhost:4200/orders。此时观察 Network 面板:
- 首次访问
/orders时,会看到一个新请求:orders-1a2b3c4d.js(hash 值因项目而异),大小约 120KB(含OrdersComponent、OrdersModule、CommonModule等); - 控制台输出
[OrdersModule] 已加载; - 再次点击浏览器后退回到
/,然后重新点/orders,Network 面板不再发起orders-*.js请求(已缓存); - 如果你清空浏览器缓存,再访问
/首页,Network 面板里绝对看不到orders-*.js—— 它只在/orders路径下才加载。
这就是懒加载生效的铁证:模块代码与路由路径严格绑定,无请求、无加载、无内存占用。
3.3 第三步:构建生产包并分析代码分割结果
执行生产构建:ng build --configuration production。构建完成后,进入dist/your-app-name/目录,查看生成的 JS 文件:
ls -lh dist/your-app-name/*.js # 输出示例: # 124K main.1a2b3c4d.js # 主应用包(不含 OrdersModule) # 3.1M vendor.5e6f7g8h.js # 第三方库(Angular Core、RxJS 等) # 120K orders-9i0j1k2l.js # 懒加载模块 1 # 210K reports-m3n4o5p6.js # 懒加载模块 2 # 85K runtime.7q8r9s0t.js # Webpack 运行时用source-map-explorer工具可视化分析(需先安装:npm install -g source-map-explorer):
source-map-explorer dist/your-app-name/main.1a2b3c4d.js你会看到一张清晰的依赖图:main.js里只有AppModule、CoreModule、SharedModule的代码,OrdersComponent、OrdersService等节点完全消失——它们被归入orders-*.js的独立 chunk 中。
实操心得:我习惯在
angular.json的build.options.statsJson设为true,构建后生成stats.json,再用 webpack-bundle-analyzer 可视化分析。比source-map-explorer更直观,能直接看到每个 chunk 的组成和大小占比。
3.4 第四步:处理常见需求——预加载、错误处理与加载状态反馈
懒加载不是“放任不管”,生产环境必须处理三大现实问题:
预加载(Preloading):平衡速度与体验
默认情况下,懒加载模块只在用户点击时才加载,可能造成点击后白屏等待。Angular 提供PreloadAllModules策略,在空闲时(NavigationEnd事件后)自动预加载所有懒加载模块:
// app-routing.module.ts import { PreloadAllModules, RouterModule, Routes } from '@angular/router'; const routes: Routes = [ { path: '', redirectTo: '/dashboard', pathMatch: 'full' }, { path: 'dashboard', loadChildren: () => import('./dashboard/dashboard.module').then(m => m.DashboardModule) }, { path: 'orders', loadChildren: () => import('./orders/orders.module').then(m => m.OrdersModule) }, // ... 其他路由 ]; @NgModule({ imports: [ RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules // ⚡ 开启预加载 }) ], exports: [RouterModule] }) export class AppRoutingModule { }预加载不是“全量加载”,而是利用浏览器空闲时间(用户阅读首页内容时)后台静默下载orders-*.js、reports-*.js。实测数据:在 4G 网络下,预加载可将/orders首次点击的加载延迟从 800ms 降至 120ms(纯内存加载)。但要注意:预加载会增加首页的总下载量,如果用户只用 20% 的功能,预加载反而浪费带宽。我的建议是:对高频路径(如/dashboard,/profile)用PreloadAllModules,对低频路径(如/admin/settings)保持按需加载。
加载状态与错误处理:给用户确定性反馈
用户点击/orders后,如果网络慢,必须显示加载中状态;如果模块加载失败(404、500、网络中断),必须友好提示。Angular Router 提供Router.events监听RouteConfigLoadStart/RouteConfigLoadEnd事件:
// app.component.ts import { Component, OnInit, OnDestroy } from '@angular/core'; import { Router, Event, RouteConfigLoadStart, RouteConfigLoadEnd, NavigationError } from '@angular/router'; import { Subscription } from 'rxjs'; @Component({ selector: 'app-root', template: ` <app-header></app-header> <div *ngIf="loading" class="loading-overlay"> <span>正在加载模块...</span> </div> <router-outlet></router-outlet> `, styles: [` .loading-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255,255,255,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000; } `] }) export class AppComponent implements OnInit, OnDestroy { loading = false; private routerSub: Subscription; constructor(private router: Router) {} ngOnInit(): void { this.routerSub = this.router.events.subscribe((event: Event) => { if (event instanceof RouteConfigLoadStart) { this.loading = true; } else if (event instanceof RouteConfigLoadEnd || event instanceof NavigationError) { this.loading = false; } }); } ngOnDestroy(): void { if (this.routerSub) this.routerSub.unsubscribe(); } }这段代码会在任何懒加载模块开始加载时显示半透明遮罩层,加载完成或失败后自动隐藏。NavigationError事件还能捕获具体错误:
else if (event instanceof NavigationError) { console.error('路由加载失败:', event.error); alert(`模块加载失败,请检查网络或稍后重试。错误码:${event.error.status}`); }注意:
RouteConfigLoadStart/End仅针对loadChildren触发,component路由不会触发。这是判断懒加载是否生效的另一个监控点。
4. 深度避坑指南:那些官方文档不会写的 7 个致命陷阱与实战对策
4.1 陷阱一:forRoot()与forChild()混用导致的路由冲突
新手常犯错误:在懒加载模块的OrdersRoutingModule中,错误地调用RouterModule.forRoot(routes):
// ❌ 错误:OrdersRoutingModule.ts import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { OrdersComponent } from './orders.component'; const routes: Routes = [{ path: '', component: OrdersComponent }]; @NgModule({ imports: [RouterModule.forRoot(routes)], // ⚠️ 错!这里应该是 forChild exports: [RouterModule] }) export class OrdersRoutingModule { }forRoot()会注册全局的Router服务实例,并重置整个路由配置。当OrdersModule被懒加载时,它会覆盖AppRoutingModule中的forRoot()配置,导致/dashboard、/profile等所有路由失效,只剩/orders可用。
正确写法:
// ✅ 正确:OrdersRoutingModule.ts import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { OrdersComponent } from './orders.component'; const routes: Routes = [{ path: '', component: OrdersComponent }]; @NgModule({ imports: [RouterModule.forChild(routes)], // ✅ 必须用 forChild exports: [RouterModule] }) export class OrdersRoutingModule { }forChild()只将子路由追加到父路由配置中,不创建新Router实例。这是 Angular 路由模块化的基石规则。
4.2 陷阱二:懒加载模块中重复提供服务,引发状态污染
假设OrdersService是一个管理订单列表的可变服务:
// orders.service.ts @Injectable({ providedIn: 'root' // ⚠️ 危险!全局单例 }) export class OrdersService { private list: Order[] = []; getList() { return this.list; } add(order: Order) { this.list.push(order); } }如果OrdersService的providedIn: 'root',它就是一个全局单例。当用户从/orders跳到/reports(另一个懒加载模块),再返回/orders,OrdersService.list里的数据还在——这看似合理,但如果ReportsModule也用了同名服务,或不同模块需要隔离状态,就会出问题。
对策:将服务提供范围限定在模块内:
// orders.module.ts import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { OrdersRoutingModule } from './orders-routing.module'; import { OrdersComponent } from './orders.component'; import { OrdersService } from './orders.service'; @NgModule({ declarations: [OrdersComponent], imports: [CommonModule, OrdersRoutingModule], providers: [OrdersService] // ✅ 在模块 providers 中提供,非 root }) export class OrdersModule { }此时OrdersService的生命周期与OrdersModule绑定:模块加载时创建实例,模块卸载时销毁实例(Angular 9+ 默认启用onDestroy生命周期钩子)。用户离开/orders后,服务实例被 GC 回收,状态彻底清空。
4.3 陷阱三:CanLoad守卫的执行时机与权限校验盲区
CanLoad守卫用于在模块加载前拦截,常用于权限控制:
// auth.guard.ts @Injectable({ providedIn: 'root' }) export class AuthGuard implements CanLoad { constructor(private authService: AuthService, private router: Router) {} canLoad(route: Route): boolean { if (this.authService.isLoggedIn()) { return true; } else { this.router.navigate(['/login']); return false; } } } // app-routing.module.ts { path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule), canLoad: [AuthGuard] // ✅ 配置 canLoad }但canLoad有一个致命盲区:它只在模块首次加载时执行一次。用户登录后访问/admin,canLoad返回true,模块加载成功;之后用户在/admin页面内操作,触发登出,此时AuthService.isLoggedIn()变为false,但用户仍在/admin页面——canLoad不会再次触发,因为模块已加载,路由只是在模块内部切换。
对策:canLoad只做“准入检查”,真正的权限校验必须结合CanActivate守卫:
// admin-routing.module.ts const routes: Routes = [ { path: '', component: AdminDashboardComponent, canActivate: [AuthGuard] // ✅ 每次进入组件都校验 }, { path: 'users', component: UserListComponent, canActivate: [AuthGuard] } ];CanLoad防止未授权用户下载敏感模块代码,CanActivate防止已加载模块内的非法访问,二者缺一不可。
4.4 陷阱四:import()路径错误导致的构建成功但运行时 404
这是最隐蔽的坑。假设你的模块路径是src/app/features/orders/orders.module.ts,但你在路由中写成:
// ❌ 错误:路径多了一层 features loadChildren: () => import('./features/orders/orders.module').then(m => m.OrdersModule)Angular CLI 构建时不会报错,因为import()是运行时解析。但ng build生成的orders-*.js文件名,是基于import()字符串路径计算的。如果路径写错,Webpack 会生成一个不存在的 chunk,运行时import()失败,报Error: Cannot find module './features/orders/orders.module'。
排查方法:
- 查看
dist/your-app-name/目录下实际生成的orders-*.js文件名; - 对比
import()字符串路径,确认是否完全匹配(注意./开头表示相对路径,/开头表示绝对路径); - 使用 VS Code 的“Go to Definition”(Ctrl+Click)功能,点击
import('./xxx'),看能否正确跳转到模块文件。
终极保险:在tsconfig.json的compilerOptions.baseUrl设为"src",然后统一用绝对路径:
// tsconfig.json { "compilerOptions": { "baseUrl": "src" } }// ✅ 绝对路径,不易出错 loadChildren: () => import('app/features/orders/orders.module').then(m => m.OrdersModule)4.5 陷阱五:resolve数据预加载与懒加载的时序冲突
resolve守卫用于在组件激活前获取数据:
// orders.resolver.ts @Injectable({ providedIn: 'root' }) export class OrdersResolver implements Resolve<Order[]> { constructor(private service: OrdersService) {} resolve(route: ActivatedRouteSnapshot): Observable<Order[]> { return this.service.getOrders(); // HTTP 请求 } } // orders-routing.module.ts const routes: Routes = [ { path: '', component: OrdersComponent, resolve: { orders: OrdersResolver } // ✅ 配置 resolve } ];问题来了:resolve的执行依赖OrdersService,而OrdersService在OrdersModule中提供。但resolve守卫的实例化,发生在OrdersModule加载之前!Angular Router 需要在模块加载前就准备好OrdersResolver实例,以便在导航时调用resolve()方法。
解决方案:将OrdersResolver提升到AppModule中提供,并通过Injector获取懒加载模块的服务:
// app.module.ts import { OrdersResolver } from './orders/orders.resolver'; @NgModule({ // ... providers: [OrdersResolver] // ✅ 在根模块提供 resolver }) export class AppModule { } // orders.resolver.ts @Injectable({ providedIn: 'root' }) export class OrdersResolver implements Resolve<Order[]> { constructor( private injector: Injector, // ⚡ 注入 injector private http: HttpClient ) {} resolve(route: ActivatedRouteSnapshot): Observable<Order[]> { // 在 resolve 时,动态获取 OrdersService(模块已加载) const ordersService = this.injector.get(OrdersService); return ordersService.getOrders(); } }这样,OrdersResolver是全局单例,但getOrders()调用时,OrdersService已被OrdersModule加载,injector.get()能正确返回实例。
4.6 陷阱六:ng update升级后loadChildren报Cannot find module的版本兼容问题
Angular 14 升级到 Angular 15 时,部分项目出现loadChildren报错:
Error: Cannot find module './orders/orders.module'根本原因是 Angular 15 的@angular-devkit/build-angular默认启用了esbuild作为构建器,而esbuild对import()动态路径的解析规则与 Webpack 不同。esbuild要求import()的路径必须是静态字符串字面量,不能包含变量或表达式。
如果你的代码中有:
// ❌ Angular 15+ esbuild 下报错 const moduleName = './orders/orders.module'; loadChildren: () => import(moduleName).then(m => m.OrdersModule)对策:严格使用静态字符串:
// ✅ 正确:永远用静态字符串 loadChildren: () => import('./orders/orders.module').then(m => m.OrdersModule)或者,在angular.json中强制回退到 Webpack 构建器(不推荐,放弃 esbuild 的速度优势):
// angular.json "architect": { "build": { "builder": "@angular-devkit/build-angular:browser", "options": { "builder": "@angular-devkit/build-angular:browser-esbuild", // 改为 browser // ... } } }4.7 陷阱七:微前端场景下,子应用懒加载的跨域与资源路径错乱
当 Angular 应用作为微前端子应用(如 qiankun、single-spa)嵌入主应用时,懒加载模块的orders-*.js请求路径可能出错。例如,主应用地址是https://main.com,子应用挂载在https://main.com/subapp,但orders-*.js却向https://main.com/orders-*.js发起请求(404),而非https://main.com/subapp/orders-*.js。
这是因为 Angular CLI 默认将baseHref设为/,Webpack 的publicPath也是/。解决方案是构建时指定baseHref:
ng build --base-href /subapp/ --deploy-url /subapp/--base-href /subapp/:设置<base href="/subapp/">,影响所有相对路径;--deploy-url /subapp/:设置 Webpack 的publicPath,让orders-*.js的请求 URL 变为/subapp/orders-*.js。
在微前端场景下,这是必须的构建参数,否则懒加载必然失败。
5. 进阶实战:从单模块懒加载到企业级路由架构设计
5.1 场景一:嵌套路由与多级懒加载——电商后台的典型结构
一个真实的电商后台路由往往有多层嵌套:
/ # 主应用(Dashboard) ├── /products # 商品管理(懒加载) │ ├── /list # 商品列表(子路由) │ ├── /create # 新建商品(子路由) │ └── /edit/:id # 编辑商品(子路由) ├── /orders # 订单管理(懒加载) │ ├── /list # 订单列表(子路由) │ └── /detail/:id # 订单详情(子路由) └── /customers # 客户管理(懒加载)实现方式是两级懒加载:AppRoutingModule懒加载ProductsModule,ProductsModule再懒加载其子模块(如ProductListModule):
// app-routing.module.ts { path: 'products', loadChildren: () => import('./products/products.module').then(m => m.ProductsModule) } // products/products.module.ts import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ProductsRoutingModule } from './products-routing.module'; import { ProductsComponent } from './products.component'; @NgModule({ declarations: [ProductsComponent], imports: [ CommonModule, ProductsRoutingModule // ✅ 子路由模块 ] }) export class ProductsModule { } // products/products-routing.module.ts const routes: Routes = [ { path: '', component: ProductsComponent, children: [ { path: 'list', loadChildren: () => import('./product-list/product-list.module').then(m => m.ProductListModule) }, { path: 'create', loadChildren: () => import('./product-create/product-create.module').then(m => m.ProductCreateModule) } ] } ];这种结构让product-list.module.ts可以独立打包(product-list-*.js),进一步细化代码分割粒度。实测:将商品列表页从ProductsModule中拆出后,ProductsModule包体积从 420KB 降至 180KB,product-list-*.js为 210KB,用户首次访问/products时更快,后续点/products/list也只需加载 210KB。
5.2 场景二:按角色动态加载模块——SaaS 多租户权限体系
SaaS 平台中,不同租户(客户)看到的功能模块不同。A 客户购买了“报表模块”,B 客户没有购买,就不该加载reports.module.ts。
传统做法是所有模块都懒加载,再用*ngIf控制菜单显示。但这样reports-*.js仍会被预加载或用户偶然触发加载,浪费资源。
动态路由方案:在AppRoutingModule初始化时,根据用户权限 API 返回的数据,动态构建routes数组:
// app-routing.module.ts import { NgModule, APP_INITIALIZER } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { AuthService } from './auth.service'; export function initApp(authService: AuthService) { return () => authService.loadUserPermissions(); // 返回 Promise } // 动态路由数组(初始为空) let dynamicRoutes: Routes = []; @NgModule({ imports: [ RouterModule.forRoot(dynamicRoutes, { // ... 配置 }) ], exports: [RouterModule], providers: [ { provide: APP_INITIALIZER, useFactory: initApp, deps: [AuthService], multi: true } ] }) export class AppRoutingModule { constructor( private router: Router, private authService: AuthService ) { // 在构造函数中,权限数据已加载完成 const permissions = this.authService.getPermissions(); const routes: Routes = [ { path: '', redirectTo: '/dashboard', pathMatch: 'full' } ]; if (permissions.includes('products')) { routes.push({ path: 'products', loadChildren: () => import('./products/products.module').then(m => m.ProductsModule) }); } if (permissions.includes('reports')) { routes.push({ path: 'reports', loadChildren: () => import('./reports/reports.module').then(m => m.ReportsModule) }); } // ✅ 动态重置路由配置 this.router.resetConfig(routes); } }APP_INITIALIZER确保权限加载完成后再初始化路由,router.resetConfig()动态更新路由表。这样,B 客户的浏览器里永远不会出现reports-*.js的请求,真正实现“按需交付”。
5.3 场景三:离线优先策略——Service Worker 与懒加载模块的协同
PWA(渐进式 Web 应用)要求离线可用。Angular 的@angular/pwa提供ng add @angular/pwa命令,但它默认只缓存index.html、main.js、vendor.js等主包,不缓存懒加载模块的orders-*.js。
要让/orders在离线时也能访问,必须在ngsw-config.json中显式声明:
{ "navigationUrls": [ { "positive": true, "regex": "^\\/.*$" } ], "assetGroups": [ { "name": "app", "installMode": "prefetch", "resources": { "files": [ "/favicon.ico", "/index.html", "/*.css", "/*.js" ] } }, { "name": "lazy-modules", "installMode": "lazy", "updateMode": "prefetch", "resources": { "files": [ "/orders-*.js", "/reports-*.js", "/customers-*.js" ] } } ] }installMode: "lazy":这些文件不在安装 Service Worker 时下载,而是按需缓存;updateMode: "prefetch"