十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Angular模块化路由实践:RouterModule、懒加载与子路由配置指南

Angular模块化路由实践:RouterModule、懒加载与子路由配置指南 1. 为什么非要把路由拆进Module从主路由失控说起先说一个我在项目里真实遇到过的情况。早期接手一个后台管理系统所有业务页面全挂在根路由的app-routing.module.ts里一批采购同事天天点着用也没人觉得哪里不对劲。但等业务膨胀到一百多个路由配置之后问题就来了AppModule里引了一堆组件所有页面代码全部打进主 bundle首屏加载慢得让人抓狂更难受的是每次新增一个功能模块都要打开那个已经接近两千行的路由文件小心翼翼地在正确位置插入一条记录稍不留神就把父子层级的缩进搞乱路由匹配顺序直接崩掉。这个痛点的本质是路由配置和模块边界没有对齐。Angular 的架构里NgModule是用来划分功能边界的每个Module管理自己的一组组件、指令、管道和服务。如果你把路由全部堆在根模块里等于把这个边界彻底抹掉了——组件的归属、服务的隔离、代码的分包都无从谈起。而把路由拆到业务 Module 内部让模块自己管自己的路由才是这套框架推荐的正路。angular 路由的这套设计解决的不只是代码变整齐这种审美问题。拆完模块之后每个业务模块的路由配置可以跟着模块一起被懒加载——用户没进这个模块之前相关代码根本不会下载。项目规模越大这个收益越明显。这篇文章的主题就是怎么把通过 Module 内的路由配置访问组件这套打法吃透包括RouterModule.forChild的注册机制、loadChildren的懒加载配置、子路由children的嵌套策略以及我在实际项目里踩过的各种路由相关坑。适合看这篇文章的读者是已经会用 Angular 写组件、知道routerLink基本用法的同学。如果你的项目还停留在所有路由一把梭的阶段或者虽然用了懒加载但说不清forRoot和forChild到底干了什么那这篇内容就是为你写的。2. RouterModule.forRoot 与 forChild路由注册的双轨制2.1 为什么根模块用 forRoot子模块用 forChild刚开始写 Angular 路由时我做过一件蠢事在懒加载的子模块里也用了RouterModule.forRoot(routes)来注册路由。结果一运行页面直接报错控制台刷出一堆关于 Router 已经被实例化的警告。后来才弄明白forRoot和forChild不是随便挑一个用它们面向的是完全不同的场景。RouterModule.forRoot(routes)只在根模块AppModule里调用一次。它的职责有两个一是注册路由配置二是创建 Router 服务实例并把它挂到全局依赖注入器里。Router 是整个应用级别的单例负责监听浏览器 URL 的变化、执行路由匹配、管理导航状态这些能力只需要一份而且必须在应用启动时就位。而RouterModule.forChild(routes)只做一件事往现有的路由配置表里追加路由规则。它不会创建新的 Router 实例只是告诉 Angular我这还有一批路由要登记。这就像去政务大厅办事——forRoot是开设窗口、配好工作人员forChild是你拿着材料去那个窗口补交。之所以这样设计是因为 Angular 的路由表是扁平化存储的。你在子模块里写的嵌套路由最终会被合并到一棵路由树中而不是各自维护一张独立的路由表。如果每个子模块都用forRoot就会反复创建 Router轻则警告重则导航状态错乱、路由匹配失效属于典型的低级错误。2.2 imports 数组里的注册顺序影响比你想象的大在实际的AppModule里路由模块通常长这样NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, HttpClientModule, AppRoutingModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }而AppRoutingModule内部是NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule] }) export class AppRoutingModule { }有一个细节很容易被忽视AppRoutingModule为什么要exports: [RouterModule]因为RouterModule里还导出了RouterLink、RouterLinkActive、RouterOutlet这些指令。如果你的根组件模板里用了router-outlet和routerLink而这些指令所在的模块没有被导出模板编译会直接报 Cant bind to routerLink since it isnt a known property 的错误。子模块里同样要在imports里引入RouterModule或你自己的路由模块否则子组件模板里也没法使用路由指令。关于imports数组的顺序虽然 Angular 对路由模块的导入顺序不像某些框架那么敏感但有一个原则值得守住业务模块放在后面公共模块放在前面。尤其是当模块之间存在路由关联时先导入被依赖的模块再导入依赖方排查问题时会省很多事。2.3 forRoot 里还能传哪些配置项forRoot不是只能传一个路由数组第二个参数还能传入一个ExtraOptions配置对象。我在项目里最常用的几个RouterModule.forRoot(routes, { useHash: true, // 使用 URL 中的 # 号适合部署在纯静态环境 enableTracing: false, // 调试路由时改成 true可以在控制台看导航日志 scrollPositionRestoration: enabled, // 页面滚动位置恢复 anchorScrolling: enabled, // 支持锚点滚动 paramsInheritanceStrategy: always // 子路由自动继承父路由参数 })useHash: true在部署到 Nginx 静态服务器、又不想为每个前端路由配置 try_files 重写时非常实用。不过要注意hash 模式下的 URL 丑一点而且对 SEO 不友好能配好服务端 rewrite 的情况下还是优先用默认的PathLocationStrategy。paramsInheritanceStrategy: always也是一个容易忽略但价值很高的配置。默认情况下子路由的ActivatedRoute只能拿到自己的参数拿不到父路由的设成always之后子路由可以顺藤摸瓜拿到父路由的params和data配合嵌套路由用起来非常方便。3. loadChildren 的两种写法懒加载模块的核心语法3.1 字符串写法与函数式写法的对比懒加载模块的配置写法上经历了两个阶段。老一点的 Angular 项目里loadChildren后面跟的是字符串const routes: Routes [ { path: products, loadChildren: ./products/products.module#ProductsModule } ];这种写法在 Angular 8 之后逐渐被废弃现在更推荐的是函数式的import()动态导入写法const routes: Routes [ { path: products, loadChildren: () import(./products/products.module).then(m m.ProductsModule) } ];区别在哪字符串写法是 Angular 自己维护了一份模块路径字符串到模块类的映射表构建工具在打包时需要扫描这些字符串去做代码分割这意味着它依赖特定的构建配置而且类型不安全——路径写错了要运行时报错才能发现。函数式写法直接利用 JavaScript 原生的动态import()语法Webpack 等打包工具天然支持能自动把ProductsModule及其依赖切分成独立的 chunk类型安全编辑器里就能检查出路径错误。核心变化是代码分割的时机从构建工具约定的字符串扫描变成了原生语言层面的动态加载语义更清晰也更好调试。3.2 函数式写法里then(m m.xxx) 到底在干什么刚接触函数式写法的人常常会对then(m m.ProductsModule)这一步产生疑惑——我都已经import()了这个模块文件为什么还要再取一次ProductsModule因为import()返回的是整个模块的命名空间对象不是默认导出。ProductsModule是那个文件里以export class ProductsModule方式导出的具名类存在模块对象的ProductsModule属性上。所以你必须从模块对象里把这个类取出来交给loadChildren。有的项目里模块文件还顺带导出了别的类比如路由模块ProductsRoutingModule如果忘了.then(m m.ProductsModule)这一步Angular 拿到的就不是一个NgModule类型的值运行时会直接报 Invalid module 之类的错误。3.3 懒加载的 chunk 拆分行为用函数式import()之后你去看构建产物会发现每个懒加载模块被拆成了独立的.js文件命名规则一般是模块名 hash。这个行为是打包工具自动完成的不需要额外配置。但这里有一个容易被忽略的问题共享依赖的抽取策略。比如ProductsModule和OrdersModule都依赖同一个SharedModule如果SharedModule比较大打包工具可能把它抽成一个公共 chunk两个业务模块共同引用如果SharedModule很小也可能被各自打进自己的 bundle 里。这个策略由optimization.splitChunks配置控制。想确认实际的拆分结果可以看构建输出目录里的文件列表或者用source-map-explorer分析包体积。3.4 prefetch 和 preload 的权衡懒加载解决的是首屏不加载无用代码的问题但代价是首次进入某个子模块时需要等对应的 chunk 从服务器拉下来会有一小段白屏时间。如果你的应用对这个延迟比较敏感可以考虑引入预加载策略。Angular 内置了两种预加载策略RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })PreloadAllModules会在首屏加载完成后把所有懒加载模块的 chunk 都提前下载到本地。这样用户后续点进任何子页面都能瞬间打开代价是带宽占用变高。如果不想全量预加载还可以自己实现CustomPreloadingStrategy通过路由配置里的data字段决定哪些模块需要预加载。这个进阶玩法适合中大型项目大到一定程度后首屏体验和次屏体验的平衡只能靠精细化控制。4. children 子路由的配置策略从单层嵌套到多层嵌套4.1 什么时候需要 children而不是重新开一段 path很多初学者容易走进一个思维惯性每个页面配一条独立的路由路径互不相关比如/products、/products-detail、/products-edit。这在页面之间毫无关系时没问题但一旦它们属于同一个业务区域、需要共享布局和公共逻辑时用children嵌套路由就是更合理的做法。children解决的问题是布局复用。比如商品模块下的列表页、详情页、编辑页顶部都要显示同一个商品分类导航栏。如果不做嵌套每个页面都要自己写一遍导航栏用嵌套路由的话父路由对应一个壳组件里面放导航栏和router-outlet子路由对应的组件渲染在壳组件的出口里。另外一个实际收益是参数共享。父路由的路径段上的参数比如/products/:categoryId/list子路由组件可以通过ActivatedRoute.parent访问到配合前面提到的paramsInheritanceStrategy: always配置甚至可以直接通过自身的ActivatedRoute.params拿到。4.2 一个完整的嵌套路由配置实例假设我有一个商品模块路径结构是这样的/products—— 商品列表页/products/:id—— 商品详情页/products/:id/edit—— 商品编辑页模块内的路由配置如下// products-routing.module.ts import { NgModule } from angular/core; import { RouterModule, Routes } from angular/router; import { ProductListComponent } from ./product-list/product-list.component; import { ProductDetailComponent } from ./product-detail/product-detail.component; import { ProductEditComponent } from ./product-edit/product-edit.component; import { ProductsComponent } from ./products.component; const routes: Routes [ { path: , component: ProductsComponent, // 壳组件内含导航和 router-outlet children: [ { path: , component: ProductListComponent }, { path: :id, component: ProductDetailComponent }, { path: :id/edit, component: ProductEditComponent } ] } ]; NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] }) export class ProductsRoutingModule { }注意这个配置里最关键的两个细节第一壳路由的path是空字符串。在子路由配置中空路径表示匹配父路由的原始路径也就是/products。但这里有个陷阱/products如果同时匹配了壳路由和列表子路由的path: Angular 会怎么处理答案是——子路由的path: 匹配的就是父路由的全路径所以/products会渲染出ProductsComponent壳壳的出口里再渲染ProductListComponent两个组件同时出现在页面上不会冲突。第二子路由path不要以斜杠/开头。path: /:id这种写法会直接报错Angular 会提示你子路由路径不能以斜杠开头。子路由最终匹配的完整 URL是父路由的路径段和子路由路径段的拼接不需要你手动加斜杠。4.3 嵌套层级的最大深度以及什么时候该收手理论上 Angular 路由嵌套没有硬性的层级上限但实践中我见过嵌套到五六层的项目维护起来非常痛苦。每一层嵌套都意味着一次组件渲染树的加深数据流要穿透多层ActivatedRoute排查问题时的上下文切换成本会指数级上升。我的经验是能控制在三层以内的绝不做到第四层。如果确实有很深的层级需求先反问自己这个层级是真的需要展示为嵌套 UI还是只是 URL 结构上的关系很多时候把一部分层级拆成独立的模块标注用查询参数或矩阵参数来传上下文反而更清晰。4.4 壳组件里 router-outlet 的摆放位置壳组件模板里router-outlet的位置决定了子组件渲染在哪。最常见的做法是放在导航栏下面!-- products.component.html -- div classproducts-container nav classcategory-nav a routerLink/products routerLinkActiveactive [routerLinkActiveOptions]{ exact: true }全部商品/a a [routerLink][/products, category.id] *ngForlet category of categories$ | async {{ category.name }} /a /nav div classproducts-content router-outlet/router-outlet /div /divrouterLinkActive在这个场景下有个常见的坑如果不加[routerLinkActiveOptions]{ exact: true }/products这个链接在访问/products/123时也会保持高亮状态因为默认是前缀匹配。只有当你希望访问任何商品子页面时全部商品都高亮时才不加exact。这个决定权在你的业务语义里但要意识到这个行为不是 bug是设计。5. 组件实际怎么访问路由链接、跳转代码与参数读取5.1 模板里的跳转routerLink 的数组写法配置好了路由组件里访问目标组件的方式无非两类模板中声明式跳转和组件类里命令式跳转。模板中最常用的是routerLink传数组而不是字符串是一个值得养成的习惯!-- 字符串写法只适合静态路径 -- a routerLink/products/123商品详情/a !-- 数组写法适合动态参数 -- a [routerLink][/products, product.id]查看详情/a数组写法的本质是把 URL 的每一段作为数组的一个元素Angular 会在内部把它们拼接成完整的路径。这对国际化、路由参数动态变化、以及嵌套路径特别有用。比如带查询参数的跳转a [routerLink][/products, product.id] [queryParams]{ from: list } 查看详情来自列表 /a这样 URL 会变成/products/123?fromlist目标组件里可以用ActivatedRoute.snapshot.queryParamMap.get(from)来读取。5.2 代码里跳转navigateByUrl 和 navigate 怎么选在组件类里做跳转有两条路import { Router, ActivatedRoute } from angular/router; constructor( private router: Router, private route: ActivatedRoute ) {} // 方式一navigateByUrl传完整 URL 字符串 goDetailByUrl(id: string) { this.router.navigateByUrl(/products/${id}); } // 方式二navigate传指令数组 goDetail(id: string) { this.router.navigate([/products, id], { queryParams: { from: component } }); }两者最核心的区别是navigateByUrl是绝对路径导航它直接把整个 URL 交给 Router 去匹配适合已知完整地址的场景navigate是相对指令导航支持relativeTo参数适合在嵌套路由的上下文里做相对跳转。比如在商品详情页里跳转到它的编辑页goEdit() { this.router.navigate([edit], { relativeTo: this.route }); }如果当前 URL 是/products/123relativeTo: this.route会让 Angular 以当前激活路由为基准去解析[edit]最终跳转到/products/123/edit。这个写法在深层嵌套路由里特别实用不用再手拼绝对路径重构时也更安全。5.3 读取路由参数snapshot 和订阅的取舍目标组件在ngOnInit里读参数有两个办法// 方法一snapshot只读一次 ngOnInit() { const id this.route.snapshot.paramMap.get(id); } // 方法二订阅流响应式 ngOnInit() { this.route.params.subscribe(params { const id params[id]; }); }两者的选择标准其实很清晰如果组件在同一个路由实例内参数会变化就必须用订阅。典型的场景是商品列表页点 A 商品、再点 B 商品URL 从/products/A变成/products/B但 Angular 默认会复用同一个ProductDetailComponent实例因为它在路由配置树中的位置没变。这时候snapshot.paramMap拿到的永远是第一次的A页面不会刷新数据。用订阅方式时要注意ngOnInit里订阅后必须在ngOnDestroy里取消订阅否则组件销毁后回调依然会执行引发内存泄漏和状态错乱。Angular 的async管道可以避免手动管理订阅或者在模板里处理参数变化!-- 模板里直接用 async 管道订阅参数 -- div *ngIfproduct$ | async as product {{ product.name }} /divproduct$ this.route.paramMap.pipe( switchMap(params this.productService.getProduct(params.get(id))) );这个switchMap的写法是我最推荐的方式参数变化时自动取消上一次请求、发起新请求而且不需要手动取消订阅是响应式编程在路由参数场景下的标准解法。5.4 参数、查询参数和矩阵参数的区分在路由传参这件事上很多人分不清三类参数。我列个表格一目了然参数类型URL 示例读取方式适用场景路由参数/products/123route.snapshot.paramMap.get(id)核心业务ID参与路由匹配查询参数/products/123?fromlistroute.snapshot.queryParamMap.get(from)附带上下文信息可选中可分享矩阵参数/products/123;editoradminroute.snapshot.paramMap.get(editor)同一 URL 下多种状态变体矩阵参数在日常业务中用得不多但有一种场景很管用多个平行组件共享同一个 URL 时矩阵参数按组件分别携带状态互不干扰。不过这种场景在后台管理系统中比较罕见了解即可。6. 辅助路由 outlet一个页面同时挂载两个组件的解法6.1 为什么需要命名路由出口后台系统里有一种非常常见的交互形态列表页旁边挂了一个最近浏览侧边栏或者弹窗式的快捷编辑面板。这两个区域的内容要和主内容区同步响应路由变化——点开一个商品主区域显示商品详情侧边栏显示这个商品的关联推荐。这种需求靠children嵌套不够用因为你需要在同一级布局里渲染两个独立的组件树。Angular 的解决方案是命名路由出口auxiliary route。主路由出口是匿名的router-outlet命名出口是可以有名字的router-outlet namesidebar。一个路由配置可以同时激活多个出口URL 里用(出口名:路径)的语法来区分。6.2 配置和跳转的完整示例假设我要做一个带侧边栏的商品模块。壳组件的模板div classproducts-layout div classmain-content router-outlet/router-outlet /div aside classsidebar router-outlet namesidebar/router-outlet /aside /div路由配置里除了主路由还要配置辅助路由const routes: Routes [ { path: products, component: ProductsComponent, children: [ { path: , component: ProductListComponent }, { path: :id, component: ProductDetailComponent } ] }, { path: recommend/:id, component: RecommendPanelComponent, outlet: sidebar // 关键这个路由激活的是 sidebar 出口 } ];跳转到辅助路由的命令是goRecommend(id: string) { this.router.navigate([{ outlets: { sidebar: [recommend, id] } }]); }这样操蛋的地方在于 URL 会变成/products/123(sidebar:recommend/123)第一次见到的人多半会愣一下。括号后面跟的是辅助路由的激活状态。如果你不想让辅助路由出现在 URL 里就偏不用不过 Angular 的实现决定了辅助路由的激活信息必须编码在 URL 里刷新才能恢复状态。6.3 辅助路由的适用边界辅助路由不是一个用得越多越好的特性。它的调试成本相对高URL 可读性差而且辅助出口过多时团队协作中很容易搞混这个出口被哪个路由控制的。我的建议是全项目最多保持一个辅助出口用于那种全局性的、需要跨模块跳转的边栏/抽屉场景。单纯的弹窗用MatDialog或自定义弹窗组件加服务就足够了没必要动用辅助路由。7. 一路踩过来的坑模块路由的常见报错与排查思路7.1 子模块没导 forChild 导致的路由指令失效报错信息通常是这样Cant bind to routerLink since it isnt a known property of a第一次遇到时很多人以为是模块导入顺序错了其实不然。根本原因是你这个子模块的组件模板里用了routerLink指令但这个模块的imports里没有引入任何导出了路由指令的模块。懒加载模块的组件只认识它自己模块里声明过的东西。解决办法在子模块的imports里加上RouterModule或者更好的是加上ProductsRoutingModule——因为它已经exports: [RouterModule]了。7.2 空路径路由重定向顺序导致的空白页配置路由时我喜欢这样写const routes: Routes [ { path: , redirectTo: /products, pathMatch: full }, { path: **, component: PageNotFoundComponent } ];pathMatch: full是全匹配redirectTo才会生效。如果不加pathMatchAngular 会按前缀匹配path: 会匹配到所有路径导致重定向无限循环。这个坑在配置空路径重定向时几乎人人都会遇到一次记住一条规则空路径的重定向永远带上pathMatch: full。通配符路由path: **要放在路由表的最后否则它会吞掉所有请求。这个规则适用于根路由也适用于每个层级的路由表。7.3 懒加载模块内部的path: 匹配不到父路由在懒加载模块里壳路由的path: 有时候会让人困惑。举个例子ProductsModule是通过loadChildren挂在/products下的模块内路由表的第一条是{ path: , component: ProductsComponent }。访问/products时它能匹配上吗能前提是你没有在模块内部再嵌套一层空路径重定向。更常见的坑是模块内第一条路由忘了写path: 直接写了{ path: list, component: ProductListComponent }访问/products就会落空页面呈现空白。懒加载模块的路由匹配是把外部的路径前缀拼到模块内部的路由路径上再匹配的模块内部不会自动补一个空路径路由。7.4 路由懒加载后模块间服务的隔离问题loadChildren加载的模块在模块中providers注册的服务作用域是这个模块自身。如果两个懒加载模块需要共享同一个服务实例比如购物车状态必须在根模块的providers或CoreModule里注册而不是在两个业务模块里各自providers。在业务模块里重复注册结果就是两个模块各持有一份实例数据完全不互通。这个问题隐蔽性很高往往是要么两边数据不一致要么状态莫名其妙被重置。排查手段是检查服务实例的引用——在服务构造函数里打印一个随机数观察两个模块拿到的值是否一致。7.5 路由配置变更未被懒加载模块感知开发时改了懒加载模块里的路由配置页面刷新后偶尔还是旧路由生效。这类问题多数是构建缓存或开发服务器热更新没生效导致的。常规操作是先清缓存重启开发服务器。如果还不行检查是否同时在多个地方 import 了同一个模块的路径路径不一致会导致模块被打包两份路由表也被注册两次。8. 我实际跑通一个最小示例后的体会按惯例最后分享一组我实际验证过的最小可运行配置适合拿去做实验。完整的工程结构极简版src/app/ ├── app.module.ts ├── app-routing.module.ts ├── app.component.html └── products/ ├── products.module.ts ├── products-routing.module.ts ├── products.component.ts ├── products.component.html ├── product-list/product-list.component.ts ├── product-detail/product-detail.component.ts └── product-edit/product-edit.component.tsapp-routing.module.ts里只有一行核心配置const routes: Routes [ { path: products, loadChildren: () import(./products/products.module).then(m m.ProductsModule) }, { path: , redirectTo: /products, pathMatch: full } ];products-routing.module.ts和前面 4.2 节的配置一样。跑起来之后访问/products、/products/42、/products/42/edit观察组件是否按预期渲染、URL 是否正常变化、network 面板里懒加载 chunk 是否只在第一次进入时下载。跑完这个示例再去回顾forRoot和forChild的区别、children嵌套的语义、loadChildren的懒加载机制很多之前模糊的概念都能对上了。Angular 路由这套体系核心其实就一句话路由配置和模块结构对齐让每个模块自己负责从路径解析到组件渲染这一段链路。理解了这个后面再看路由守卫、路由解析器、路由懒加载与预加载的组合玩法就都是水到渠成的事了。
返回列表