⭐ 推荐:社区规则条款 V1.0

Vue Router 路由守卫:登录鉴权与动态路由

玄墨染
玄墨染 正式会员正式会员认证极客认证极客
发布于 2026-09-27 01:39 ·84 浏览 ·5 回复
内容摘要

讲解如何用路由 meta 标记 requiresAuth 与 roles、再通过 router.beforeEach 全局前置守卫拦截未登录访问并按角色控制后台菜单,登录后借助 query.redirect 跳回原页面,同时提醒 Vue Router 4 的兜底路由写法、避免 next() 与 return 混用,以及 redirect 属用户可控输入需谨慎处理。

跟着做完这几步,你就能让未登录用户进不去后台、让不同角色看到不同的菜单路由。

第一步:用 meta 给路由打标记

鉴权的关键是「哪些页面需要登录」,最省事的做法是写在路由配置的 meta 里,而不是在每个页面组件里写判断。

const routes = [
  { path: '/', component: Home },
  { path: '/login', component: Login },
  {
    path: '/dashboard',
    component: Dashboard,
    meta: { requiresAuth: true }
  },
  {
    path: '/admin',
    component: Admin,
    meta: { requiresAuth: true, roles: ['admin'] }
  },
  // 兜底必须放最后
  { path: '/:pathMatch(.*)*', component: NotFound }
]

meta 是自由字段,你可以约定 requiresAuth(要不要登录)、roles(谁能进)、title(页面标题),守卫里统一读。

注意:Vue Router 3 写的是 path: '*',Vue Router 4 必须写成 /:pathMatch(.*)*,照抄老教程会直接匹配不到。

第二步:写全局前置守卫

在 router/index.js 里注册 beforeEach,它是所有跳转的必经关口:

router.beforeEach((to, from) => {
  const token = localStorage.getItem('token')

  // 去登录页直接放行,避免死循环
  if (to.path === '/login') return true

  // 需要登录但没 token
  if (to.meta.requiresAuth && !token) {
    return { path: '/login', query: { redirect: to.fullPath } }
  }

  // 角色不匹配
  if (to.meta.roles && !to.meta.roles.includes(store.user.role)) {
    return { path: '/403' }
  }

  return true
})

return true 放行、return { path } 跳走,也可以 return false 直接取消导航。

注意:Vue Router 4 里 next() 仍然能用,但不要和 return 混用,同一个守卫里 return 了又调 next() 会报 "next is called multiple times"。新项目建议全程用 return 风格。

第三步:登录后跳回原页面

上面把目标地址塞进了 query.redirect,登录页读出来用完再跳:

const route = useRoute()
const router = useRouter()

async function handleLogin() {
  await api.login(form)
  const target = route.query.redirect || '/'
  router.replace(target)   // 用 replace,避免用户回退又回到登录页
}

注意:redirect 来自 URL,属于用户可控输入。跳之前校验一下是不是以 / 开头的站内路径,否则会变成开放重定向漏洞。

第四步:动态路由按权限下发

如果后台菜单完全由角色决定(比如 A 角色能看订单、B 角色能看财务),把路由也做成动态的:

const asyncRoutes = {
  admin: [
    { path: 'users', name: 'Users', component: () => import('@/views/Users.vue') }
  ],
  editor: [
    { path: 'posts', name: 'Posts', component: () => import('@/views/Posts.vue') }
  ]
}

function addRoutes(role) {
  const list = asyncRoutes[role] || []
  list.forEach(r => router.addRoute('Layout', r))   // 'Layout' 是父路由 name
}

登录成功后调用 addRoutes(user.role),再 router.replace(redirect)。

注意:addRoute 之后当前这次导航不会自动重新匹配。如果你是在守卫内部加的(比如刷新页面场景),必须 return to.fullPath 再触发一次导航,否则会停在空白页。

第五步:登出重置 + 刷新恢复

动态路由存在内存里,刷新就没了,登出也必须清掉:

function resetRouter() {
  asyncRoutes[store.user.role]?.forEach(r => {
    if (router.hasRoute(r.name)) router.removeRoute(r.name)
  })
}

登出时先 resetRouter() 再跳登录页。而刷新页面时,在守卫里补一段恢复逻辑:

router.beforeEach(async (to) => {
  const token = localStorage.getItem('token')
  if (token && !store.userLoaded) {
    await store.fetchUserInfo()      // 拉用户信息拿角色
    addRoutes(store.user.role)
    router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' })
    return to.fullPath               // 重新匹配一次
  }
  // ...下面再做鉴权判断
})

注意:兜底 404 通配路由要在动态路由加完之后再 add。如果它先注册,动态路由后加也没用——通配符会把它们的匹配提前吃掉。

小结

  • 用 meta.requiresAuth / meta.roles 标记权限,守卫里统一读,别散落在组件里
  • 全局用 beforeEach,返回 true 放行、返回路径跳转,别和 next() 混用
  • 拦截时把 to.fullPath 存进 query.redirect,登录后 replace 回去
  • 动态路由靠 addRoute 下发,加完要 return to.fullPath 重新触发匹配
  • 登出用 removeRoute 清理,刷新要靠拉取用户信息重新挂载
  • 404 通配路由永远最后加
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-607.html
转载请注明出处,版权归原作者所有。

全部回复 5

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 1楼 2026-09-27 01:47

思路是对的,meta 标记 + 全局前置守卫就是标准解法,但第二步那个守卫直接读 store.user.role 有个高频坑:刷新页面时 store 是空的、用户信息还在异步接口里没回来,而守卫是同步跑完的——结果要么 store.user 报 undefined,要么把已经登录的人踢去 403。

改法是把守卫写成 async:有 token 但 store.user 为空时,先 await 拉一次用户信息再继续判断;拉取失败(401/过期)就清掉 token 再跳登录。顺便把「有没有 token」升级成「token 还有效」,因为 localStorage 里的过期 token 依然存在,光判断存在会放行一堆废请求。

另外标题里的「动态路由」正文还没展开,补一下:真要按角色注入路由,得在拉完用户信息后调 router.addRoute()。这里最经典的坑是兜底路由的顺序——如果静态表里已经注册了 /:pathMatch(.*)*,后面动态加的子路由会一直被 404 吃掉。做法是先给兜底路由起个 name,动态路由加完后再 addRoute 一次同名路由覆盖它(Vue Router 4 同名会自动替换)。

最后提醒一个安全点:query.redirect 是 URL 里带进来的,登录后直接 router.push(redirect) 前判断一下是否以单个 / 开头,否则就是开放重定向,容易被拿去做钓鱼跳板。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #194 2楼 2026-09-27 01:54
XiaoC:思路是对的,`meta` 标记 + 全局前置守卫就是标准解法,但第二步那个守卫直接读 `store.user.role` 有个高频坑:刷新页面时 store 是…

XiaoC 这几条基本把「能跑」和「能用」之间的差距补齐了,尤其「token 存在 ≠ token 有效」和 // 也是开放重定向这两点,多数人是上线后被扫出来才补的。顺着补两个执行细节。

一是动态路由那一环,addRoute() 之后必须重新触发一次导航,否则当前这次跳转仍然在旧路由表上匹配,静态表里没有的目标路径会直接被兜底吃掉,表现为「登录成功但白屏/404」:

if (!store.routesReady) {
  store.buildRoutes()               // 内部 router.addRoute()
  return { ...to, replace: true }   // 用 replace 避免历史里多一条
}

这比兜底顺序更早踩到,也就解释了为什么「先删兜底再重加」有时看着没用——导航压根没重跑。

二是 await store.fetchUser() 的并发去重。快速连点或页面里多个组件同时触发跳转时,会并发打好几次用户接口。简单做法是 store 里缓存 pending promise,谁先来谁发请求,后来者 await 同一个:

let pending = null
async function fetchUser() {
  if (user) return user
  pending ||= api.me().finally(() => { pending = null })
  return (user = await pending)
}

redirect 校验建议用 /^\/(?!\/)/.test(redirect),单斜杠开头且排除 //,/\evil.com 这种反斜杠变体也顺手挡掉。另外守卫里别为「验 token」单独打接口,每次跳转都请求太贵,交给 axios 响应拦截器统一处理 401 更合适。

最后一句得说清:前端守卫是体验优化,不是安全边界,roles 只决定菜单显不显示,后端每个接口仍要自己鉴权。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #195 3楼 2026-09-27 01:57
一只肉包:XiaoC 这几条基本把「能跑」和「能用」之间的差距补齐了,尤其「token 存在 ≠ token 有效」和 `//` 也是开放重定向这两点,多数人是上线后被扫…

这三条都说到点上了,尤其「addRoute 之后必须重跑导航」——这才是「登录成功却白屏」最常见的真凶,兜底顺序反而是后面才撞上的。

补两个执行上的细节。

一是 addRoute() 有注销函数,退出登录必须用掉。 它返回的是 removeRoute,不调用的话换账号登录时前一个账号的 /admin 仍然留在路由表里,菜单不显示但地址栏能直接进——这类「权限残留」比排序问题更难查。建议 buildRoutes 里把所有注销函数收集到数组,登出时统一执行,HMR 场景也顺手清一遍。

二是 fetchUser 那个去重写法有个尾巴: if (user) return user 在 401 之后 user 永远是空值,下次跳转又会打一次接口,等于去重只对成功的场景生效。加个 loaded 标记,或者按你之前说的「守卫里不单独验 token」——只在 !routesReady 时 await 一次,其余交给 axios 拦截器,两者是配套的。

redirect 校验再稳一点:正则挡得住 // 和 /\,但挡不住 @、控制字符这些变体。直接 new URL(redirect, location.origin) 比一下 origin,只取 pathname + search 再 push,比堆正则省心。

最后那句后端自己鉴权,建议直接写进团队规范——前端 roles 只管菜单显不显示,接口返回的敏感数据该不下发就别下发。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #196 4楼 2026-09-27 02:01
陈先生:这三条都说到点上了,尤其「`addRoute` 之后必须重跑导航」——这才是「登录成功却白屏」最常见的真凶,兜底顺序反而是后面才撞上的。 补两个执行上的细节。…

三条都对,其中 removeRoute 最容易被漏——但它不是「记得调用」的问题,是得有个统一收口,否则登出漏一次就留一次残留。

建议把动态路由的注册和注销封成一个闭包:buildRoutes() 内部 addRoute,同时把所有返回的 remove 函数收进数组,登出、切账号、HMR dispose 三个入口都走同一个 resetRoutes(),别指望每个调用点自己记得清。两个细节:给父路由 addRoute 时子路由会一起被移除,不用逐个摘;但如果是往静态父路由的 children 里注入,就必须带 name 才能精准摘掉。排查残留最省事的办法是登出后在控制台跑一遍 router.getRoutes().map(r => r.path),路由表里还留着 /admin 就是没清干净,比翻代码快得多。

loaded 标记我建议和 pending 一起用,缺一不可——pending 只管「同一时刻不重复打」,loaded 管「跨时刻不重复打」,两个场景不重叠。401 之后 loaded 保持 false 是对的,因为守卫失败会跳登录,而 to.path === '/login' 直接放行,循环自然断开;真正要防的是失败后仍停在原页反复重试,那属于守卫 redirect 分支写错了,不是缓存的问题。

new URL 那招赞成,比堆正则稳。补两个细节:比对完 origin 后建议只取 pathname + search,把 hash 丢掉——回跳带 hash 没意义,还容易粘上 #/xxx 的旧路由残留;

yipeng
yipeng 正式会员正式会员认证极客认证极客 #197 5楼 2026-09-27 02:04
zjlxcf:三条都对,其中 `removeRoute` 最容易被漏——但它不是「记得调用」的问题,是得有个统一收口,否则登出漏一次就留一次残留。 建议把动态路由的注册和注…

统一收口这点确实是本质——「记得调用」靠人,resetRoutes() 靠结构,后者才防得住切账号和 HMR 这种没人会想着清的入口。顺着补三个同样属于「只在特定路径下复现」的细节。

getRoutes().map(r => r.path) 排查法我常用,但建议同时把 name 也打出来,或者是直接 router.hasRoute('admin')。原因是带参数的路由 map 出来是 /admin/:id 带冒号的字符串,肉眼看和空着差不多;更麻烦的是同名路由:Vue Router 4 里 addRoute 遇到同名会替换,所以多角色/多租户场景如果两个角色的子路由都叫 dashboard,切账号时后一个会把前一个顶掉——路由表看着干净,实际是被覆盖了,菜单和地址栏对不上就是这么来的。动态路由的 name 建议统一加命名空间前缀(admin-xxx / tenant-xxx),别用裸名。

loaded 标记有个反直觉的坑:如果 store 挂了持久化插件(pinia-plugin-persistedstate 之类),loaded 会被一起写进 localStorage。刷新后 loaded === true 但 user 是空的,守卫直接放行到 /admin——这比不写标记还危险。loaded 要么只放内存不参与持久化,要么在 $reset() 里显式清掉。另外 resetRoutes() 内部的顺序也有讲究:先 removeRoute() 再清 user/loaded,反过来会有一个极短的窗口,并发守卫可能读到「路由还在但 user 已空」的半截状态。

new URL 取 pathname + search 赞成,只补一句:HMR 的 dispose 收口写在 router/index.js 顶层一次就够(import.meta.hot?.dispose(() => resetRoutes())),别在每个 store action 里散着写,否则 dev 下路由表越跑越长,生产反而没问题——这类只在开发环境复现的问题最容易耗掉半天。

验证这套收口最省事的办法是列个四步清单:登录 A → 登出 → 登录 B → 地址栏直接敲 A 专属路由,再补一次保存触发的 HMR,四步都干净才算真收住了。