Vue Router 导航守卫实战:一个菜单权限需求,把登录拦截和动态路由折腾明白了
权限需求最容易被低估成“把菜单过滤一下”。真落到后台项目里,它动的通常不是一份菜单配置,而是整套路由守卫、动态路由注入、刷新恢复、退出清理和账号切换逻辑。
这次角色权限改造就很典型。表面目标是“谁能看哪些菜单”,实际上要解决的是:没权限的页面能不能直接进、刷新后动态路由还在不在、换账号后上一个人的权限残留会不会漏出来。只要这些问题没一起想清楚,路由系统就会开始出现白屏、死循环和脏状态。
后面会从这个需求切进去,把 beforeEach、动态路由、白名单和权限恢复这几块真正会踩坑的地方按项目里的顺序讲清楚。
第一步:先把已有的登录拦截捋顺
项目里原本就有一段最简版的登录拦截:
1router.beforeEach((to, from, next) => { 2 const token = localStorage.getItem('token') 3 4 if (token) { 5 next() 6 } else { 7 next('/login') 8 } 9})
这版有个经典问题:登录页本身没放行。没 token 时访问 /login 也会被 next('/login'),又触发一次守卫,死循环。之前项目里是在别处打了补丁绕过去的,这次借着改权限,我把它重写成白名单结构:
1const whiteList = ['/login'] 2 3router.beforeEach((to, from, next) => { 4 const token = localStorage.getItem('token') 5 6 if (token) { 7 if (to.path === '/login') { 8 next('/') 9 } else { 10 next() 11 } 12 return 13 } 14 15 if (whiteList.includes(to.path)) { 16 next() 17 } else { 18 next(`/login?redirect=${to.fullPath}`) 19 } 20})
这里两个细节是我之前吃过亏才有的执念。一个是已登录还访问 /login 要直接踢回首页,否则用户登录后按浏览器后退又回到登录页,体验很怪。另一个是 redirect=${to.fullPath}——运营同学经常从群里的链接直达某个活动编辑页,被拦到登录页后,登录成功必须跳回原本想去的地方,而不是甩回首页。登录页那边配合着读这个参数:
1// 登录成功后 2const redirect = this.$route.query.redirect || '/' 3this.$router.replace(redirect)
白名单也别只放 /login。我们项目里还有 /forgot-password、给商家分享的只读数据页、第三方回调页(OAuth、支付回调)都要放行。我把白名单抽成了单独的文件维护,免得散落在守卫里越加越乱。
第一个坑:next 调了两次
改守卫的第一天我就制造了一个 bug。某个分支里写成了这样:
1if (token) { 2 next() 3} 4 5next('/login')
next() 先放行了,紧接着又 next('/login') 想拦截。Vue Router 实际只认第一次有效调用,后面的会被忽略,但控制台会出现很迷惑的导航行为,我盯着「怎么有时候跳有时候不跳」看了半天。
从那以后我给自己立了条规矩:守卫里要么 next() 放行,要么 next(地址) 重定向,调用完立刻 return,绝不让一次执行里出现两条都可能跑到的 next。
还有一个相邻的坑是异步守卫里忘了 await:
1router.beforeEach((to, from, next) => { 2 store.dispatch('user/fetchProfile') // 没 await 3 next() // profile 还没回来就放行了 4})
页面进去了,但用到 profile 的地方拿到的是空,于是产出一堆「偶现的」空指针。要么把守卫写成 async 再 await,要么把 next() 放进 .then 里。这个坑我是在测试环境网速慢的时候撞见的——本地接口太快,永远复现不了。
第二个坑:刷新之后用户信息没了
权限要按角色过滤,角色信息在 Vuex 里。问题来了:页面一刷新,Vuex 状态全丢,但 localStorage 里的 token 还在。这时守卫觉得「已登录」直接放行,可 store 里没有角色,权限判断全部失效。
解法是在守卫里补拉用户信息:
1router.beforeEach(async (to, from, next) => { 2 const token = localStorage.getItem('token') 3 4 if (!token) { 5 next('/login') 6 return 7 } 8 9 if (!store.state.user.profile) { 10 try { 11 await store.dispatch('user/fetchProfile') 12 } catch (error) { 13 next('/login') 14 return 15 } 16 } 17 18 next() 19})
写到 catch 分支时我又踩进一个很隐蔽的死循环:fetchProfile 失败(token 过期、用户被禁用、接口异常都可能)时直接 next('/login'),而这时 token 还在本地——下次进守卫又判定「已登录」,又去拉 profile,又失败,又跳……浏览器直接卡死。测试环境把我的 token 手动改成过期值,一刷新页面风扇就开始转。
修法是失败时先把本地 token 清掉再跳:
1} catch (error) { 2 await store.dispatch('user/resetToken') // 清 token 和用户态 3 next(`/login?redirect=${to.fullPath}`) 4 return 5}
token 清掉后,下次进守卫 !token 直接命中,自然跳出循环。守卫里的每个失败分支,都要问自己一句:这个分支会不会把导航引回守卫自己。
另外要注意 fetchProfile 的并发。首屏如果有多个异步组件几乎同时触发导航,守卫可能并发拉好几次 profile。我在 store 里缓存了一个 pending 的 Promise 做去重:第一次调用发请求,后续调用直接等同一个 Promise,避免重复打接口。
权限标记:meta 上做文章
角色信息有了,接下来是「哪个路由需要什么角色」。我用路由 meta 来标:
1{ 2 path: '/product', 3 component: ProductPage, 4 meta: { 5 title: '商品管理', 6 roles: ['admin', 'operator'] 7 } 8}
判断函数很简单:
1function hasPermission(route, roles) { 2 if (!route.meta || !route.meta.roles) return true 3 4 return route.meta.roles.some(role => roles.includes(role)) 5}
没配 roles 的路由视为公共页面,人人可进;配了的,用户角色和路由要求有交集才放行,没权限就:
1next('/403')
评审这段代码时我特意跟后端同事对了一次:前端权限只负责体验,接口必须由服务端再校验一遍。前端同学很容易把「菜单藏起来、按钮 v-if 掉」当成做了权限,实际上用户打开 DevTools 绕过 v-if,或者直接构造请求,照样能打到后端。前端权限的意义是「不该看到的别让用户看到」,真正的拦截在服务端——这次的需求背景不就是客服打到了不该打的接口吗,后端那边也同步加了接口级的角色校验。
最难的一步:动态路由
菜单按角色过滤,最干净的做法是路由表本身就按角色生成——菜单直接从路由表渲染,路由表里没有的页面,菜单上自然没有,URL 直敲也进不去。流程是:
- 登录拿 token
- 拉用户信息和角色
- 按角色过滤路由表
router.addRoutes挂上去- 进入目标页面
过滤逻辑是递归遍历,按 meta.roles 跟用户角色比对,过不了的连同子路由一起剔除:
1function filterRoutes(routes, roles) { 2 return routes 3 .filter(route => hasPermission(route, roles)) 4 .map(route => { 5 if (route.children) { 6 return { ...route, children: filterRoutes(route.children, roles) } 7 } 8 return route 9 }) 10}
第一版我只在登录成功的回调里 addRoutes 了一次,本地点着一切正常,提测当天 QA 就报了「刷新白屏」。原因想通之后很简单:刷新后路由表回到初始状态,动态路由还没加回来,用户停在 /product/list 一刷新,to 匹配不到任何路由,白屏。这是后台系统「刷新白屏」最常见的根因。
正确的做法是把「生成动态路由」也放进守卫,用一个标记判断是否已生成:
1router.beforeEach(async (to, from, next) => { 2 const token = localStorage.getItem('token') 3 4 if (!token) { 5 next(whiteList.includes(to.path) ? undefined : '/login') 6 return 7 } 8 9 // 已生成过动态路由,直接放行 10 if (store.state.permission.routesReady) { 11 next() 12 return 13 } 14 15 try { 16 await store.dispatch('user/fetchProfile') 17 const accessRoutes = await store.dispatch('permission/generateRoutes', store.state.user.roles) 18 router.addRoutes(accessRoutes) 19 20 // 关键:用 replace + to 重新进入,确保 addRoutes 生效 21 next({ ...to, replace: true }) 22 } catch (error) { 23 await store.dispatch('user/resetToken') 24 next(`/login?redirect=${to.fullPath}`) 25 } 26})
最后那行 next({ ...to, replace: true }) 是精髓,也是坑了我整整一下午的地方。addRoutes 之后,当前这次导航用的路由匹配结果还是旧的,必须重新发起一次导航,新加的路由才会被匹配上。我一开始写的是 next(),动态路由明明加上了,页面就是进不去,打断点看 to.matched 是空的才恍然大悟。replace: true 是避免在 history 里多压一条记录,用户按后退不会退到那次「空导航」。
收尾的坑:退出登录和 404
自测时我干了件事:先用管理员账号登录,退出,再用客服账号登录——好家伙,客服的菜单里还挂着「商品管理」。因为 addRoutes 只加不减,Vue Router 2/3 没有官方的 removeRoutes,上一个用户的动态路由还残留在路由表里。换个低权限账号登录,高权限路由还在,这本身就是权限漏洞。
常见解法是把 matcher 整个换回初始状态:
1import Router from 'vue-router' 2 3const createRouter = () => new Router({ routes: constantRoutes }) 4const router = createRouter() 5 6export function resetRouter() { 7 router.matcher = createRouter().matcher 8}
退出登录时清 token、清 store,再调 resetRouter(),路由表回到只有静态路由的干净状态。
404 也有讲究。动态路由场景下,通配的 { path: '*', component: NotFound } 必须在 addRoutes 的时候最后追加,不能跟静态路由一起注册。否则动态路由还没加进来时,所有未匹配的地址都先撞上通配规则跑去 404,刷新页面会闪一下 404 再跳回来,很难看。我就是先在静态表里放了通配符,看到那一闪的 404 才把它挪走的。
错误分流:别什么错都跳登录
联调阶段还暴露一个问题:进入页面失败的原因其实有好几种——没登录、没权限、路由不存在、用户信息接口挂了——第一版全都跳登录页,客服没权限访问商品页也被踢去重新登录,被产品当场抓住吐槽。
后来分流成:401 跳登录,403 去无权限页,404 去不存在页,接口 500 提示服务异常。而且 401 我没在守卫里判断,是收口在 axios 响应拦截器里——401 拦截器里直接清 token、跳登录;守卫只管「有没有 token、有没有权限」这两件事。职责分开之后,排查问题快了很多:登录态问题看拦截器,权限问题看守卫,互不掺和。
配合 afterEach 把体验补齐
功能全通了之后,我用 afterEach 补了两个体验细节。它不接收 next、没法拦截,专门适合「进来之后做点什么」:
1router.afterEach((to) => { 2 document.title = to.meta.title ? `${to.meta.title} - 商城管理后台` : '商城管理后台' 3 NProgress.done() 4})
页面标题从 meta.title 取——正好和菜单名共用一份配置。进度条的 NProgress.start() 放在 beforeEach 开头,done() 放 afterEach,切页面时顶部有个加载条,守卫里拉 profile、生成路由那几百毫秒用户不会觉得页面死了。
还有个和权限配套的延伸:菜单收掉了,页面里的按钮也得管。我写了个简单的自定义指令 v-permission,按角色决定按钮渲不渲染:
1Vue.directive('permission', { 2 inserted(el, binding) { 3 const roles = store.state.user.roles || [] 4 const need = binding.value || [] 5 const ok = need.some(function (role) { return roles.includes(role) }) 6 if (!ok && el.parentNode) { 7 el.parentNode.removeChild(el) 8 } 9 } 10})
1<el-button v-permission="['admin']" @click="handleDelete">删除</el-button>
路由级管「能不能进这个页」,指令级管「进来之后能点什么」,两层配合,前端的体验型权限才算完整——当然,还是那句话,服务端校验一个都不能少。
上线之后
需求上线那周我盯着反馈群,没有白屏、没有死循环,客服账号登录后菜单干干净净只剩订单和售后,产品很满意。我自己更满意的是借这个需求把守卫这块彻底理顺了:守卫不是简单判断 token,它承担登录拦截、用户信息恢复、权限路由、错误分流这一整套职责。
白名单、next 只调一次、失败分支先清 token、addRoutes 后重新导航、退出重置 matcher、通配 404 放最后——这几条规矩每一条背后都是一个真实踩过的坑。一位同事问我怎么记得住这么多细节,我说不用记,每个都疼过一次就忘不掉了。