Vue Router 这个系列,我本来没打算写第二篇。基础版无非就是 createRouter、routes 数组、router-view 和 router-link,这些内容社区里一搜一大堆。但上个月帮某公司排查一个“刷新后白屏”的问题,追到后半夜,发现根因是动态路由、404 匹配顺序和导航守卫的时序搅在一起了。这种问题基础教程里根本不会提,可它就是真实项目中最高发的一类故障。所以我把进阶部分的经验整理出来,接着上一篇往下写。默认你已经能用 Vue Router 跑通基本配置,这篇重点讲嵌套路由、命名视图、路由元信息、导航守卫、动态路由与懒加载,以及几个踩过才会懂的坑。内容适配 Vue 3 + Vue Router 4,如果你是从 Vue 2 + Vue Router 3 切过来的,API 差异我也会顺手标出来。
1. 嵌套路由与命名视图:布局应该由路由表来扛
1.1 嵌套路由:把页面拆成“外壳 + 内容区”
真实项目里几乎没有纯扁平页面。拿中后台管理界面来说,通常头顶一个导航栏、左边一个菜单栏、右边一块内容区域。如果每个页面都把这三块从头写一遍,改一次导航栏要动几十个文件,这不是工程化该有的写法。
嵌套路由的思路就是让路由结构跟页面结构一一对应:父路由对应一个布局组件,布局组件里只放一个<router-view />作为内容出口,子路由负责决定这个出口里渲染什么。
const routes = [ { path: '/admin', component: () => import('@/layouts/AdminLayout.vue'), children: [ { path: '', name: 'AdminHome', component: () => import('@/views/admin/Home.vue') }, { path: 'users', name: 'AdminUsers', component: () => import('@/views/admin/Users.vue') }, { path: 'roles', name: 'AdminRoles', component: () => import('@/views/admin/Roles.vue') } ] } ]布局组件模板里只需要保证有一个<router-view />:
<template> <div class="admin-layout"> <AppSidebar /> <main class="admin-main"> <router-view /> </main> </div> </template>这里有几个关键细节,都是实际写代码容易翻车的点:
第一,children 里的 path 千万不要以/开头。写了/users,Vue Router 会把它当成根路径来解析,最终匹配不到/admin/users,页面直接空白,而且控制台不报错,排查起来非常别扭。正确的写法是去掉前导斜杠,写'users'。
第二,空字符串{ path: '' }表示父路径本身也匹配一个页面。比如访问/admin时,内容区会渲染 AdminHome。这个用法很推荐,因为用户直接输入/admin时不会出现“有壳没内容”的尴尬。
第三,理解“层层铺”的匹配机制。URL 为/admin/users时,Vue Router 从根路由开始逐层匹配:先命中/admin对应的布局组件,再往下命中users子路由,子路由渲染在父组件里的<router-view />中。这个从外到内的铺层关系是嵌套路由的灵魂,后面排查各种路由不到位的 Bug 时,大概率要回到这个模型里找原因。
嵌套路由的核心价值在于“布局组件只存在一份”。用户从用户管理切到角色管理,变化的是内容区,导航栏和侧边栏完全不会重新渲染,切换体验和性能都更好。我见过一个反面案例:某开发者把每个子页面都当成顶层路由写,每个页面组件里手动引入布局,结果切菜单时整个布局连同侧边栏全部重新创建,接口重复请求、页面闪烁,排查半天才意识到是路由结构设计的问题。
1.2 命名视图:解决“同屏多出口”的另一种思路
嵌套路由适合“父子包含”的结构,但有些页面是“同级并列”的:一个 URL 下,页面被切成三块相互独立、都想跟着 URL 同步变化的区域。比如门户网站的文章详情页,中间是正文,左侧是作者卡片,右侧是相关推荐。如果这三块都塞进同一个组件里,组件会非常臃肿,而且各自的更新逻辑全混在一起。
命名视图就是干这个的。它允许同一个路由对应多个组件,分到不同名字的出口上:
const routes = [ { path: '/detail/:id', components: { default: () => import('@/views/detail/Main.vue'), author: () => import('@/views/detail/Author.vue'), related: () => import('@/views/detail/Related.vue') } } ]模板里的出口这样写:
<template> <div> <router-view name="author" /> <router-view /> <!-- 默认出口,对应 default --> <router-view name="related" /> </div> </template>注意几个细节:配置项是components是复数,不是component;default对应没有 name 属性的<router-view />;其他命名出口必须用 name 属性显式指定。访问/detail/1时,三个组件会同时渲染,等于用一份路由配置管理了三个并列区域的同步更新。
命名视图和嵌套路由并不互斥,反而是搭档关系。常见做法是:外层用嵌套路由搭好页面骨架,某个具体页面内部再用命名视图拆分并列模块。我个人的习惯是,凡是超过三个并列区块且后续有独立复用的需求,就优先考虑命名视图,否则老老实实拆子组件、用 props 传数据,反而更简单。
1.3 嵌套还是命名视图?我的选择标准
两种方案各有适用场景,这里分享一个选择标准,也是我踩过几次坑后总结出来的:
嵌套路由适合“父组件作为容器、子路由作为内容”的结构,控制维度是路由层级;命名视图适合“同一 URL 下多个并列兄弟块”的结构,控制维度是出口名称。还有一个实际差别会影响取舍:命名视图切换时,所有出口对应的组件一起销毁重建,想单独对其中某块做 keep-alive 缓存会很别扭,而嵌套路由可以精细化控制到某个子路由对应的组件。
| 对比维度 | 嵌套路由 | 命名视图 |
|---|---|---|
| 结构关系 | 父子包含 | 同级并列 |
| 控制方式 | 路由层级 | 视图名称 |
| URL 表达 | 多个 URL 对应同一布局 | 一个 URL 对应多个组件 |
| 缓存控制 | 精细到子路由 | 多出口一起更新 |
| 典型场景 | 后台管理、Tab 页 | 门户首页、详情页 |
拿后台项目来说,绝大多数情况用嵌套路由就够了;命名视图更适合偏展示类的页面。两个都掌握不亏,但别为了炫技硬上。
2. 路由元信息与导航守卫:把权限判断收敛到一处
2.1 meta 字段:给路由挂“标签”
很多初学者不知道 meta 的定位。它不是什么魔法功能,本质上就是路由表里的一个自定义字段,允许你往任意路由上挂数据。最常见的用途是页面标题、权限标识、面包屑文案、是否需要缓存。先记住一点:meta 可以在访问路由时通过to.meta读取,这是后面所有守卫逻辑的基础。
{ path: '/user/list', name: 'UserList', meta: { title: '用户列表', requiresAuth: true, roles: ['admin', 'operator'] }, component: () => import('@/views/user/List.vue') }标题这个需求最容易尝到 meta 的甜头。没有 meta 的时候,每个页面组件里都要写一段document.title = '某某页',重复劳动且容易漏。有了 meta,只要在导航守卫的 afterEach 里统一处理一次:
router.afterEach((to) => { document.title = to.meta.title ? `${to.meta.title} - 管理后台` : '管理后台' })后续新增页面只需要在路由表里补一个 title,不需要再碰页面组件。这就是“收敛”带来的好处:同类逻辑集中在一处,改动成本低,排查成本更低。
2.2 全局守卫完整链路:beforeEach 到 afterEach 的顺序
导航守卫是 Vue Router 里最容易被“大概会用但说不清顺序”的功能。守卫运行的完整顺序是:全局前置守卫 beforeEach -> 路由独享守卫 beforeEnter -> 组件内守卫 beforeRouteEnter -> 全局解析守卫 beforeResolve -> 正式跳转 -> 全局后置钩子 afterEach。这个顺序必须背下来,因为调试权限问题的时候,第一步就是判断当前卡在哪一个环节。
以登录校验为例,最典型的需求是:未登录用户访问需要登录的页面,直接弹回登录页,并且登录成功后回到原目标页。用 Vue Router 4 的新语法写,不需要 next 参数,直接 return 结果即可:
import { useAuthStore } from '@/stores/auth' router.beforeEach((to) => { const authStore = useAuthStore() if (to.meta.requiresAuth && !authStore.isLogin) { return { path: '/login', query: { redirect: to.fullPath } } } if (to.meta.roles && to.meta.roles.length > 0 && !to.meta.roles.includes(authStore.role)) { return { path: '/403' } } return true })这里有三个重点值得展开说。
第一个重点是 return 与 next 不可混用。Vue Router 3 时代大家都写next('/login'),Vue Router 4 推荐return字符串或路径对象,如果你在同一个守卫里既 return 又调用 next,Vue Router 会给出警告,实测还可能导致导航被调用两次,表现成重复跳转。建议新项目统一用 return 风格,看老代码时再把 next 心智切回来。
第二个重点是 return 的值代表什么。返回true表示放行;返回false表示取消这次导航,URL 不会变化;返回一个路径对象表示重定向到新地址。很多人对返回值语义不清,导致权限拦截后用户原地不动,误以为系统卡死。单看返回值语义不值钱,但和下面要讲的动态路由联动时,理解就值钱了。
第三个重点是访问 Pinia 的时序。在 Vue 3 项目中,如果你在守卫里用useAuthStore(),需要保证守卫执行时 Pinia 已经安装。实践中守卫生效在应用启动时,而 Pinia 的app.use(pinia)一般在router使用之前或之后,注意初始化顺序即可。最常见的写法是:main.js 里先const pinia = createPinia(),再app.use(pinia),最后app.use(router),这样守卫里就能安全使用 store。
2.3 组件内守卫:beforeRouteEnter / beforeRouteUpdate / beforeRouteLeave
全局守卫负责大面上的拦截,但有些场景必须在组件内部感知路由变化。这三个组件内守卫各有各的用途,而且容易混淆。
beforeRouteEnter在进入组件前触发。注意此时组件实例还没创建,所以拿不到this。想操作组件实例,只能通过回调拿到vm再执行。它的典型用途是:进入页面时,根据 URL 参数从接口拉数据,把拉取动作放在“进入之前”可以做加载态管理。
beforeRouteUpdate的价值在于解决同一个组件被复用的问题。比如详情页/detail/1跳到/detail/2,URL 变了,但 Vue 为了性能会复用 Detail 组件实例,created和mounted都不会重新执行。此时数据要更新就必须靠这个守卫,或者监听$route。我在第 4 章还会专门展开这个问题。
beforeRouteLeave是“防离开”利器。最典型的场景是表单页面,用户填了一半点保存之外的按钮想跳走,你需要拦一下:
beforeRouteLeave(to, from, next) { if (formState.isDirty && !window.confirm('当前内容尚未保存,确定离开吗?')) { return false } next() }注意这里我没法全用 return 风格,因为 Vue Router 4 的beforeRouteLeave如果想在内部做异步确认,还是要用 next 配合。这也是老项目迁移时最常见的坑:文档上说要抛弃 next,但组件内守卫这个 API 依然保留完整形式。我的建议是:组件内守卫统一走 next 风格,全局守卫统一走 return 风格,两边分开记反而不会乱。
组件内守卫和全局守卫配合的场景很典型:全局守卫管“能不能进”,组件内守卫管“进来了之后怎么处理”。比如某个页面需要根据 URL 上的 id 去请求详情,全局前置守卫只做登录校验,beforeRouteEnter里再去发请求拉数据。职责分离后,权限逻辑集中在路由层,业务逻辑留在组件层,代码结构会清爽很多。
3. 懒加载、动态路由与 404:上线前必须处理的三个点
3.1 路由级代码分割:首屏的免费优化
很多人忽略路由懒加载,直到真机上首页白屏几秒被用户吐槽才回头看。Vue 应用打包时,默认情况下所有页面组件会被打进同一个 JS 文件,首屏不得不把这个大文件整个下载完才能渲染。路由懒加载的思路是:模块按需引入,访问到哪个页面,才去下载哪个页面的 JS 文件。
写法非常简单:
// 原来 import UserList from '@/views/UserList.vue' { path: '/user/list', component: UserList } // 懒加载写法 { path: '/user/list', component: () => import('@/views/UserList.vue') }() => import()这个动态导入写法是 ES 原生能力,打包工具(Vite 和 Vue CLI 底层都支持)遇到它就会自动把 import 的内容拆成独立的 chunk。可以通过注释给 chunk 起一个有意义的名字:
component: () => import(/* webpackChunkName: "user-list" */ '@/views/UserList.vue')Vite 下同样支持这段注释,拆出来的文件名会包含user-list,浏览器开发者工具里排查异步请求也更直观。
不过懒加载有个度的问题。也不是拆得越碎越好,如果每个页面拆成一个小文件,首屏确实小了,但用户每跳转一次页面就要多发几个请求,请求数暴涨反而拖慢体验。我的实践标准是:按页面路由维度分割是最合理粒度,公共代码(比如第 3 方 UI 组件库)用单独的 manual chunks 或 Vite 的 build.rollupOptions.output.manualChunks 配置统一拆包,别让公共库跟着每个路由重复进包。懒加载之后,首次跳转新页面会有短暂的白屏等待,建议配一个全局的顶部进度条组件,视觉上给用户一个“正在加载”的反馈,体验会好很多。
3.2 动态权限路由:addRoute 的正确姿势
后台管理系统的常见需求:不同角色登录后,菜单不一样,直接输入 URL 访问无权限页面会被拦截。如果路由表全部静态写死,靠守卫里判断角色来拦截,就会出现一个问题:路由表里包含了所有页面的路径,前端用户虽然跳转不了,但仔细看打包代码能发现未授权页面的存在,而且每次进入首页都要把全量路由配置过一遍。行业里更常见的做法是动态路由:后端起返回当前角色可见的路由表,前端按响应结果动态注册进去。
经典的动态路由流程分为三步。第一步,用户登录,前端保存 token;第二步,在全局守卫 beforeEach 里判断:如果已登录但权限路由还没加载,就去请求后端接口拿菜单权限;第三步,根据权限过滤出可见路由,循环调用router.addRoute()注册,然后重新发起当前导航。
核心代码骨架:
const allRoutes = [ { path: '/user', name: 'User', component: () => import('@/layouts/AdminLayout.vue'), children: [ { path: 'list', name: 'UserList', component: () => import('@/views/user/List.vue') }, { path: 'detail/:id', name: 'UserDetail', component: () => import('@/views/user/Detail.vue') } ] }, // ... 其他页面 ] router.beforeEach(async (to) => { const authStore = useAuthStore() if (!authStore.isLogin && to.meta.requiresAuth) { return { path: '/login', query: { redirect: to.fullPath } } } if (authStore.isLogin && !authStore.routesLoaded) { const permissions = await authStore.fetchPermissions() const accessibleRoutes = filterRoutes(allRoutes, permissions) accessibleRoutes.forEach((route) => { router.addRoute(route) }) authStore.routesLoaded = true return { ...to, replace: true } } return true })这段代码里最关键的一行是最后的重置导航:动态路由添加完成后,不能继续当前这个导航流程,因为当前的to是在路由表注册之前解析的,此时组件可能已经判定为“找不到”。正确姿势是 return 一个新的导航目标:展开当前地址并设置replace: true,让 Vue Router 重新走一遍完整的匹配、守卫流程。我见过很多新手在这里让页面白屏或一直卡在空白页,原因就是 addRoute 之后没有触发重新导航。
另一个高频坑是刷新页面后动态路由丢失。Vue Router 的路由表存在内存里,刷新浏览器就清空了。所以刷新时必须重新拉取权限数据、重新 addRoute。上面的代码用routesLoaded标记已经覆盖了这个场景:刷新后标记变成 false,下一次导航会重新走加载逻辑。注意这里权限数据最好也持久化到本地存储,避免刷新时多等一次接口。
重复添加同名路由的问题也容易踩。同一个路由addRoute调两次,如果 name 不同但 path 相同,路由表里会出现两个匹配项,先注册的先匹配,行为不可控。所以每次 addRoute 之前先用router.hasRoute(name)判断一下,或者统一用固定后缀命名,避免混淆。
3.3 通配符与 404:匹配顺序的“一刀”
Vue Router 4 中,404 页面不能再用 Vue Router 3 时代的*通配符写法了,新语法是/ :pathMatch(.*)*:
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: () => import('@/views/NotFound.vue') }pathMatch(.*)会捕获路径中剩余的所有字符,配合*表示零个或多个。用通俗的话讲,它就是一个兜底路由:前面所有路由都没匹配到,就轮到它。这个兜底路由必须放在路由表的最末尾,因为 Vue Router 的匹配规则是按下标顺序执行的,先匹配到谁就用谁。如果把 404 放在/user/:id前面,那所有/user/xxx都会先命中 404,正常的页面反而进不去了。
在动态路由场景下,404 和 addRoute 的配合顺序特别容易出问题。如果你把 404 写在静态路由表里,而业务页面是动态注册的,刷新时动态路由尚未加载完毕,当前 URL 可能先匹配到静态的 404 路由,导致用户明明有权限也看到“页面不存在”。治本的办法是把 404 也放入动态路由,在所有动态业务路由注册完成后再 addRoute 进去。这样保证了动态路由比 404 先注册,兜底逻辑永远在最后。
4. 高频坑位实录:切换不刷新、缓存、滚动与重复跳转
4.1 同一个组件被复用:数据不更新的三种解法
这是路由场景里最常被新手问到的问题:从/detail/1切到/detail/2,页面数据没变,还是文章 1 的内容。原因前面提到过:Vue 检测到两个路由用的是同一个组件,出于性能考虑直接复用了组件实例,created、mounted这些生命周期都不会重新执行。
解法有三种,按推荐度排序。第一种是在组件里监听路由的变化:
import { useRoute } from 'vue-router' const route = useRoute() watch(() => route.params.id, async (newId) => { await fetchDetail(newId) })这里专门监听route.params.id,变化了就去拉新数据,比监听整个$route对象更精准,还能避免其他不相关参数变化引起无谓请求。
第二种是使用beforeRouteUpdate守卫。这个守卫只会在组件被复用时触发,恰好对应当前场景:
beforeRouteUpdate(to, from) { this.fetchDetail(to.params.id) }第三种是给<router-view />加一个:key,强制组件销毁重建:
<router-view :key="route.fullPath" />这个写法很粗暴:URL 一变就重建组件,生命周期重新走一遍。它适合组件本身没有复杂状态、重建成本低的场景。但要注意,如果组件内部有大量数据需要初始化,重建反而比更新更慢,而且会丢失组件内部状态。我一般只在“页面内完全由路由参数驱动、没有内部交互状态”的场景才用 key 重建方案。
4.2 keep-alive 按需缓存:include 和组件 name 的关系
列表页翻到第 3 页,然后跳到详情页再返回列表页,这时候用户期望列表还停在原来的位置,而不是回到第 1 页重新加载。解决这个问题要用 keep-alive 缓存路由组件。
Vue 3 + Vue Router 4 的推荐写法:
<router-view v-slot="{ Component }"> <keep-alive :include="['UserList', 'RoleList']"> <component :is="Component" /> </keep-alive> </router-view>include数组里填的是组件的name 选项,不是路由的 name,这一点坑了无数人。比如路由配置里写了name: 'UserList',组件内部却忘了定义对应的 name(Vue 3 的<script setup>写法默认不会自动设置组件名),结果 keep-alive 永远不生效,页面每次返回还是重新加载。解决方法是显式声明组件名:
<script setup> defineOptions({ name: 'UserList' }) // ... 业务逻辑 </script>或者用传统的不带 setup 的 script 块声明也行。
缓存带来另一个需要注意的问题:被 keep-alive 缓存的组件,再次进入时不会重新执行 created、mounted,而是触发onActivated钩子。这意味着,缓存列表页的同时,通常需要在onActivated里做数据刷新或状态同步。我第一次做这个功能时,列表始终不刷新最新数据,排查半天才发现是缓存生效后生命周期变了,刷新逻辑应该挂在 onActivated 上而不是 mounted 上。这个细节文档里写得轻描淡写,实践中能卡住一大片人。
4.3 scrollBehavior 与重复导航报错
路由切换后滚动条位置自动回到顶部,这个体验细节很影响观感。Vue Router 提供了 scrollBehavior 配置:
const router = createRouter({ history: createWebHistory(), routes, scrollBehavior(to, from, savedPosition) { if (savedPosition) { return savedPosition } return { top: 0 } } })savedPosition在用户使用浏览器前进后退按钮时会被自动记录传进来,此时返回它就能恢复原来的滚动位置;如果是通过点击链接正常跳转,savedPosition 为空,返回{ top: 0 }让新页面从顶部开始。如果你的页面切换带了过渡动画,滚动恢复要等 DOM 渲染完成后再执行,否则会因为动画还没结束导致位置错乱,这种情况可以用 nextTick 包一层,或者把 return 改为一个延迟执行的回调。
另一个高频困扰是重复导航报错。在 Vue Router 4 里,连续点击同一个按钮或者路由渲染异常时,控制台会抛Uncaught (in promise) NavigationDuplicated或者类似的重复导航警告。这个报错本身不对业务造成崩溃,但会污染控制台,而且说明你的代码里存在大量重复且无效的导航请求。根治方法是在调用 router.push 的地方统一 catch:
const handleJump = async () => { try { await router.push('/user/list') } catch { // 忽略重复导航 } }同时从源头减少重复触发:跳转按钮加 disabled 状态,防止快速连点。有些人会试图在 beforeEach 里判断from.fullPath === to.fullPath然后 return false 来拦截,但治标不治本,真正的做法还是避免发起无意义的导航。
说实话,路由这块写代码只是基本功,真正的功力在于把权限、缓存、懒加载这些横切逻辑组织得井井有条。我这几年养成的习惯是把路由相关的一切独立成模块,路由表放一个文件、守卫逻辑放一个文件、动态路由工具函数放一个文件,页面组件只关心自己的业务,完全感知不到路由策略。如果这些逻辑散落在各个页面里,排查问题时就是一锅粥。最后再分享一个实用小技巧:开发模式下在浏览器控制台执行router.getRoutes()能直接看当前应用完整的路由表,动态路由有没有注册成功、404 在不在最后,一眼就能确认,比反复刷新页面猜原因高效得多。