接手过几个 Angular 后台管理系统,路由配置从最开始十几个页面,慢慢长到几十上百个模块,最让我头疼的不是页面数量,而是页面之间的层级关系。菜单是两级、三级嵌套,页面里还要再嵌 tab、再嵌详情页,谁是谁的父路由、谁渲染在哪个 router-outlet 里,稍微不规划好,页面要么白屏,要么渲染错了位置。这篇就是把 Angular 嵌套路由这点事儿彻底捋一遍:父子路由怎么配、多级路由出口到底是怎么一层层匹配的、实际项目里怎么组织才能不踩坑。
不需要你有特别深的基础,只要写过 Angular 组件、知道 RouterModule 的基本用法,跟着思路走一遍,基本能覆盖日常 90% 的嵌套路由需求。
1. 嵌套路由在真实项目中的使用场景
1.1 为什么路由会越写越深
Angular 的路由设计本质上是一个树形结构。顶层是 App 的根路由,下面每个路由项又可以挂 children,children 里面还能再挂 children。很多人一开始写路由都是平铺的,比如/user/list、/user/detail,都放在一级路由里,组件也全部丢到顶层<router-outlet>里渲染。确实能跑,但一旦页面结构里出现“侧边栏菜单 + 顶部面包屑 + 内容区”,或者“列表页点进去是详情页,详情页里面又有一个二级 tab”,平铺方案就非常难处理了。
举个例子,一个后台系统的典型布局长这样:最外层是主框架组件,里面左边是菜单,右边是内容区。内容区里又分成几个业务子模块,每个子模块有自己的页头、操作栏和表格区域。如果所有业务页都渲染在同一个一级 outlet 里,那菜单的高亮、面包屑的联动、以及每个模块自己的布局状态,都得靠一堆*ngIf和全局变量去判断,维护成本特别高。
嵌套路由解决的核心问题是:让“路由层级”和“组件层级”一一对应。父路由组件负责布局,子路由组件渲染在父组件模板里的<router-outlet>中。这样一来,每一层的 UI 框架只需要关心自己这一层的路由状态,不用跨层通信,也不用存一堆“当前激活菜单”之类的全局状态。
1.2 项目案例:后台管理系统的三级页面结构
我最近维护的一个项目里,有一个典型的“三级嵌套”结构:
- 一级:登录后的主框架,包含顶栏、侧边栏、底部,以及主内容区;
- 二级:主内容区里是“销售管理”这个业务模块,模块内部有列表页、统计页;
- 三级:列表页里点击某条订单,进入“订单详情”,详情页里还有“基本信息”“操作日志”两个 tab。
这套结构如果不用嵌套路由,做起来非常别扭:订单详情必须知道它自己挂在哪个列表页下面,tab 切换也需要维护“当前详情页来自哪个列表”的状态。用嵌套路由之后,我的路由表是这么想的:
- 主框架是一个组件
MainLayoutComponent,路径为空时加载它,它的模板里有一个主<router-outlet>; MainLayoutComponent的 children 里有sales路径,对应SalesModule;SalesModule里自己的路由表有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-outlet>,SalesComponent模板里也必须有一个<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-outlet>,Angular 的 Router 在激活路由时,会按照路由配置的层级,找到每一层组件模板里的出口,然后把下一层组件塞进去。
拿上面那段配置来说:
- 浏览器访问
/main/sales/list; - Router 先匹配到
path: 'main',激活MainLayoutComponent,把它的模板渲染在根组件 AppComponent 的<router-outlet>里; - 紧接着继续匹配
main的 children,匹配到path: 'sales',在MainLayoutComponent模板中寻找<router-outlet>,把SalesComponent放进去; - 再往下匹配
path: 'list',在SalesComponent模板中寻找<router-outlet>,把SalesListComponent放进去。
所以,一个路由最终渲染在哪个出口,取决于你是“哪一层”路由。这也意味着,如果你想调整页面的物理位置,改的不是路由表,而是父组件模板里出口的位置。比如列表页的详情不是全屏覆盖,而是想出现在列表右边的抽屉区域,那就可以把SalesComponent模板设计成左右两栏,右侧栏放一个<router-outlet>,detail路由渲染在这个出口里。
注意:一个模板里可以有多个
<router-outlet>,但默认出口name属性为空的那个只能有一个。如果需要多个“并列”的出口区域,就要用到命名出口,后面专门说。
3. 多级路由出口实战:router-outlet 原理与命名出口
3.1 默认出口如何逐层匹配
默认情况下,模板里的<router-outlet></router-outlet>没有name属性,属于“主出口”。Angular 在每次路由变化时,会遍历激活的路由配置链,从根路由开始,依次确定每一层组件要渲染在哪个出口。
这里有个容易混淆的点:虽然每个组件模板都可以有一个主出口,但同一时刻“当前嵌套层级”的主出口只有一个。比如/main/sales/detail/:id,它渲染链是:
- AppComponent 出口 -> MainLayoutComponent
- MainLayoutComponent 出口 -> SalesComponent
- SalesComponent 出口 -> SalesDetailComponent
如果你在 MainLayoutComponent 模板里放了两个不带 name 的<router-outlet>,Angular 会警告:More than one default outlet。但如果你在不同层级的组件里都放主出口,那完全没问题,因为它们在组件树里属于不同层级,不会冲突。
理解这一点之后,你就能明白为什么嵌套路由必须“级联配出口”:缺失任何一层的<router-outlet>,下一层路由组件就找不到渲染位置,页面表现为白屏或者只渲染到某一层就断了。
3.2 命名 router-outlet 应对多区域渲染
有些场景下,一个父组件需要同时渲染多个子路由。比如详情页左侧是基本信息,右侧是操作日志,两个区域是并行路由关系,不是嵌套关系。这时候就可以用命名出口:
<!-- sales-detail.component.html --> <div class="detail-layout"> <div class="left"> <router-outlet name="info"></router-outlet> </div> <div class="right"> <router-outlet name="logs"></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 class="app-shell"> <aside class="sidebar">菜单</aside> <main class="content"> <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 class="sales-wrapper"> <div class="sales-header"> <h2>销售管理</h2> <nav> <a routerLink="/sales/list">列表</a> <a routerLink="/sales/stats">统计</a> </nav> </div> <div class="sales-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 class="list-page"> <div class="table-area"> <!-- 表格内容,点击行时跳转 detail --> </div> <div class="detail-panel" *ngIf="hasDetail"> <router-outlet></router-outlet> </div> </div>这个结构实现了:列表页和详情页共享同一个SalesListComponent外壳环境,点击某一行,详情区域局部渲染,不会跳出整个列表模块的框架。这里的hasDetail可以通过监听路由变化来控制,要在有详情路由激活时才显示右侧出口区域,否则空router-outlet会留下一个空白根元素影响布局。
4.2 父组件模板中的路由出口
很多人以为路由表配置好就完事了,其实模板里出口的位置决定了子路由最终“长”在哪儿。我曾见过一个项目,路由表写得非常标准,但父组件模板最外层只放了一个<router-outlet>,结果所有子页面都直接平铺在根布局里,菜单、面包屑全部失效。
设计模板出口时,我给自己的规则是:
- 每个“带有 children 的组件”模板里有且只有一个默认出口;
- 出口放在这个组件视觉上真正的“内容区”;
- 不要在同一个视觉区域内放多个默认出口;
- 如果子路由之间是切换关系,主出口就够用;如果是同时显示关系,才考虑命名出口。
SalesListComponent里那个*ngIf="hasDetail"的写法,本质上是为了解决“列表页没有详情时,右侧也不该留白”的体验问题。如果不加这个判断,空<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 class="detail-tabs"> <a [routerLink]="['./basic']">基本信息</a> <a [routerLink]="['./logs']">操作日志</a> </div> <div class="detail-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对应的路由段可以拿到id;DetailBasicComponent对应的路由段啥参数也没有;- 但
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 的路由系统对你来说基本就没有什么死角了。嵌套路由的核心始终是“把页面结构变成路由结构”,只要你在写路由表之前,先画出组件的嵌套树,后面每一步都会顺很多。