做某内容平台项目进入第十天的时候,我遇到一个绕不过去的坎。前九天页面还是"平铺"的:一个路由对应一个页面,URL 一配、组件一挂,完事。但这一天要同时把用户端前台和管理员后台放进同一个项目,老办法直接撑不住了。前台有导航、页脚、固定布局,后台有侧边栏、顶栏、折叠菜单,两边页面还要共用登录状态和权限体系。路由一下子从"穿项链"变成了"叠罗汉",我不得不正式搞起路由嵌套,顺手把整套前后台架构重新搭了一遍。
这篇就把 Day10 完整过程写出来。内容围绕两层:一是嵌套路由的原理、配置、踩坑,二是基于嵌套路由的前后台两套布局怎么落地。适合正在用 Vue 开发中后台项目、被路由结构和权限控制绕晕的人。看完你至少能分清"静态路由表 + 动态嵌套路由 + 布局组件出口"这三层关系,也能避开我踩过的那几个坑。
1. 为什么平铺路由撑不住真实项目:路由嵌套要解决的问题
1.1 我是在哪一刻决定必须用嵌套路由的
Day10 的需求是给内容平台加管理后台。前台是用户浏览页,后台是管理员操作区,两者差异很大。前台页面长这样:顶部导航、中间内容、底部版权,三块结构几乎所有页面共用。后台页面则是左侧菜单、顶部面包屑、右侧内容区,而且后台还需要二级菜单,比如"用户管理"下面要挂"用户列表"和"用户详情"两个页面。
如果用平铺路由硬写,每个页面都要自己引入一遍导航组件。比如前台有 8 个页面,我就得在 8 个页面的模板里都复制一份顶部导航代码,改一处要同步改八处。后台更麻烦,侧边栏的选中高亮逻辑写在哪?菜单展开状态怎么在页面切换时保持一致?这些问题在平铺结构下没有一个干净的答案。
真正让我决定必须改架构的,是 URL 的问题。后台用户详情的 URL 是/admin/users/detail/123,从语义上看,这个页面天然属于"用户管理"这个上级模块。但平铺路由里它就是一个独立的顶级路由,和"用户管理列表"没有父子关系,这导致后续做面包屑、做权限粒度控制、做菜单高亮全都别扭。我意识到,路由不只是"路径到组件的映射",它本身就应该是项目结构的一种表达。
1.2 嵌套路由的本质:URL 层级、组件层级、布局复用三者合一
嵌套路由的核心机制其实一句话就能说清:父路由的组件负责渲染公共布局,子路由的组件渲染在父组件内部的某个出口里。
拿用户中心举例。我要实现这么一套结构:进入用户中心的任何子页面,顶部都是同一套个人资料卡和功能导航。用嵌套路由配置是这样的:
const routes = [ { path: '/user', component: UserCenterLayout, // 公共布局 children: [ { path: 'articles', component: UserArticles }, { path: 'settings', component: UserSettings } ] } ]访问/user/articles时,渲染过程是这样的:先渲染UserCenterLayout,组件里有顶部导航、侧边导航、核心内容区等结构;然后 Vue Router 会继续匹配到UserArticles,把它渲染到UserCenterLayout模板里的<router-view />位置。URL 的层级、组件的嵌套、布局的复用,这三者在嵌套路由里是统一的。
反过来想,如果不用嵌套路由,/user/articles和/user/settings就是两个平级路由,各自组件都得包含完整的页头页脚。公共部分一旦修改,所有页面都要跟着改,维护成本非常高。所以嵌套路由解决的不只是"好看",它把公共部分的渲染提升到了父组件,这是复用性和一致性的根本保证。
1.3 嵌套路由对比平铺路由:三个最直观的改善
从 Day10 的实际对比看,换成嵌套路由后有三个方面改善非常明显:
菜单高亮和面包屑有了数据来源。嵌套路由天然产生一条匹配链路,route.matched数组里依次是父路由记录、子路由记录、孙路由记录。面包屑可以直接根据这条链路的meta.title生成,侧边菜单也可以依据当前matched链判断哪个父菜单应该展开、哪个子菜单应该高亮。平铺路由阶段这些逻辑全靠手写判断,现在变成了路由系统自带的信息。
公共状态和动画可以收敛到布局层。前台和后台各自的布局组件,可以在布局里统一处理滚动条恢复、页面进出场动画、keep-alive 缓存策略。子页面只管自己的业务内容,互不干扰。平铺路由时每个页面都要重复做这些事情,很容易出现有的页面处理了、有的页面没处理的零散现象。
权限控制的粒度可以下沉。嵌套层级决定了权限控制的最小单元。在父路由上挂meta: { requiresAuth: true },它的所有子路由都受到这层约束。如果某个子路由想对权限做更细的限制,自己再挂一层meta: { role: 'admin' }就可以。这种层层叠加的模式,比平铺路由里"每个页面单独判断权限"要清晰得多。
2. 嵌套路由的配置要点:从路由表到父组件出口
2.1 基础语法:children 里的 path 千万别带多余的斜杠
嵌套路由的配置语法本身不难,但有一个细节非常容易踩坑:子路由的 path 不要以/开头。
// 正确写法:子路由 path 不带斜杠,是相对父路由的路径 const routes = [ { path: '/admin', component: AdminLayout, children: [ { path: 'dashboard', component: AdminDashboard }, // 实际 URL: /admin/dashboard { path: 'users', component: AdminUsers } // 实际 URL: /admin/users ] } ]// 错误写法:子路由 path 带了斜杠,变成绝对路径 const routes = [ { path: '/admin', children: [ { path: '/dashboard', component: AdminDashboard } // 这个路由和 /admin 没有层级关系 ] } ]一旦子路由的 path 带了/,它就会被当成顶层路由处理。你访问/admin/dashboard会渲染AdminDashboard,但AdminLayout不会被渲染,公共布局直接就不见了,子页面变成了一个"孤儿页面"。这是我 Day10 亲眼见过的错误,一开始还以为是组件写错了,排查了半小时才发现是路径多了个斜杠。这个规则记住就行了:children 里的 path 一律不写斜杠,除非你有意让它成为绝对路径。
2.2 父组件必须留出口:router-view 的位置决定布局结构
嵌套路由配置好了,但如果父组件模板里没有<router-view />,子路由永远渲染不出来,页面就是一片空白。这是嵌套路由最容易犯、也最让人摸不着头脑的问题。
父布局组件的模板结构大致是这样:
<template> <div class="admin-layout"> <aside class="sidebar"> <!-- 侧边菜单,固定不变 --> </aside> <div class="main"> <header class="navbar"> <!-- 顶栏,固定不变 --> </header> <main class="content"> <!-- 这里的 router-view 是子路由的渲染出口 --> <router-view /> </main> </div> </div> </template><router-view />放在哪里,子页面就渲染在哪里。想保留哪块公共区域,就把出口放在那个区域的外面。比如后台布局里,侧边栏和顶栏不想让子页面自己控制,就放在出口外面;内容区是每个页面都不一样的,就对应的把出口放在内容区里。
一个 layout 里其实也可以放多个<router-view />,这就是命名视图(named view)的用武之地。复杂后台布局中,顶部、侧边、内容三块各有不同的渲染目标时,可以给每个出口起一个名字,父路由用components对象一次性声明:
{ path: '/admin', components: { navbar: AdminNavbar, sidebar: AdminSidebar, default: AdminContent // 不写 name 的默认出口 } }不过以我的经验,大多数项目一个默认出口就够了。多出口会让路由配置和组件映射变得隐晦,除非确实有"同一条 URL 下需要同时切换多个独立区块"的需求,否则先用一个出口,简单直接。
2.3 默认子页面怎么写:redirect 与空路径两种方式的取舍
访问/admin的时候,你希望它自动展示哪个子页面?这是嵌套路由里最常见的"默认页"需求。两种写法各有各的适用场景。
第一种,父路由配置redirect,把访问/admin的动作重定向到某个确定的子路由:
{ path: '/admin', component: AdminLayout, redirect: '/admin/dashboard', // 访问 /admin 时自动跳转到 /admin/dashboard children: [ { path: 'dashboard', component: AdminDashboard }, { path: 'users', component: AdminUsers } ] }第二种,给父路由配置一个path: ''的空子路由,让/admin本身直接渲染一个默认子组件:
{ path: '/admin', component: AdminLayout, children: [ { path: '', component: AdminDashboard }, // 访问 /admin 时直接渲染 AdminDashboard { path: 'users', component: AdminUsers } ] }两种方式的差别在于:redirect 是"URL 会变化",访问/admin浏览器地址栏最终变成/admin/dashboard;空路径是"URL 不变",地址栏一直停在/admin,内容却是默认子页面。
实际项目中我更推荐用 redirect。原因是 URL 一旦标准化到/admin/dashboard,菜单高亮、面包屑、分享链接的语义都更清晰。空路径适合那种"父路由本身就是一个有意义的页面,不需要跳转"的场景,比如访问某个详情页根路径时直接展示摘要页。我自己一般只在特殊场景用空路径,默认页统一用 redirect 搞定。
2.4 命名路由和命名视图:嵌套层级深了之后的"安全带"
嵌套层数一多,路径字符串就会变得又长又容易拼错。比如从 /admin/users/detail/123跳回/admin/users,如果手动拼接路径,中间任何一个路由改动都会牵连一片代码。这种场景下,给每条路由加上name,跳转全部走命名路由,会安全很多:
{ path: 'users', name: 'AdminUsers', component: AdminUsers }, { path: 'users/detail/:id', name: 'AdminUserDetail', component: AdminUserDetail }跳转时这样写:
// 推荐:按 name 跳转 router.push({ name: 'AdminUsers' }) router.push({ name: 'AdminUserDetail', params: { id: '123' } })命名路由的价值在嵌套多层后越发明显。它让路由之间的逻辑关系体现在"名字"上,而不是"路径字符串"上。后端改 URL 结构、前端拼写错误、链接路径失效这些风险都能降到最低。嵌套路由的层级一深,字符串拼接跳转基本就是等着踩坑,早点习惯用 name,后面写权限控制时也会顺手很多。
3. 前后台两套布局的落地:路由、目录与页面文件的组织
3.1 为什么前台后台要分成两条顶级路由分支
前后台同处一个项目,最忌讳的是把所有页面放在同一套布局里,靠meta字段控制某块显示或不显示。短期看是省事了,但两种角色的页面越来越多之后,这套布局的模板会堆满各种条件判断,侧边栏、顶栏、页脚的显示逻辑盘根错节,改前台不敢动后台,改后台怕影响前台。
Day10 我采用了拆分方案:前台和后台各占一个顶级路由分支,各自配一套独立的 layout 组件。结构上看起来是这样:
/portal 前台,PortalLayout 包裹 /admin 后台,AdminLayout 包裹 /login 登录页,独立页面,不归属任何布局前台分支里可以继续嵌套用户中心等子结构,后台分支里按模块再往下挂。两条分支互不干扰,前台再改导航、后台再叠菜单,都不会串线。后面做权限控制也轻松:直接在/admin这层父路由上挂requiresAuth,整条后台分支天然受保护,不需要逐个页面配置。
有的项目还会有"用户中心"这类介于前台和后台之间的结构,我的处理是把它当前台下的一个子布局,而不是单独开一条顶级分支。因为它的整体外壳还是前台风格,只是内部有自己的二级导航。这正好是嵌套路由的经典应用:每一层嵌套,本质是在"当前的页面风格"下面再划分一套"局部的公共部分"。
3.2 完整路由表示例:登录页、前台分支、后台分支、404 兜底
下面是我 Day10 最终落地的路由表结构,去掉业务细节,留核心骨架:
const routes = [ { path: '/login', name: 'Login', component: () => import('@/views/login/LoginPage.vue') }, { path: '/portal', component: () => import('@/layout/PortalLayout.vue'), children: [ { path: '', redirect: '/portal/home' }, { path: 'home', name: 'PortalHome', component: () => import('@/views/portal/HomePage.vue') }, { path: 'user', component: () => import('@/views/portal/user/UserCenterLayout.vue'), children: [ { path: '', redirect: '/portal/user/articles' }, { path: 'articles', name: 'PortalUserArticles', component: () => import('@/views/portal/user/MyArticles.vue') }, { path: 'settings', name: 'PortalUserSettings', component: () => import('@/views/portal/user/UserSettings.vue') } ] } ] }, { path: '/admin', component: () => import('@/layout/AdminLayout.vue'), meta: { requiresAuth: true, role: 'admin' }, children: [ { path: '', redirect: '/admin/dashboard' }, { path: 'dashboard', name: 'AdminDashboard', component: () => import('@/views/admin/DashboardPage.vue') }, { path: 'users', name: 'AdminUsers', component: () => import('@/views/admin/user/ManageUsers.vue') }, { path: 'users/detail/:id', name: 'AdminUserDetail', component: () => import('@/views/admin/user/UserDetail.vue') } ] }, { path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/error/NotFoundPage.vue') } ]几个设计点说明一下。
第一,所有页面组件都用动态导入/() => import(...),这样 Vue Router 会把每个页面拆成独立 chunk,首屏只加载登录页和当前分支的代码,后台代码不会打进前台用户的下载包里。
第二,两个分支的父路由都配置了redirect: ''让根路径有默认归属。前台落到/portal/home,后台落到/admin/dashboard,访问不带子路径的根地址时用户不会面对空白页。
第三,404 兜底路由放在最后,用/:pathMatch(.*)*捕获所有未匹配的路径。这里要注意,通配路由必须放最后,否则它会抢先拦截所有合法路由。嵌套路由的层级越深,通配路由的位置就越要谨慎,放在最后是铁律。
3.3 目录结构设计与路由懒加载分包
路由表的组织方式直接反映项目结构。我按页面归属把views目录拆成三大块,路由文件也单独分模块:
src/ ├── layout/ │ ├── PortalLayout.vue # 前台布局 │ └── AdminLayout.vue # 后台布局 ├── router/ │ ├── index.js # createRouter + 路由汇总 │ ├── portalRoutes.js # 前台路由模块 │ └── adminRoutes.js # 后台路由模块 └── views/ ├── login/ ├── portal/ │ └── user/ └── admin/ └── user/路由模块化的核心目的不是少写代码,而是让"哪个页面归哪个模块管"一目了然。我后来动态加权限路由时,就是直接读取adminRoutes.js里导出的数组,筛选出当前角色有权限的项再注册进去,不需要再翻目录找页面。
配套懒加载后,打包产物大致是这样的结构:PortalLayout和前台页面相关代码一个 chunk,AdminLayout和后台业务代码一个 chunk,登录页单独一个 chunk。访问前台时后台代码完全不加载,反过来也一样。嵌套路由一定要配合懒加载使用,否则嵌套层级带来的结构调整都会被"全量打包"抵消掉,白费功夫。
3.4 嵌套路由推荐的跳转方式
嵌套层级多了以后,跳转时最容易出问题的就是相对路径。我见过有人写<router-link to="articles">,想着从/portal/user相对跳到/portal/user/articles,但 Vue Router 对相对路径的解析规则相对容易产生歧义,一旦路径嵌套层级变化,链接就悄悄失效了。
我的建议很明确:嵌套路由跳转一律使用绝对路径或命名路由,不要依赖相对路径。
<!-- 推荐:命名路由 --> <router-link :to="{ name: 'PortalUserArticles' }">我的文章</router-link> <!-- 推荐:绝对路径 --> <router-link to="/portal/user/articles">我的文章</router-link> <!-- 不推荐:相对路径,解析规则容易产生歧义 --> <router-link to="articles">我的文章</router-link>命名路由的好处在于路径怎么改都不影响代码。比如后来我把/admin/users/detail/:id调整成/admin/user/detail/:id,所有引用AdminUserDetail的跳转代码一行都不用动,这种维护成本的优势在嵌套层级变深后会非常明显。
4. 嵌套路由实战中我踩得最深的那几个坑
4.1 页面空白:父组件缺 router-view 的完整排查链路
说到嵌套路由,第一个坑就是页面空白。我 Day10 第一次配嵌套路由时就遇到了:路由表写了、子组件也建了,运行起来访问子路由 URL 正常变化,但页面就是一片空白。
完整的排查思路是这样走的:
先打开浏览器开发者工具看 DOM 结构。发现父布局组件渲染出来了,但内容区是空的。这就说明问题不在路由匹配,而在子组件没有渲染出来。再检查父组件的模板,果然,我只写了侧边栏和顶栏,内容区完全没有<router-view />。子路由匹配到了,但没有出口可以渲染。
如果父组件已经写了<router-view />还空白,就继续检查 keep-alive 的问题。当父组件被<keep-alive>包裹时,出口通常要配合动态组件的方式写:
<router-view v-slot="{ Component }"> <keep-alive> <component :is="Component" /> </keep-alive> </router-view>直接写<keep-alive><router-view /></keep-alive>在 Vue 3 里其实是优先级的问题,不保证符合预期。改写成v-slot+ 动态组件方式后,子路由的渲染就正常了。
排查之后总结一下,这个坑的本质是:路由匹配只解决"组件该不该渲染"的问题,但"渲染到哪里"由父组件的模板结构决定。这两个环节脱节时,页面看起来就是"有 URL 没内容"。
4.2 子路由悄悄变成了顶层路由:path 写法的坑
第二个坑我在第 2 章提过,但 Day10 里它造成的问题更隐蔽。当时后台有个表单页,我想让它和列表页共用一个布局,写配置的时候手一抖,children 里的 path 写成了/form:
{ path: '/admin', component: AdminLayout, children: [ { path: 'users', component: AdminUsers }, { path: '/form', component: AdminForm } // 这里多了斜杠 ] }现象是:访问/admin/form时页面渲染了,但布局完全不对,侧边栏、顶栏全都不见了,只剩一个孤零零的表单在浏览器最左上角。
当时第一反应是布局组件出问题了,调了半天没找到原因。后来我把route.matched打印出来,才看到匹配到的路由记录只有一条,AdminLayout根本不在这条匹配链路里。问题一下子就清楚了:子路由 path 带了斜杠,它被当成了独立的顶层路由,不再嵌套在/admin下面。
记这个教训很简单:children 里的 path,只有两种情况——要么不写斜杠作为相对路径,要么确实想把它变成顶层路由就移出去放到 routes 顶层。两者取一个,不要在 children 里用斜杠开头的路径,十有八九是误操作。
4.3 redirect 把自己绕进死循环的现场
第三个坑是 redirect 配错了。当时我想让访问/admin默认跳到/admin/users,但写的时候不小心写成了重定向到父路由自身:
{ path: '/admin', redirect: '/admin', // 自己重定向自己,死循环 children: [...] }运行时页面直接报错"Maximum call stack size exceeded"或者无限重定向。浏览器地址栏疯狂闪烁,页面一直跳转不停。原因很好理解:访问/admin要跳转到/admin,跳过去之后又要跳转到/admin,永远有个不停的循环。
同类坑还有一种:redirect 写到一个不存在的子路径,比如redirect: '/admin/userxxx',结果跳过去之后匹配不到任何路由,又被 404 兜底捕获,表现是页面空白或者直接跳到 404。所以每次配 redirect 都检查一下目标路径是否真的存在于该分支的 children 里。父子路由之间不要互相 redirect,这是铁律。
4.4 动态添加子路由后嵌套关系失效:addRoute 的挂载点
权限系统里经常要根据用户角色动态添加路由。Day10 做后台权限时,我踩了一个隐蔽的坑:用router.addRoute添加后台子路由时,如果只传路由对象不传父路由的 name,它会被加到顶层去,嵌套关系直接失效。
// 错误:添加到顶层,嵌套关系失效 router.addRoute({ path: 'users', component: () => import('@/views/admin/user/ManageUsers.vue') }) // 正确:第一个参数传父路由的 name,挂到 /admin 分支下 router.addRoute('AdminLayout', { path: 'users', name: 'AdminUsers', component: () => import('@/views/admin/user/ManageUsers.vue') })addRoute的第一个参数就是父路由的 name,不传的话新路由就是顶层路由,和/admin没有任何父子关系,布局、权限、面包屑全部失效。这个参数极易被忽略,因为编译器不检查,运行时不报错,只有等你看到页面布局不对了才会发现。
4.5 keep-alive 和嵌套路由缓存错乱的组合
嵌套路由配合 keep-alive 是个大坑,尤其是缓存颗粒度的问题。后台内容区做了 keep-alive 之后,我只想缓存列表页,让它翻页后回来不丢失状态。但实际运行时发现,连详情页也被缓存了,从用户 A 的详情切到用户 B 的详情,页面内容还是用户 A 的。
问题出在<component :is="Component" />的key设置。如果不加 key,同一路由组件切换时会复用上一个实例,参数变了组件却不重新创建。加上:key="$route.fullPath"后,每个不同路径都会有独立实例,缓存粒度就正常了:
<router-view v-slot="{ Component }"> <keep-alive :include="cachedViews"> <component :is="Component" :key="$route.fullPath" /> </keep-alive> </router-view>但是这里有个取舍:$route.fullPath作为 key,意味着同一个组件、不同 query 参数会创建新实例,这有时候也是我们想要的(比如详情页不同 id 确实是不同内容)。如果某个组件你希望所有参数都复用同一个实例,可以把 key 改为$route.name。用什么 key,取决于业务希望"组件的生命周期跟谁走"。在嵌套路由层级较深时,这个决定最好趁早统一,不然后期每个页面的缓存行为都会不一样,查起来很痛苦。
5. 权限联动下的动态路由:前后台权限控制的整合方案
5.1 哪些路由静态,哪些路由动态
前后台架构搭好后,权限控制是绕不开的。Day10 的思路是把路由分成两类:
静态路由是所有人都能访问的:/login、/portal下的前台页面、404 兜底。这些路由在应用启动时就注册好,不需要任何权限判断。
动态路由是登录后根据角色才能注册的:/admin分支下的所有业务页面。管理员能看用户管理、内容审核;普通运营人员可能只看内容审核,看不到用户管理。这些路由在应用启动时不能注册,必须等登录接口返回角色权限之后,再把当前角色有权限的路由动态添加进去。
静态路由和动态路由分开管理的最大好处是,权限系统的行为是可预测的。没有权限的路由在应用里根本不存在,访问它直接落到 404,比"路由存在但跳转提示无权限"要干净得多。
5.2 beforeEach 全局守卫的完整执行逻辑
权限控制的枢纽是全局前置守卫router.beforeEach。它在每次路由跳转前执行,我在这里完成三件事:登录状态检查、动态路由加载、目标路由放行。
router.beforeEach(async (to, from, next) => { const token = useAuthStore().token // 1. 未登录且非登录页,跳去登录 if (!token && to.path !== '/login') { return next({ path: '/login', query: { redirect: to.fullPath } }) } // 2. 已登录且访问登录页,跳去首页 if (token && to.path === '/login') { return next({ path: '/portal/home' }) } // 3. 已经登录,但动态路由还没加载,先加载再重新进入目标 if (token && !usePermissionStore().routesLoaded) { const accessRoutes = await usePermissionStore().generateRoutes() accessRoutes.forEach(route => router.addRoute(route)) return next({ ...to, replace: true }) } next() })这个逻辑最关键的部分是第三步的return next({ ...to, replace: true })。当时我看很多教程只写到"addRoute 之后直接 next()",结果一运行就是死循环,页面疯狂刷新。根因是:addRoute 是异步生效的,守卫里 addRoute 执行完,当前这次跳转的匹配结果可能还是旧的,to目标路由依然被认为是未匹配的。直接放行的话,守卫下一次触发时依然走"routesLoaded 为 false"的分支,于是无限循环。
加上{ ...to, replace: true }后,守卫会带着当前目标路由的完整信息重新发起一次跳转。这次跳转时动态路由已经注册完成,routesLoaded也已经是 true,走正常分支就通过了。replace: true是为了不让这次"重进"在历史记录里多留一层,用户按后退按钮时不会莫名其妙退回一页。
5.3 动态路由刷新丢失:恢复方案的取舍
动态路由有个绕不开的问题:页面一刷新,内存里的动态路由全部丢失。因为router.addRoute添加的路由只活在当前应用实例的内存里,刷新后应用重启,路由表回到初始的静态状态。此时用户直接访问/admin/users,匹配不到路由就会落进 404。
解决办法有三个层级。最省事的方案是:在守卫里发现"目标是 /admin 下的路径但当前路由表里没有匹配"时,先判断本地有没有存储权限标识,如果有就重新加载动态路由再放行。核心逻辑还是 5.2 那段代码,只是routesLoaded不能只看内存,要结合持久化的状态判断。
比较主流的做法是把角色信息存在本地存储里,刷新后通过角色重新请求权限数据、重新addRoute。接口返回快,用户几乎感知不到刷新过程中路由重新注册的过程。如果你的权限接口较慢,可以在守卫第三步加一个"等待路由加载完成"的状态标记,防止用户刷新后立刻点击页面导致匹配失败。
这个方案我建议直接落地,不要在刷新恢复上做太多花活。权限系统的核心要求是"不泄露、不崩溃、可恢复":不泄露在于路由不能提前注册,不崩溃在于守卫逻辑不能死循环,可恢复在于刷新后权限数据能重新拉起来。
6. 路由的工程化收尾:meta 规划、面包屑、标题与滚动行为
6.1 把 meta 当作路由的"身份证"
嵌套路由层级深了之后,meta字段的重要性会急速上升。它相当于路由的身份证,所有展示层需要的信息都可以提前挂在上面,而不是在组件里临时判断。
我 Day10 给每条路由都规划了统一的 meta 结构:
{ path: 'users', name: 'AdminUsers', component: () => import('@/views/admin/user/ManageUsers.vue'), meta: { title: '用户管理', icon: 'user', requiresAuth: true, role: 'admin' } }title给面包屑和浏览器标签页标题用icon给侧边菜单渲染用requiresAuth给导航守卫判断登录态用role给粒度更细的角色权限用
统一 meta 结构这件事,我当时一开始没做,导致后面面包屑和菜单只能到处硬编码。后来花了半天把所有路由的 meta 补齐,后面所有展示组件都只需要读route.meta就够了。建议你在建路由表的第一天就把 meta 约定好,别等页面多了再补,代价是逐条修改路由记录。
6.2 面包屑和菜单高亮:route.matched 一次搞定
嵌套路由的天然优势是route.matched会按层级返回一组路由记录。比如访问/portal/user/articles,匹配链路是:PortalLayout → UserCenterLayout → UserArticles。只要每条路由都有 meta.title,面包屑就是一次简单的转换:
const breadcrumbs = route.matched .filter(item => item.meta && item.meta.title) .map(item => ({ title: item.meta.title, path: item.path }))如果某层的路由不想出现在面包屑里,就把meta.title留空或者加一个meta.breadcrumb: false标记,生成时过滤掉即可。
菜单高亮同理。侧边菜单根据当前route.matched里是否有某个菜单对应的路由记录来判断展开和高亮状态。比"每个页面手动设置 activeMenu"要优雅得多,而且嵌套层级怎么加深,高亮逻辑都不用改。
这里有个细节要提醒:父布局组件的 path 可能是/admin或/portal,这类布局路由通常没有业务标题,如果不做过滤,面包屑第一级会显示一个空 title 或者奇怪的占位。记得在 meta 里给布局路由设计合适的 title,或者干脆在生成面包屑时跳过没有 title 的中间层。
6.3 动态标题和页面滚动:两个不起眼但影响体验的点
最后说两个小优化,虽然不起眼,但直接影响使用体验。
一个是浏览器标签页标题。嵌套路由切换时,我建议把 document.title 和当前路由的 meta 联动起来,而不是每个页面组件自己写document.title = ...。全局守卫里统一处理:
router.afterEach((to) => { const titles = to.matched.filter(item => item.meta && item.meta.title) const baseTitle = '某内容平台' if (titles.length) { document.title = `${titles[titles.length - 1].meta.title} - ${baseTitle}` } else { document.title = baseTitle } })to.matched最后一项通常是具体的页面路由,取它的 title 最为准确。层级越深,标题就应该是"具体页面 > 父模块 > 平台名"这种由近及远的组合。
另一个是页面滚动行为。嵌套路由默认情况下,切换到新页面时浏览器滚动位置不会自动复位,你会停在"上一个页面的滚动位置"。在创建路由时配置 scrollBehavior:
const router = createRouter({ history: createWebHashHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition // 后退时恢复原位置 } return { top: 0 } // 新页面回到顶部 } })不过注意,如果后台内容区是自定义滚动容器(比如.content区域 overflow: auto),路由的 scrollBehavior 管不到这个容器,需要在布局组件里监听路由变化手动把容器滚回顶部。这个坑我先说在前面:scrollBehavior 只负责浏览器窗口的滚动,自定义滚动容器要自己处理,别指望路由系统一把梭。
收个尾,说说 Day10 之后的体会。路由嵌套这套东西,语法本身不难,难的是把"URL 层级、组件嵌套、布局复用、权限控制"这四个维度在脑子里统一成一张图。只要你脑子里有这张图,后面加页面、加模块、加权限都是一层一层往上叠的事。最后再分享一个我从 Day10 之后一直沿用的习惯:每新建一条路由,第一件事就写 meta 的 title 和 requiresAuth,别等做完页面再补。这个习惯帮我省掉了后面无数个"面包屑缺标题""菜单高亮不对""刷新后路由丢失"的调试时间。路由表是骨架,meta 是骨架上的关节,一开始就把关节接好,整个项目才能立得住。