
做了几年后台管理系统最让我头疼的往往不是业务组件而是登录态这一环。早期我习惯在每个页面created里写一句if (!token) 跳登录结果代码到处复制改一处漏三处后来切到 Vue3 Vue Router 4用路由守卫统一处理登录态校验、token 管理和白名单重定向才把这块彻底理顺。这篇文章就是这次梳理的完整记录偏向路由与布局骨架部分适合正在做中后台项目、被重复校验代码困扰、以及第一次接触路由守卫不知道从哪下手的同学。1. 先厘清边界路由守卫到底该不该管登录态1.1 为什么不能把登录校验写在每个组件里最朴素的登录校验方案是在每个需要登录的页面组件里判断 tokenif (!localStorage.getItem(token)) { router.push(/login) }这段代码看着没问题但实际用起来非常难受。首先是重复几十个页面就要复制几十次哪天 token 字段名变了或者要加一个“记住我”的逻辑全局搜索替换能让人改到怀疑人生。其次是时机组件里的created或onMounted执行时页面组件已经被创建甚至一部分初始化接口已经发出去了这时候再跳转会产生不必要的请求和闪烁。路由守卫解决的就是这个问题。它在导航确认之前执行能做“进入页面之前”的拦截比组件内判断更早也天然集中。用路由守卫判断登录态代码只有一份加页面时只需要在路由配置里标记一下是否需要登录不需要在组件里写任何和鉴权相关的逻辑。注意路由守卫做的是前端导航拦截它保证“未登录的人看不到受保护页面”但不能替代后端接口的权限校验。接口层面该拦的还是要拦两层配合才安全。1.2 Vue Router 4 的守卫接口与 next 的兼容问题Vue Router 4 里有三种全局守卫beforeEach、beforeResolve、afterEach。做登录态校验用的是beforeEach它会在每次路由跳转前触发适合做拦截和重定向。很多人是从 Vue Router 3 或者老教程过来的习惯写router.beforeEach((to, from, next) { // 判断逻辑 next() })Vue Router 4 仍然兼容next写法但官方更推荐直接返回值因为next有个容易踩的坑一旦在某条分支里调用过一次next后面如果逻辑没写干净又调了一次控制台会报 “next()was called multiple times” 的警告重定向还会变得不可预测。我的建议是新项目一律用返回值写法和next的对应关系如下场景返回值写法next 写法放行return truenext()取消导航return falsenext(false)跳到登录页return /loginnext(/login)跳转并携带参数return { path: /login, query: { redirect: to.fullPath } }next({ path: /login, query: { redirect: to.fullPath } })返回值写法不仅避免重复调用还能让守卫函数保持纯函数风格更好测试。后面我给的完整实现也统一用返回值。1.3 守卫能做的、做不了的边界很多初学者会把路由守卫当成万能闸门试图在里面做角色权限、按钮权限、接口鉴权。其实它更适合做两件事导航可见性控制和导航完成后的副作用。页面级权限比如不同角色看到不同菜单、不同路由可以配合meta.roles 动态路由来做但注意这只影响前端页面可见性真正的数据权限还是要由后端接口控制。按钮级权限不建议放守卫里用自定义指令或组件封装更灵活。路由守卫不是万能的它只是导航链路上的一个拦截点把边界想清楚后面扩展才不会打架。2. 骨架先行路由分层、Layout 与 meta 字段2.1 把路由按权限和布局拆分而不是平铺很多后台项目刚开始时路由是平铺的const routes [ { path: /login, component: Login }, { path: /dashboard, component: Dashboard }, { path: /users, component: Users } ]这套结构在页面少时没问题页面一多布局就乱了。因为后台系统通常需要一个公共布局顶部栏、侧边栏、面包屑、内容区登录页和 404 页则不需要这个布局。正确做法是先把路由分成两层公共路由和带 Layout 的业务路由。我的骨架是这样const routes [ { path: /login, name: Login, component: () import(/views/login/index.vue), meta: { title: 登录 } }, { path: /, component: () import(/layout/index.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/dashboard/index.vue), meta: { title: 概览, requiresAuth: true } }, { path: users, name: Users, component: () import(/views/users/index.vue), meta: { title: 用户管理, requiresAuth: true } } ] } ]这里/login是独立路由不挂在 Layout 下这样登录页不会被后台的侧边栏包一圈。/下面挂一个Layout组件业务页面全部作为它的childrenLayout 内部只有一个核心出口template div classlayout Sidebar / div classlayout-main Header / router-view / /div /div /templaterouter-view会渲染匹配到的子路由组件这是嵌套路由的基本用法。骨架搭好后新加业务页面只需要在children里加一条记录不需要再考虑布局问题。2.2 meta 字段作为唯一数据源白名单从标记反推路由守卫需要知道“哪些页面需要登录”这个信息最好直接放在路由的meta里而不是单独维护一个白名单数组。我之前见过有人单独写一个whiteList数组const whiteList [/login, /register, /404]短时间够用但维护两处数据早晚会漏。更好的做法是只标记受保护页面没标记的一律视为白名单。我约定requiresAuth字段只有两种情况不写这个字段默认不需要登录比如登录页、注册页、404。requiresAuth: true进入前必须已登录。这样路由表本身就是唯一数据源新增页面时想起要保护就加requiresAuth: true不想保护就不加不需要同步白名单数组。meta里我还会放title用于页面标题和面包屑后续如果要做角色权限再加roles。注意一个细节Vue Router 会把父路由和子路由的meta做合并所以如果父级有requiresAuth子级即使没写to.meta.requiresAuth也可能是true。为了精确判断“当前匹配链路中是否有人要求登录”我用的是to.matched.some(record record.meta.requiresAuth)而不是直接读to.meta这样可以逐条记录判断不会被父子合并搞晕。2.3 404 兜底与通配路由的写法变化路由骨架还有一个容易被忽略的部分404 兜底。Vue Router 4 的通配路由写法从 Vue Router 3 的*改成了正则风格{ path: /:pathMatch(.*)*, name: NotFound, component: () import(/views/error/404.vue), meta: { title: 页面不存在 } }这条路由要放在路由表最后因为通配会匹配所有没被前面路由命中的路径。加了它之后访问不存在的地址才不会白屏而是进入 404 页面。登录状态下如果访问了一个需要登录但路由不存在的地址会走到这里未登录访问同样的地址会被守卫先拦截去登录这个顺序在 4.1 的完整实现里再展开。3. token 怎么存、怎么清、什么时候判定失效3.1 localStorage 持久化 store 响应式双写登录态的核心是 token。很多人直接localStorage.setItem(token, xxx)一把梭用的时候再getItem取。这样能用但有个痛点组件没法响应式地感知 token 变化。比如用户在某个页面点了“退出登录”侧边栏的用户信息要立刻变成未登录状态如果只是 localStorage 读写组件不知道该什么时候更新。我的做法是localStorage 负责持久化Pinia store 负责响应式状态两者双写// stores/user.js import { defineStore } from pinia const TOKEN_KEY app_token export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(TOKEN_KEY) || }), actions: { setToken(token) { this.token token localStorage.setItem(TOKEN_KEY, token) }, clearToken() { this.token localStorage.removeItem(TOKEN_KEY) } } })关键是state初始化时直接从 localStorage 里读一次。这样刷新页面后store 实例重新创建但 token 还在内存里组件可以同步拿到。setToken和clearToken里同时维护 store 和 localStorage保证两边永远一致。为什么不直接用 sessionStorage因为用户刷新浏览器时 sessionStorage 不会丢它只在标签页关闭时丢但中后台系统通常希望刷新后登录态不消失localStorage 更符合习惯。安全性上localStorage 有 XSS 风险所以项目里要尽量做好 XSS 防护不要把敏感信息明文写进日志或 URL。3.2 守卫里要不要调接口验 token这个问题我纠结过很久。从理论上看路由守卫里可以await一个校验 token 的接口返回 401 再跳登录这样最严谨。但从实践看我不建议在每次导航都调接口校验 token。原因有两个。第一后台系统里路由跳转很频繁每次跳页都去请求一次校验接口会增加大量无效请求。第二token 的合法性其实是后端接口的职责页面加载时真正调业务接口如果 token 失效接口会返回 401我们在 axios 拦截器里统一处理效果完全一样。前端路由守卫只需要判断“本地有没有 token”把“token 是否有效”交给接口拦截器这样职责清晰也不会有额外延迟。如果确实需要在进入某些敏感页面前实时校验可以做一个独立的checkToken方法在beforeEach里调用但要注意缓存结果和使用标志位避免并发跳转时重复请求。骨架阶段建议先按“本地有 token 就放行”来做跑通后再优化。3.3 401 统一拦截与退出登录的动作token 失效的判断时机在接口响应。我用 axios 封装一个实例在响应拦截器里统一处理 401// request.js import axios from axios import { useUserStore } from /stores/user import router from /router const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) service.interceptors.response.use( (response) response, (error) { const userStore useUserStore() if (error.response error.response.status 401) { userStore.clearToken() if (router.currentRoute.value.path ! /login) { router.replace({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) } } return Promise.reject(error) } )这套逻辑和路由守卫配合起来是这样用户带着一个过期 token 刷新页面守卫看到本地 token 存在放行进入页面页面发请求后端返回 401拦截器清掉 token并把用户带到登录页。整个过程用户会看到页面一闪然后跳转这是完全正常的因为前端无法提前知道 token 本地是否失效。注意401 跳转最好用router.replace而不是router.push避免用户按返回键又回到那个已经没有权限的页面。4. 核心 beforeEach 逻辑白名单、重定向、登录回跳4.1 完整实现与逐行解释把前面的骨架、store、守卫串起来最终的beforeEach长这样// router/index.js import { createRouter, createWebHistory } from vue-router import { useUserStore } from /stores/user const router createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from) { const userStore useUserStore() const token userStore.token const needAuth to.matched.some( (record) record.meta.requiresAuth ) // 未登录访问受保护页面去登录页并带上当前地址用于回跳 if (needAuth !token) { return { path: /login, query: { redirect: to.fullPath } } } // 已登录访问登录页直接回首页避免重复登录 if (to.path /login token) { return { path: / } } // 其余情况放行白名单、404、已登录访问受保护页 return true }) export default router我习惯把逻辑分成三个分支按优先级写清楚受保护且未登录这是最核心的拦截分支重定向到/login并把当前完整路径塞到query.redirect里后面登录成功可以跳回来。已登录访问/login如果已经登录还去登录页属于多余动作直接送回首页。其余全部放行包括无需登录的公开页、404、已登录的受保护页面。关键点在于needAuth的判断用to.matched.some。假设/user/detail这个子路由没写requiresAuth但它的父级/user写了to.matched里就能匹配到父记录的requiresAuth: true这条链路上依然会被判定为受保护。如果只判断to.meta父子合并的结果可能也是 true但用some更显式排查问题时也更清楚是哪一层标记的。4.2 登录成功后的 redirect 回跳与安全校验守卫把未登录用户带到登录页时URL 会带一个redirect参数/login?redirect%2Fusers登录页提交成功后不能写死跳首页要优先回跳目标页const redirect route.query.redirect router.push(redirect || /)但这里有两个容易踩坑的点。第一个是redirect可能以字符串形式存在也可能是数组用的时候最好确保是字符串。第二是不能直接信任 URL 里的跳转地址否则容易被构造恶意跳转。比如有人构造一个/login?redirecthttps://evil.com登录成功后就会跳去外部站。我的防御很简单只允许以/开头的站内路径作为回跳目标外部地址一律忽略。function safeRedirect(fullPath) { if (typeof fullPath string fullPath.startsWith(/)) { return fullPath } return / } // 登录成功后 router.push(safeRedirect(route.query.redirect))这里不需要复杂判断站内路由一定以/开头外部 URL 会被过滤掉就能防止大部分开放重定向问题。4.3 退出登录和清 token 的正确姿势退出登录的流程看起来简单清 token跳登录页。但我见过不少人漏了一个细节登录页是白名单从受保护页面退出后跳登录页如果守卫逻辑没写好可能又被打回受保护页面或者出现奇怪循环。我的退出逻辑function logout() { userStore.clearToken() router.replace({ path: /login, query: { redirect: / } }) }clearToken()之后store 里的 token 已经清空。此时即使当前页面还在受保护路由里下一次导航发生时守卫会重新判断。用router.replace而不是push是为了不留下“退出前页面”的历史记录用户按返回键不会回到一个已经失去 token 的页面。如果项目里有“退出登录需要调用后端接口”的需求建议先调用接口、无论成功与否都清本地 token避免后端接口超时导致前端卡在已登录状态。5. 实战排雷无限重定向、刷新白屏、动态路由时机5.1 无限重定向的两个典型写法无限重定向是路由守卫最常见的坑我至少见过两种写法触发。第一种是在未登录拦截分支里没写return true结果放行分支也走了。比如// 错误示范 router.beforeEach((to, from, next) { if (needAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) } if (to.path /login token) { next({ path: / }) } next() })表面看有四个分支实际上next()可能被执行多次而且第二次next()会覆盖第一次的重定向结果导航流程被反复触发。改成返回值写法之后每个分支都有明确出口就不容易出现这种问题。第二种是登录页和首页互相跳转的死循环。例如// 错误示范 if (!token) { return /login } if (to.path /login) { return / }未登录时访问/login第一个条件满足跳登录第二个条件不管访问首页第一个条件满足跳登录守卫再次执行发现还是没 token又跳登录看起来像是正常但如果把判断顺序反过来或者条件写错if (to.path /login) { return / } if (!token) { return /login }未登录访问/login时token本来就没有却先被第二个条件重定向到首页首页又触发第一分支去登录页……来回跳导航栈直接告警。所以守卫分支的顺序很重要先处理核心拦截再处理登录页回跳最后统一放行。5.2 刷新白屏和闪一下登录页守卫逻辑写好后刷新页面可能遇到两种现象要么短暂闪一下登录页然后跳回目标页要么直接白屏。闪一下登录页多半是 store 初始化时token还没读出来。虽然我在 store 里用 localStorage 初始化了但要注意 Pinia 的安装顺序。如果router.beforeEach里调用useUserStore()而 Pinia 实例还没挂到 app 上会报错或者拿到空 store。正确顺序是// main.js import { createApp } from vue import { createPinia } from pinia import router from /router import App from ./App.vue const app createApp(App) app.use(createPinia()) app.use(router) app.mount(#app)app.use(router)之后首次导航守卫执行时 Pinia 已经安装useUserStore()能正常拿到 storetoken 也能从 localStorage 读到。白屏问题则要检查 404 兜底路由和动态路由的配合。如果访问的地址是一个不存在但带requiresAuth的路径未登录会被先拦截没问题已登录用户访问不存在地址如果 404 路由没加requiresAuth守卫放行进入 404也没问题。真正的白屏往往出现在项目用了动态路由addRoute还没执行完页面已经尝试渲染导致找不到对应组件。这个下面单说。5.3 动态路由 addRoute 后守卫怎么配合中后台项目经常要根据角色动态添加路由比如管理员能访问/system普通用户看不到。动态路由通常这样加router.addRoute({ path: /system, component: Layout, children: [ { path: config, component: () import(/views/system/config.vue) } ] })但加了动态路由后第一次进入/system/config时守卫可能在addRoute之前就完成了匹配找不到路由直接进了 404。我的处理办法是加一个标志位确认动态路由添加完再放行第二次导航let dynamicRoutesAdded false router.beforeEach(async (to) { if (!dynamicRoutesAdded to.path ! /login) { const userStore useUserStore() if (!userStore.token) { return { path: /login, query: { redirect: to.fullPath } } } // 这里做一个假接口实际项目里换成从后端拿到的角色菜单 const asyncRoutes await fetchUserRoutes() asyncRoutes.forEach((route) router.addRoute(route)) dynamicRoutesAdded true // 重新触发一次导航这次路由表里已经有动态路由了 return { ...to, replace: true } } const needAuth to.matched.some((record) record.meta.requiresAuth) if (needAuth !userStoreAvailable()) { return /login } return true })这里要注意两点return { ...to, replace: true }是为了让当前导航重新走一遍守卫dynamicRoutesAdded这个标志位要放在守卫外部避免每次导航都重复请求角色菜单。因为本文是路由与布局骨架篇动态路由部分先不展开这里只提示一个配合思路。5.4 一个小习惯afterEach 里做标题与进度条做完拦截逻辑我习惯在afterEach里统一处理页面标题而不是在每个页面自己的onMounted改document.title。理由和守卫一样集中管理。router.afterEach((to) { const pureTitle to.meta.title || document.title pureTitle ? ${pureTitle} · 管理后台 : 管理后台 })如果项目里用了 NProgress 这类顶部进度条还可以把start放在beforeEachdone放在afterEach。这套做法不起眼但对用户体验的连续性帮助很大也避免我新开页面时忘记设置标题。把导航拦截、token 存储、白名单重定向、页面骨架这几件事想清楚之后一个中后台项目的路由基础就算立住了。后面再扩展动态权限、多标签页、按钮级权限都是在这个骨架上加东西而不是推翻重来。