Vue Router 4 实战进阶:导航守卫、动态路由与权限控制详解

发布时间:2026/10/14 4:19:17
Vue Router 4 实战进阶:导航守卫、动态路由与权限控制详解 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 classadmin-layout AppSidebar / main classadmin-main router-view / /main /div /template这里有几个关键细节都是实际写代码容易翻车的点第一children 里的 path 千万不要以/开头。写了/usersVue 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 nameauthor / router-view / !-- 默认出口对应 default -- router-view namerelated / /div /template注意几个细节配置项是components是复数不是componentdefault对应没有 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 又调用 nextVue 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/2URL 变了但 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 :keyroute.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 :isComponent / /keep-alive /router-viewinclude数组里填的是组件的name 选项不是路由的 name这一点坑了无数人。比如路由配置里写了name: UserList组件内部却忘了定义对应的 nameVue 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 的地方统一 catchconst handleJump async () { try { await router.push(/user/list) } catch { // 忽略重复导航 } }同时从源头减少重复触发跳转按钮加 disabled 状态防止快速连点。有些人会试图在 beforeEach 里判断from.fullPath to.fullPath然后 return false 来拦截但治标不治本真正的做法还是避免发起无意义的导航。说实话路由这块写代码只是基本功真正的功力在于把权限、缓存、懒加载这些横切逻辑组织得井井有条。我这几年养成的习惯是把路由相关的一切独立成模块路由表放一个文件、守卫逻辑放一个文件、动态路由工具函数放一个文件页面组件只关心自己的业务完全感知不到路由策略。如果这些逻辑散落在各个页面里排查问题时就是一锅粥。最后再分享一个实用小技巧开发模式下在浏览器控制台执行router.getRoutes()能直接看当前应用完整的路由表动态路由有没有注册成功、404 在不在最后一眼就能确认比反复刷新页面猜原因高效得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询