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

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

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

第一步:用 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轻量论坛系统 - 轻量级 PHP 论坛系统,原文地址: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,四步都干净才算真收住了。