ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Angular嵌套路由完全指南:父子路由配置与多级router-outlet实战

Angular嵌套路由完全指南:父子路由配置与多级router-outlet实战 接手过几个 Angular 后台管理系统路由配置从最开始十几个页面慢慢长到几十上百个模块最让我头疼的不是页面数量而是页面之间的层级关系。菜单是两级、三级嵌套页面里还要再嵌 tab、再嵌详情页谁是谁的父路由、谁渲染在哪个 router-outlet 里稍微不规划好页面要么白屏要么渲染错了位置。这篇就是把 Angular 嵌套路由这点事儿彻底捋一遍父子路由怎么配、多级路由出口到底是怎么一层层匹配的、实际项目里怎么组织才能不踩坑。不需要你有特别深的基础只要写过 Angular 组件、知道 RouterModule 的基本用法跟着思路走一遍基本能覆盖日常 90% 的嵌套路由需求。1. 嵌套路由在真实项目中的使用场景1.1 为什么路由会越写越深Angular 的路由设计本质上是一个树形结构。顶层是 App 的根路由下面每个路由项又可以挂 childrenchildren 里面还能再挂 children。很多人一开始写路由都是平铺的比如/user/list、/user/detail都放在一级路由里组件也全部丢到顶层router-outlet里渲染。确实能跑但一旦页面结构里出现“侧边栏菜单 顶部面包屑 内容区”或者“列表页点进去是详情页详情页里面又有一个二级 tab”平铺方案就非常难处理了。举个例子一个后台系统的典型布局长这样最外层是主框架组件里面左边是菜单右边是内容区。内容区里又分成几个业务子模块每个子模块有自己的页头、操作栏和表格区域。如果所有业务页都渲染在同一个一级 outlet 里那菜单的高亮、面包屑的联动、以及每个模块自己的布局状态都得靠一堆*ngIf和全局变量去判断维护成本特别高。嵌套路由解决的核心问题是让“路由层级”和“组件层级”一一对应。父路由组件负责布局子路由组件渲染在父组件模板里的router-outlet中。这样一来每一层的 UI 框架只需要关心自己这一层的路由状态不用跨层通信也不用存一堆“当前激活菜单”之类的全局状态。1.2 项目案例后台管理系统的三级页面结构我最近维护的一个项目里有一个典型的“三级嵌套”结构一级登录后的主框架包含顶栏、侧边栏、底部以及主内容区二级主内容区里是“销售管理”这个业务模块模块内部有列表页、统计页三级列表页里点击某条订单进入“订单详情”详情页里还有“基本信息”“操作日志”两个 tab。这套结构如果不用嵌套路由做起来非常别扭订单详情必须知道它自己挂在哪个列表页下面tab 切换也需要维护“当前详情页来自哪个列表”的状态。用嵌套路由之后我的路由表是这么想的主框架是一个组件MainLayoutComponent路径为空时加载它它的模板里有一个主router-outletMainLayoutComponent的 children 里有sales路径对应SalesModuleSalesModule里自己的路由表有list和stats渲染在销售模块自己的布局组件里其中list组件里又嵌套了一个router-outlet订单详情的路由挂在list的 children 下这样详情页就只在列表页区域内渲染。实际上 Angular 的 Router 并不会限制嵌套层级理论上你可以一直children套下去但代码层面、模板层面一定要控制好出口数量。多级路由出口不是越多越好每一级出口都是组件树的一个分支层级太深会明显增加变化检测和路由激活时的性能开销。2. 父子路由配置从路由表说起2.1 最基础的父子关系children 配置Angular 里配置父子路由的核心就是children字段。看一段最基础的路由配置// app-routing.module.ts const routes: Routes [ { path: main, component: MainLayoutComponent, children: [ { path: sales, component: SalesComponent, children: [ { path: list, component: SalesListComponent, }, { path: detail/:id, component: SalesDetailComponent, }, ], }, ], }, ];这里MainLayoutComponent模板里必须有一个router-outlet/router-outletSalesComponent模板里也必须有一个router-outlet/router-outlet。只有每一层都放了出口路由才能一级一级渲染下去。很多人只配置了路由表忘了在父组件模板里加出口结果子路由永远不显示这就是最典型的新手问题。还有一个容易被忽视的点父组件自己需要有一个“默认内容”时可以在 children 里配置path: 的默认子路由而不是让父组件模板里直接写死ng-content。这样路由进入/main/sales时SalesComponent会渲染在MainLayoutComponent的出口里然后它的默认子路由对应的组件会渲染在SalesComponent自己的出口里。2.2 路径匹配的坑绝对路径与相对路径配置嵌套路由时最常踩的坑就是写错了路径。在 Angular 路由配置中children里的 path 默认是相对路径不要在前面加斜杠/加了斜杠就变成了从根路由开始匹配的绝对路径会打乱嵌套关系。比如// 错误写法子路由用了绝对路径 { path: main, component: MainLayoutComponent, children: [ { path: /sales, component: SalesComponent } // 匹配的是根路由下的 /sales不是 /main/sales ] }正确写法是写成path: sales这样实际匹配的完整路径才是/main/sales。如果你需要在某个层级里直接配置一个“根路径”风格的路由可以用path: /absolute-path但这种情况极其少见绝大多数时候都应该用相对路径。在组件里用routerLink跳转时也一样。默认routerLink里的路径是相对根路径的也就是说写了[routerLink][/main/sales/list]会从根路由开始匹配。如果当前已经在/main/sales下想相对跳转需要传一个对象[routerLink][./list]甚至可以用[../detail, id]这种相对上一级路径的写法。实际开发中我倾向于在模板里全部写绝对路径因为相对路径在多层嵌套下非常难排查代码一多你根本不知道./相对于哪一级。但如果是组件里动态拼接跳转链接用相对路径反而更稳妥因为父级路径一旦调整子级不需要跟着改。2.3 多个子路由出口router-outlet 的嵌套这是嵌套路由最核心的概念。每一级路由组件对应一个router-outletAngular 的 Router 在激活路由时会按照路由配置的层级找到每一层组件模板里的出口然后把下一层组件塞进去。拿上面那段配置来说浏览器访问/main/sales/listRouter 先匹配到path: main激活MainLayoutComponent把它的模板渲染在根组件 AppComponent 的router-outlet里紧接着继续匹配main的 children匹配到path: sales在MainLayoutComponent模板中寻找router-outlet把SalesComponent放进去再往下匹配path: list在SalesComponent模板中寻找router-outlet把SalesListComponent放进去。所以一个路由最终渲染在哪个出口取决于你是“哪一层”路由。这也意味着如果你想调整页面的物理位置改的不是路由表而是父组件模板里出口的位置。比如列表页的详情不是全屏覆盖而是想出现在列表右边的抽屉区域那就可以把SalesComponent模板设计成左右两栏右侧栏放一个router-outletdetail路由渲染在这个出口里。注意一个模板里可以有多个router-outlet但默认出口name属性为空的那个只能有一个。如果需要多个“并列”的出口区域就要用到命名出口后面专门说。3. 多级路由出口实战router-outlet 原理与命名出口3.1 默认出口如何逐层匹配默认情况下模板里的router-outlet/router-outlet没有name属性属于“主出口”。Angular 在每次路由变化时会遍历激活的路由配置链从根路由开始依次确定每一层组件要渲染在哪个出口。这里有个容易混淆的点虽然每个组件模板都可以有一个主出口但同一时刻“当前嵌套层级”的主出口只有一个。比如/main/sales/detail/:id它渲染链是AppComponent 出口 - MainLayoutComponentMainLayoutComponent 出口 - SalesComponentSalesComponent 出口 - SalesDetailComponent如果你在 MainLayoutComponent 模板里放了两个不带 name 的router-outletAngular 会警告More than one default outlet。但如果你在不同层级的组件里都放主出口那完全没问题因为它们在组件树里属于不同层级不会冲突。理解这一点之后你就能明白为什么嵌套路由必须“级联配出口”缺失任何一层的router-outlet下一层路由组件就找不到渲染位置页面表现为白屏或者只渲染到某一层就断了。3.2 命名 router-outlet 应对多区域渲染有些场景下一个父组件需要同时渲染多个子路由。比如详情页左侧是基本信息右侧是操作日志两个区域是并行路由关系不是嵌套关系。这时候就可以用命名出口!-- sales-detail.component.html -- div classdetail-layout div classleft router-outlet nameinfo/router-outlet /div div classright router-outlet namelogs/router-outlet /div /div路由配置里需要在同一个路由层级下用outlet属性标记出口名称{ path: detail/:id, component: SalesDetailComponent, children: [ { path: info, outlet: info, component: SalesInfoComponent }, { path: logs, outlet: logs, component: SalesLogsComponent }, ] }这种配置下访问 URL 会变成/main/sales/detail/123/(info:info//logs:logs)格式相对复杂。实际项目中我很少直接让用户看到这种 URL更多是配合辅助路由aux route做弹层或面板或者用编程式导航router.navigate去维护。命名出口最大的价值是一个父路由下可以同时激活多个子路由互不干扰。但它也带来两个问题URL 结构复杂、路由状态难读另外命名出口的子路由不会跟着默认出口的子路由自动销毁关闭其中一个出口时需要手动router.navigate清掉否则刷新页面后它会诡异复活。如果你刚接触嵌套路由我建议先别急着上命名出口优先把默认出口的层级关系理顺。3.3 嵌套路由与懒加载的配合项目一大路由表如果全部写在 AppModule 里启动时会加载所有组件各种图表库和第三方插件全塞进首屏 bundle体验非常差。懒加载是嵌套路由的黄金搭档。懒加载的配置方式比较简单父路由里不写component而是写loadChildren{ path: sales, loadChildren: () import(./sales/sales.module).then(m m.SalesModule) }SalesModule自己维护一份路由表并在模块内用RouterModule.forChild(routes)注册。这样一来访问/sales时才会去加载SalesModule的代码。嵌套路由配合懒加载要注意一个细节子模块路由表的根路径是空路径不是重复写父级路径。比如SalesModule里的路由配置const routes: Routes [ { path: , component: SalesLayoutComponent, children: [ { path: list, component: SalesListComponent }, { path: detail/:id, component: SalesDetailComponent }, ], }, ];这里path: 表示继承父路由的路径最终匹配/sales/list。如果你写成path: sales那访问的就是/sales/sales/list很容易发现自己多了一层没用的路径。懒加载还有一个优势模块内部的路由可以继续懒加载子模块。SalesModule里如果订单列表和统计报表是两个独立团队维护可以继续用loadChildren拆分理论上无限嵌套。我建议每一层模块只做“这一层需要的事情”不要把所有 children 全部挂到一个模块里否则懒加载的优势就没了。4. 实际配置案例三级菜单的完整实现4.1 模块划分与路由表代码下面用一个可以直接抄作业的案例来示范。假设项目结构是src/app/ app-routing.module.ts layout/ main-layout.component.ts pages/ sales/ sales.module.ts sales-routing.module.ts sales-layout.component.ts sales-list/ sales-list.module.ts sales-list-routing.module.ts sales-list.component.ts sales-detail/ sales-detail.component.ts根路由表// app-routing.module.ts const routes: Routes [ { path: , component: MainLayoutComponent, children: [ { path: sales, loadChildren: () import(./pages/sales/sales.module).then(m m.SalesModule) }, { path: , redirectTo: sales, pathMatch: full } ] } ];MainLayoutComponent模板结构示意div classapp-shell aside classsidebar菜单/aside main classcontent router-outlet/router-outlet /main /div销售模块路由表// sales-routing.module.ts const routes: Routes [ { path: , component: SalesLayoutComponent, children: [ { path: list, loadChildren: () import(./sales-list/sales-list.module).then(m m.SalesListModule) }, { path: stats, component: SalesStatsComponent }, { path: , redirectTo: list, pathMatch: full } ] } ];销售布局组件模板div classsales-wrapper div classsales-header h2销售管理/h2 nav a routerLink/sales/list列表/a a routerLink/sales/stats统计/a /nav /div div classsales-body router-outlet/router-outlet /div /div列表模块继续懒加载// sales-list-routing.module.ts const routes: Routes [ { path: , component: SalesListComponent, children: [ { path: detail/:id, component: SalesDetailComponent } ] } ];列表组件模板里在列表区域旁边放一个出口给详情用div classlist-page div classtable-area !-- 表格内容点击行时跳转 detail -- /div div classdetail-panel *ngIfhasDetail router-outlet/router-outlet /div /div这个结构实现了列表页和详情页共享同一个SalesListComponent外壳环境点击某一行详情区域局部渲染不会跳出整个列表模块的框架。这里的hasDetail可以通过监听路由变化来控制要在有详情路由激活时才显示右侧出口区域否则空router-outlet会留下一个空白根元素影响布局。4.2 父组件模板中的路由出口很多人以为路由表配置好就完事了其实模板里出口的位置决定了子路由最终“长”在哪儿。我曾见过一个项目路由表写得非常标准但父组件模板最外层只放了一个router-outlet结果所有子页面都直接平铺在根布局里菜单、面包屑全部失效。设计模板出口时我给自己的规则是每个“带有 children 的组件”模板里有且只有一个默认出口出口放在这个组件视觉上真正的“内容区”不要在同一个视觉区域内放多个默认出口如果子路由之间是切换关系主出口就够用如果是同时显示关系才考虑命名出口。SalesListComponent里那个*ngIfhasDetail的写法本质上是为了解决“列表页没有详情时右侧也不该留白”的体验问题。如果不加这个判断空router-outlet虽然不会渲染组件但会在 DOM 里留下一个注释节点和少量空白对于精确布局可能会造成几像素偏差。4.3 子组件内继续嵌套配置三级菜单只是起点项目里还有可能无限嵌套下去。子组件里的路由出口注意点跟父级一样但有一个细节容易忽略子组件被复用时的路由状态。比如订单详情组件SalesDetailComponent里面又按 tab 分成了“基本信息”“操作日志”用children tab 切换实现{ path: detail/:id, component: SalesDetailComponent, children: [ { path: , redirectTo: basic, pathMatch: full }, { path: basic, component: DetailBasicComponent }, { path: logs, component: DetailLogsComponent }, ] }模板里放 tab 导航和出口div classdetail-tabs a [routerLink][./basic]基本信息/a a [routerLink][./logs]操作日志/a /div div classdetail-content router-outlet/router-outlet /div这样 URL 变成/sales/list/detail/123/basic每一层都有明确的路径刷新后也能自动定位到对应 tab。比起用[routerLink]切换ngIf显隐的方式路由驱动的 tab 天然支持浏览器前进后退也方便把当前 tab 状态分享给同事排查问题。嵌套层级深了以后有一个“如何获取父级路由参数”的问题会频繁出现。比如详情组件里的子组件需要拿到id参数不能直接this.route.snapshot.paramMap.get(id)因为那只会取当前路由段的参数不会包含父级路由段的参数。后面会专门讲怎么处理。5. 嵌套路由中的参数传递与路由守卫5.1 ActivatedRoute获取父级与子级参数在多层嵌套路由下ActivatedRoute对象形成了一个“路由树”。比如当前激活路由是/sales/list/detail/123/basic那么SalesDetailComponent对应的路由段可以拿到idDetailBasicComponent对应的路由段啥参数也没有但DetailBasicComponent里的组件如果需要id必须从父路由段拿。代码有两种写法。第一种逐层向上找。import { ActivatedRoute } from angular/router; constructor(private route: ActivatedRoute) { this.route.parent?.paramMap.subscribe(params { this.orderId params.get(id); }); }缺点很明显路由层级一改parent的指向就变了代码很脆弱。我推荐第二种用snapshot路由树遍历或者直接用paramMap的“合并查询”。Angular 其实并没有提供直接获取“全部参数”的内置 API但可以自己封装一个工具函数function getRouteParams(route: ActivatedRoute): ParamMap { let current route.snapshot; const params: { [key: string]: string } {}; while (current) { current.params Object.assign(params, current.params); current current.parent; } return convertToParamMap(params); }这个函数会从当前路由一直向上合并参数。子路由的参数会覆盖父路由的同名参数这个覆盖顺序符合直觉越具体的路由参数优先级越高。另外还有ActivatedRoute.firstChild的用法常用在“父组件需要知道当前激活的子路由是什么”的场景。比如列表页要根据详情路由是否被激活来显示右侧面板constructor(private route: ActivatedRoute) { this.route.firstChild?.url.subscribe(url { this.hasDetail url.some(segment segment.path detail); }); }5.2 路由守卫与多级路由的相互影响多级路由还有一个容易忽略的点CanActivate守卫在路由链中是从父到子依次执行的。当前 URL 层级越深需要通过的守卫就越多。如果某一层的CanActivate返回 false整个导航直接取消当前页面路由不变。实际项目里最常见的需求是订单详情里的操作日志需要“额外权限”但是基本信息不需要。这时候不建议把权限判断塞进DetailLogsComponent的构造函数里做“进入后拦截”更好的方式是给logs这个子路由单独挂CanActivate守卫{ path: logs, canActivate: [LogPermissionGuard], component: DetailLogsComponent, }这样权限不足的用户点击“操作日志”tab 时导航直接不成功页面还停留在基本信息的 URL。比在组件内部弹提示再跳转要干净得多。守卫里要拿父级路由参数也很常见登录守卫需要根据当前订单的归属人做鉴权。这个时候不要只依赖ActivatedRouteSnapshot.paramMap因为那一层可能没有id。正确做法是从route.parent一路向上找Injectable() export class LogPermissionGuard implements CanActivate { canActivate(route: ActivatedRouteSnapshot): boolean { let current route; while (current.parent) { current current.parent; } const orderId current.paramMap.get(id); // 根据 orderId 做权限判断 return this.authService.canViewLogs(orderId); } }注意这里用while (current.parent)是因为不确定id到底在第几层所以干脆遍历整个父链。6. 常见问题与排查技巧实录6.1 页面空白/组件不渲染这种情况几乎都出在嵌套层级上。排查步骤我已经固定在脑子里了打开浏览器控制台看有没有 Angular 的警告或错误特别是有没有Cannot match any routes打开开发者工具的 Network看懒加载模块有没有被请求。如果对应的 js 文件没有加载说明loadChildren路径写错了进入最终目标页面的组件模板从最外层逐个确认每一层的router-outlet是否存在检查路由配置里的 path 是否以/开头子路由绝对路径是最常见的问题来源之一。有一次我排查一个白屏找半天发现是父组件的*ngIf把出口所在的 DOM 给移除掉了。Angular 在 DOM 里找不到出口子路由自然不渲染。这种问题比路由配置错误更隐蔽因为控制台没任何错误组件也加载了页面就是空的。6.2 路由出口太多不知道渲染到哪里项目里如果命名出口和默认出口混用容易出现“组件渲染位置完全不在预期”的情况。我的建议是先画出一棵组件嵌套树每个节点标注对应的路由 path在模板里给每个router-outlet加一个>buildBreadcrumb(route: ActivatedRoute): BreadcrumbItem[] { let current route.snapshot; const items []; while (current) { if (current.data[breadcrumb]) { items.unshift({ label: current.data[breadcrumb], url: current.pathFromRoot.map(r r.url).join(/) }); } current current.parent; } return items; }菜单高亮同理可以用Router.isActive(url, { paths: subset, queryParams: ignored })来判断当前路由是否属于某个菜单项这也是嵌套路由场景下最常用的小技巧。后续如果你想再深入可以研究路由的runGuardsAndResolvers、自定义复用策略RouteReuseStrategy、以及辅助路由与命名出口的配合。等你把这些都吃透了Angular 的路由系统对你来说基本就没有什么死角了。嵌套路由的核心始终是“把页面结构变成路由结构”只要你在写路由表之前先画出组件的嵌套树后面每一步都会顺很多。
返回列表