ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Angular首屏优化:路由懒加载loadChildren实战全记录

Angular首屏优化:路由懒加载loadChildren实战全记录 做了几年 Angular 项目每次提到首屏性能优化我脑子里第一个蹦出来的方案就是路由懒加载。尤其是后台管理系统这种模块多、路由多、业务代码动辄几兆的项目如果不做懒加载首屏加载时间能拖到让人怀疑人生。这篇博文以一个实际项目的页面跳转改造过程为例完整拆解如何用 loadChildren 做路由懒加载顺便把踩过的坑和验证手段一并交代清楚希望给正在折腾 Angular 首屏优化的同学一些参考。Angular 页面跳转 07路由懒加载实战与性能优化记录1. 项目现状与优化思路拆解1.1 首屏性能瓶颈到底出在哪我接手这个项目的时候它的构建产物大概是这样的单文件主 bundle 超过 3MB加上第三方依赖打成的一个 vendor chunk整体加起来接近 5MB没有做任何代码分割。首屏加载需要把整个应用全部拉下来再初始化所有模块用户从输入地址到看到登录页一般要等 5 到 8 秒网络差一点甚至奔着 10 秒去了。这个时间消耗主要有三个地方。第一是网络传输5MB 资源就算在本地局域网也有延迟放到线上 CDN 之后再叠加 HTTP 请求的排队时间体感非常明显。第二是 JavaScript 解析执行浏览器下载完 JS 不是直接能用还要经过解码、语法分析、字节码编译、执行这个过程5MB 的 JS 大致需要几百毫秒到一两秒的 CPU 时间低端设备上会更严重。第三是 Angular 自身的模块初始化只要被引入到 NgModule 里的 provider、组件、指令在应用启动时都要完成注册模块越多启动成本越高。项目的业务结构是典型的多页面后台登录、工作台、用户管理、订单管理、商品管理、报表中心、系统设置每个业务域都有独立的路由和页面组件。这种情况下把所有页面打包进一个 bundle 就非常浪费因为用户登录之后大概率只用其中一两个模块其余代码其实根本不需要在首屏加载。1.2 路由懒加载的概念与选择理由路由懒加载是基于 Angular 路由机制做代码分割的核心手段。核心做法是不要在主模块里直接 import 子模块的 NgModule而是通过路由配置里的 loadChildren 方法告诉 Angular“这个路由对应的模块等我真正访问它的时候再加载”。这样做的好处是构建工具webpack 或 esbuild会把每个懒加载路由对应的模块单独切割成一个 chunk 文件首屏只需要加载主 chunk 和依赖 chunk其他业务模块的代码按需获取。用户先看到登录页登录后再跳转到具体业务这时才加载对应模块的 chunk整体首屏传输体积可以从 5MB 直接降到几百 KB。Angular 在路由懒加载这块有相当成熟的支持loadChildren 从 Angular 2 时代就有了一直用到现在 Angular 17、18、19。新版本还支持loadComponent直接懒加载独立组件连模块都不用建。不过针对既有项目最稳妥、改动最小、收益最明确的方案仍然是 loadChildren 子 NgModule 的经典模式。1.3 模块划分的核心边界做懒加载之前先要把业务模块的边界划分清楚。这个活干得不好后续会出现两种尴尬情况一种是模块拆得太碎每个模块就两三个页面chunk 文件数量爆炸HTTP 请求太多反而拖慢加载另一种是模块之间互引公共代码导致 chunk 的复用率低公共依赖被重复打进多个 chunk体积不降反升。我建议按照业务域来划分。登录用不到业务页面工作台是登录后的默认落地页用户管理、订单管理这些彼此独立各自成模块。每个模块内部再按需引入通用的组件和指令。一个比较直观的判断标准是如果两个页面之间跳转时需要共享同一个服务实例或者它们的业务逻辑紧密耦合那就放在同一个模块里如果只是偶尔相互跳转尽量拆开。2. 核心实现loadChildren 的完整改造过程2.1 从一个嵌套路由改造实例说起项目原来的路由写法是这样的主路由把所有子页面全部 import 进来在 AppModule 里统一注册import { NgModule } from angular/core; import { RouterModule, Routes } from angular/router; import { LoginComponent } from ./login/login.component; import { DashboardComponent } from ./dashboard/dashboard.component; import { UserListComponent } from ./user/user-list.component; import { OrderListComponent } from ./order/order-list.component; const routes: Routes [ { path: , redirectTo: /login, pathMatch: full }, { path: login, component: LoginComponent }, { path: dashboard, component: DashboardComponent }, { path: user, component: UserListComponent }, { path: order, component: OrderListComponent }, ];这个写法很直观但问题很明显所有 Component 都会被编译进主 bundle因为它们在顶层就被静态 import 了。改为懒加载之后路由配置要彻底重写核心变化是不再直接 import 子页面组件而是用 loadChildren 指向一个子模块的路由文件import { NgModule } from angular/core; import { RouterModule, Routes } from angular/router; const routes: Routes [ { path: , redirectTo: /login, pathMatch: full }, { path: login, loadChildren: () import(./login/login.module).then(m m.LoginModule) }, { path: dashboard, loadChildren: () import(./dashboard/dashboard.module).then(m m.DashboardModule) }, { path: user, loadChildren: () import(./user/user.module).then(m m.UserModule) }, { path: order, loadChildren: () import(./order/order.module).then(m m.OrderModule) }, ]; NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { }每个子模块内部再定义自己的路由和组件。以用户管理模块为例user.module.ts 里是这样组织的import { NgModule } from angular/core; import { CommonModule } from angular/common; import { RouterModule, Routes } from angular/router; import { UserListComponent } from ./user-list.component; import { UserDetailComponent } from ./user-detail.component; const routes: Routes [ { path: , component: UserListComponent }, { path: :id, component: UserDetailComponent }, ]; NgModule({ declarations: [UserListComponent, UserDetailComponent], imports: [ CommonModule, RouterModule.forChild(routes) ] }) export class UserModule { }改造之后主路由文件瘦身非常明显所有业务组件都不再进主 bundle每个业务模块单独生成 chunk 文件访问对应路由时才发起加载。2.2 一个完整的子路由懒加载模块配置再拿订单模块举例它包含订单列表、订单详情、订单导出三个页面。这个模块在路由配置里是这样懒加载的{ path: order, loadChildren: () import(./order/order.module).then(m m.OrderModule) }而 order.module.ts 内部使用了 forChild 注册子路由并加了一个路由守卫来拦截未登录的访问import { NgModule } from angular/core; import { CommonModule } from angular/common; import { RouterModule, Routes } from angular/router; import { OrderListComponent } from ./pages/order-list/order-list.component; import { OrderDetailComponent } from ./pages/order-detail/order-detail.component; import { OrderExportComponent } from ./pages/order-export/order-export.component; import { AuthGuard } from ../core/guards/auth.guard; const routes: Routes [ { path: , component: OrderListComponent, canActivate: [AuthGuard] }, { path: :id, component: OrderDetailComponent, canActivate: [AuthGuard] }, { path: export, component: OrderExportComponent, canActivate: [AuthGuard] }, ]; NgModule({ imports: [ CommonModule, RouterModule.forChild(routes), ], declarations: [ OrderListComponent, OrderDetailComponent, OrderExportComponent, ], }) export class OrderModule { }这里有个细节值得注意子模块里不要再导入 BrowserModule用 CommonModule 就够了。如果你在懒加载模块里再引一次 BrowserModuleAngular 会在运行时抛异常。因为 BrowserModule 只能在 AppModule根模块里出现一次它注册的是应用级启动服务懒加载模块里只需要 CommonModule 提供的结构型指令和管道。2.3 懒加载之后的页面跳转方式改造之后页面跳转的代码可以完全不用变。router.navigate、routerLink和router.navigateByUrl仍然按路径跳转。真正发生变化的是跳转那一刻如果目标路由对应的模块还没加载过Angular 路由会先异步加载对应的 chunk加载完成之后再渲染目标组件整个过程对使用方是无感的。this.router.navigate([/order, order.id]);不过实际体验中首次跳转到从未访问过的懒加载模块会有一个短暂的等待时间。这个时间取决于 chunk 大小和网络环境通常在几十毫秒到几百毫秒之间。如果想让这个等待时间不那么明显可以配合 Angular 的预加载策略具体后面详细说明。需要注意另一种情况如果代码里使用了RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })那么懒加载模块会在应用初始化后的空闲时间被预先拉取首次访问业务的等待时间会明显缩短但代价是首屏加载后会有额外的网络请求需要在性能和体验之间做权衡。3. 进阶构建体积分析、预加载与模块复用3.1 用 source-map-explorer 直观查看体积变化懒加载改造是否生效不能只看感觉要用工具验证。我最常用的工具是 source-map-explorer它能依据构建产物里的 sourcemap 分析出每个 chunk 中包含哪些业务代码直观地用区块图展示体积构成。使用方式很简单。在构建配置里开启 sourcemap然后执行npx source-map-explorer dist/**/*.js或者如果你用的是 Angular CLI可以写成 npm script{ scripts: { analyze: ng build --source-map npx source-map-explorer dist/**/*.js } }改造前看这个图所有业务代码都堆在主 bundle 一个大方块里。改造后主 bundle 只保留框架核心代码、公共组件和登录模块其他业务模块像一个个独立的气泡分散在周围每个气泡的大小就是对应模块的 chunk 体积。我用这个工具发现过不少问题。比如某个懒加载模块里不小心把所有业务组件都 import 了一遍结果这个模块的 chunk 变得特别大。还有一个情况是公共模块被多个懒加载模块引用但构建工具没有自动提取公共依赖导致每个 chunk 里都打了一份重复代码。这些问题不通过体积分析很难提前察觉。3.2 预加载策略的自定义实现Angular 内置了两种预加载策略PreloadAllModules预加载所有懒加载模块和NoPreloading不预加载。实际项目里我推荐做一个自定义的预加载策略只预加载用户最可能访问的模块既不浪费首屏流量又能提升跳转体验。思路是给路由配置里的 data 字段加一个preload: true标记然后实现 Angular 的PreloadingStrategy接口import { Injectable } from angular/core; import { PreloadingStrategy, Route } from angular/router; import { Observable, of } from rxjs; Injectable({ providedIn: root }) export class SelectivePreloadingStrategy implements PreloadingStrategy { preload(route: Route, load: () Observableany): Observableany { if (route.data route.data[preload]) { return load(); } return of(null); } }然后在路由配置里这样标记需要预加载的模块{ path: dashboard, loadChildren: () import(./dashboard/dashboard.module).then(m m.DashboardModule), data: { preload: true } }注册时把策略传进去RouterModule.forRoot(routes, { preloadingStrategy: SelectivePreloadingStrategy })这样登录模块和 dashboard 模块会在应用空闲时自动加载而用户管理、订单管理这些需要用户点击才访问的模块仍然保持按需加载。实测下来页面切换的等待时间减少了一半左右首屏传输体积并没有明显增加。有个情况需要注意预加载会改变网络请求的时机如果某些懒加载模块在初始化时会发大量 HTTP 请求预加载可能导致空闲时请求量激增。这种情况下建议关掉这些模块的预加载标记只保留轻量模块。3.3 公共模块的拆分与重复加载问题懒加载改造完如果不管公共模块大概率会遇到 chunk 重复加载的问题。比如多个懒加载模块都使用了同一个自定义组件库或工具函数构建时这部分的代码如果被打进了各自的 chunk会导致重复下载。Angular CLI 自带的构建优化能自动处理一部分公共依赖。在 angular.json 的 optimization 配置里你可以开启commonChunk相关选项让构建工具把公共模块提取到单独的 chunk 中。不过这个配置有时候并不完美特别是当你使用了一些动态 import 路径时webpack 的代码分割策略可能会把公共依赖重复打包。我遇到过印象最深的情况是两个懒加载模块都引用了同一个第三方图表库图表库被打进了各自模块的 chunk导致两个 chunk 都有近 600KB 的重复代码。解决方法是把这个图表库抽到一个共享模块里然后在 AppModule 中全局引入或者用 webpack 的splitChunks配置把它单独拆出来。Angular 项目用的是 webpack可以在 angular.json 里通过customWebpackConfig合并自定义 webpack 配置但更简单的做法是把真正需要共享的第三方库在 AppModule 或 SharedModule 里引入确保它们在主 chunk 中只存在一份。公用的业务工具函数也尽量放在 core 或 shared 目录不要散落在各个业务模块里。4. 常见问题与排查技巧实录4.1 路由跳转后白屏控制台报找不到模块路径懒加载改造后最典型的坑是部署路径问题。本地开发一切正常一旦部署到服务器子目录或者使用了非根路径访问应用懒加载出来的 chunk 路径就会 404路由跳转之后白屏控制台能看到 Failed to load module script 之类的错误。这个问题的根源是构建时 Angular 会把懒加载 chunk 的默认加载路径写成基于根路径的绝对路径如果应用托管在https://example.com/admin/这样的子路径下浏览器解析 chunk 路径时就会找错。解决办法是设置 Angular 的 baseHref在构建时指定应用的实际部署路径ng build --base-href/admin/或者在 index.html 中手动指定base href/admin/注意还要把 router 的 useHash 打开减少服务器端路由回退配置的麻烦RouterModule.forRoot(routes, { useHash: true })hash 模式虽然看起来没有 history 模式优雅但在后端没有配合配置 rewrite 规则的情况下是最稳妥的方案。4.2 懒加载模块之间出现循环依赖模块拆分多了之后很容易在不知不觉中产生循环依赖。比如 A 模块 import 了 SharedComponent而这个组件内部又通过路由跳转到 A 模块的页面如果 SharedComponent 所在的 SharedModule 被 A 模块 import同时 SharedModule 里又想使用 A 模块的路由循环依赖就产生了。循环依赖在构建时不一定报错很多时候应用能正常编译但运行时会报Cannot access A before initialization之类的错误。排查方法是在代码中搜索 import 路径看模块之间的依赖方向是否形成了闭环更直接的做法是用 webpack 的 circular-dependency-plugin 在构建期检测。解决循环依赖我的经验是SharedModule 里只放无状态展示组件不依赖任何业务模块如果某个共享组件需要跳转用指令或者服务注入的方式在业务模块中封装一层不要让共享组件直接引用业务流程。另外路由配置统一收敛到各个模块内部不要在共享模块中定义全局路由。4.3 懒加载后首屏指标不降反升有些项目改造懒加载之后首屏加载时间反而上升了。这种问题大概率不是因为懒加载本身而是懒加载引发了更多碎小的 HTTP 请求加上原来的依赖没有合理拆分导致每个模块的 chunk 大小都不小。这种情况下我建议分三步排查。第一步用浏览器开发者工具的网络面板查看首屏发出的请求数量和总传输体积确认问题方向。第二步用 source-map-explorer 或者 webpack-bundle-analyzer 分析 chunk 构成看每个 chunk 里有没有重复的第三方库。第三步针对重复的公共依赖调整splitChunks或者把公共依赖移动到主模块中加载。核心原则是懒加载的目标是减少首屏不必要代码不是让请求数量激增。合理的 chunk 数量一般控制在 10 到 30 个之间如果首屏加载完发现几十个 chunk 同时请求就要检查是不是模块拆分得太碎了。4.4 懒加载模块中的组件无法使用公共管道还有一个比较隐蔽的问题项目里定义了一些全局管道放在 SharedModule 里导出但某个懒加载模块没有 import SharedModule直接使用了管道导致模板编译报错。Angular 的模块体系里管道、指令、组件都需要在声明它们的模块中导出然后被其他模块 import 才能使用。这不是懒加载特有的问题只是因为懒加载模块和主模块的联系更松散更容易漏掉 import。检查时优先看报错信息里提到的管道或组件到对应模块的 declarations 和 imports 里确认一下即可。另外提一点如果你在某个懒加载模块里 import 了 SharedModule而这个 SharedModule 又 import 了某个公共服务模块这个懒加载模块会连带加载这些依赖。为了避免不必要的体积可以让 SharedModule 保持精简只放那些真正所有模块都会用的内容。5. 性能优化效果验证与后续扩展5.1 我验证优化效果的方式懒加载改造之后我习惯从三个方面去验证效果。第一是构建产物体积变化对比改造前主 bundle 大小和改造后主 bundle 大小这个数据最能直观说明问题。第二是浏览器加载性能用 Lighthouse 或者浏览器开发者工具的 Performance 面板测量首次加载时间、可交互时间、总阻塞时间这些指标。第三是实际网络环境测试用 4G 或慢速 3G 网络模拟真实用户体验。在我的项目里改造前主 bundle 大约 4.8MB改造后首屏实际加载的资源降到 700KB 左右首屏渲染时间从 6 秒多降到 2 秒以内可交互时间也明显提前。虽然路由跳转第一次访问某个业务模块时会有额外的加载过程但配合自定义预加载策略整体体验比原来强太多。5.2 后续还可以做的优化懒加载只是首屏优化的起点。接下来还有几个可以顺延的方向。一个是图片资源的懒加载。页面里如果有大量图片不要一股脑全都请求可以用 IntersectionObserver 实现可视区域内的图片懒加载这样首屏请求数量能再降一个量级。另一个是 Angular 的provideServerRendering和预渲染技术。对于需要 SEO 或者追求极致首屏速度的场景可以结合 Angular Universal 做服务端渲染或静态预渲染让用户先看到完整的 HTML 骨架再注水变成可交互应用。这个改造工程量大但收益也很可观。还有一个小细节是 CDN 缓存策略。懒加载生成的 chunk 文件名通常包含哈希值文件名变化代表内容变化非常适合长缓存。可以在服务器上配置这些 chunk 的缓存时间为一年充分利用浏览器缓存机制。5.3 踩坑之后的个人心得做过一轮完整的懒加载改造我最深刻的体会是懒加载不是万能的它是好看的花根子还要扎在模块划分和依赖管理上。模块边界划分得合理懒加载做起来顺手模块之间纠缠不清懒加载反而会放大依赖问题。另外懒加载改造要配合监控体系最好是能统计线上用户访问各个路由的时间分布用数据决定哪些模块值得预加载哪些模块保持按需加载。不要拍脑袋做决定数据不会骗人。我见过一个团队把所有模块都标记为预加载结果首屏流量没省多少还多了一堆请求最后不得不回过头来调整策略。最后再分享一个小技巧改造过程中如果担心影响线上业务可以先用路由守卫做一个灰度开关让一部分用户走懒加载逻辑另一部分用户保持原来的加载方式对比一段时间的数据再全量放开。这样即使出了问题影响面也控制在可控范围内。
返回列表