【Vue3 + Vue Router + Pinia】中后台前端实战:从路由守卫规范到落地实操,彻底搞懂beforeEach最佳写法,避开死循环、重复请求两大高频坑!

📑 文章目录
- 一、开篇:为什么路由守卫值得单独写一篇文章?
- [二、先搞清楚:什么是路由守卫?beforeEach 在哪个时机执行?](#二、先搞清楚:什么是路由守卫?beforeEach 在哪个时机执行?)
- [2.1 路由守卫的时机](#2.1 路由守卫的时机)
- [2.2 必须记住的 3 点](#2.2 必须记住的 3 点)
- [三、beforeEach 里「应该做」什么?](#三、beforeEach 里「应该做」什么?)
- [3.1 适合做的事](#3.1 适合做的事)
- [3.2 完整示例:登录校验 + 标题设置](#3.2 完整示例:登录校验 + 标题设置)
- [四、beforeEach 里「不应该做」什么?](#四、beforeEach 里「不应该做」什么?)
- [4.1 不适合做的事](#4.1 不适合做的事)
- [4.2 为什么「在 beforeEach 里发请求」容易出问题?](#4.2 为什么「在 beforeEach 里发请求」容易出问题?)
- 五、死循环:为什么会发生?怎么避免?
- [5.1 常见死循环场景](#5.1 常见死循环场景)
- [5.2 正确写法:白名单 + 明确分支](#5.2 正确写法:白名单 + 明确分支)
- 六、重复请求:如何避免?
- [6.1 问题示例](#6.1 问题示例)
- [6.2 正确做法:在 Store 里做「请求去重」](#6.2 正确做法:在 Store 里做「请求去重」)
- [6.3 更推荐:在应用启动或登录后拉取](#6.3 更推荐:在应用启动或登录后拉取)
- 七、状态管理与路由守卫的配合规范
- [7.1 职责划分](#7.1 职责划分)
- [7.2 推荐的项目流程](#7.2 推荐的项目流程)
- 八、完整实战示例
- [九、小结:beforeEach 使用清单](#九、小结:beforeEach 使用清单)
- [🔍 系列模块导航](#🔍 系列模块导航)
同学们好,我是 Eugene(尤金),一名多年中后台前端开发工程师。
(Eugene 发音 /juːˈdʒiːn/,大家怎么顺口怎么叫就好)
很多前端开发者都会遇到一个瓶颈:
代码能跑,但不够规范;功能能实现,但维护起来特别痛苦;一个人写没问题,一到团队协作就各种混乱、踩坑、返工。
想写出干净、优雅、可维护 的专业代码,靠的不是天赋,而是体系化的规范 + 真实实战经验。
这一系列《前端规范实战》,我会用大白话 + 真实业务场景,不讲玄学、不堆理论,只分享能直接落地的规范、标准与避坑指南。
帮你从「会写代码」真正升级为「会写优质、可维护、团队级别的代码」。
一、开篇:为什么路由守卫值得单独写一篇文章?
在 Vue3 + Vue Router 项目里,beforeEach 几乎是必用的。很多人写了不少业务代码,但对「在 beforeEach 里该做什么、不该做什么」没有清晰规范,经常出现:
- 死循环(页面白屏或一直跳转)
- 重复请求(接口被多次调用)
- 逻辑和登录状态、权限、面包屑等混在一起,难以维护
这篇文章的目标是:结合日常实战,讲清楚 beforeEach 的规范用法,以及如何避免常见问题。不追求底层实现,而是「日常该怎么写」。
[⬆ 返回目录](#⬆ 返回目录)
二、先搞清楚:什么是路由守卫?beforeEach 在哪个时机执行?
2.1 路由守卫的时机
路由守卫分为三种:全局守卫、路由独享守卫、组件内守卫。本文重点讲 **全局前置守卫 ** beforeEach。
js
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [...]
})
// 全局前置守卫:每次路由跳转「之前」都会执行
router.beforeEach((to, from, next) => {
// to: 要去的目标路由
// from: 当前要离开的路由
// next: 控制跳转的函数
next() // 必须调用 next(),否则路由不会继续
})
执行顺序可以简单理解为:
- 用户点击链接 / 执行
router.push等 - 路由开始切换
- beforeEach 被调用
- 如果调用了
next()→ 继续到目标路由 - 如果调用
next(false)→ 取消本次跳转 - 如果调用
next('/login')→ 重定向到其他路由
[⬆ 返回目录](#⬆ 返回目录)
2.2 必须记住的 3 点
| 要点 | 说明 |
|---|---|
| 必须调用 next() | 不调用 next,路由会一直卡住 |
| 只调用一次 next() | 多次调用会导致警告和异常行为 |
| 在异步逻辑里也要保证调用 next | 例如发请求后要调用 next,否则路由无法继续 |
[⬆ 返回目录](#⬆ 返回目录)
三、beforeEach 里「应该做」什么?
3.1 适合做的事
- 登录校验:未登录用户访问需登录页面 → 重定向到登录页
- 权限校验:无权限访问某页面 → 重定向到 403 或首页
- 设置页面标题 :
document.title = to.meta.title - 路由元信息加工:面包屑、菜单高亮等
- 轻量级状态同步:如将「需要登录」标记写入 Vuex/Pinia
核心原则:快、简单、无副作用。
[⬆ 返回目录](#⬆ 返回目录)
3.2 完整示例:登录校验 + 标题设置
js
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录', requiresAuth: false }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '工作台', requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 假设从 Pinia 获取登录状态
import { useUserStore } from '@/stores/user'
router.beforeEach((to, from, next) => {
// 1. 设置页面标题
if (to.meta.title) {
document.title = `${to.meta.title} - 我的应用`
}
// 2. 登录校验
const userStore = useUserStore()
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
if (requiresAuth && !userStore.isLoggedIn) {
next({ path: '/login', query: { redirect: to.fullPath } })
return
}
// 3. 已登录访问登录页 → 直接去 redirect 或首页
if (to.path === '/login' && userStore.isLoggedIn) {
next({ path: (to.query.redirect || '/dashboard').toString() })
return
}
next()
})
说明:
requiresAuth:通过to.matched.some(...)判断目标路由(含父级)是否要求登录redirect:登录后可以跳回用户原来想去的页面- 每个分支内
next()只调用一次,逻辑清晰
[⬆ 返回目录](#⬆ 返回目录)
四、beforeEach 里「不应该做」什么?
4.1 不适合做的事
| 不要做 | 原因 |
|---|---|
| 发起接口请求(如获取用户信息、菜单) | 每次跳转都请求,容易重复、阻塞,不好控制 |
| 复杂业务逻辑 | 路由守卫应尽量轻量,复杂逻辑放组件或 store |
| 修改全局状态(如 Vuex 大量 mutation) | 增加心智负担,难以排查问题 |
| 依赖尚未就绪的 store | 可能拿到空数据或导致时序问题 |
[⬆ 返回目录](#⬆ 返回目录)
4.2 为什么「在 beforeEach 里发请求」容易出问题?
js
// ❌ 错误示例:在 beforeEach 里发请求
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
if (!userStore.userInfo) {
await api.getUserInfo() // 每次没 userInfo 就请求
userStore.setUserInfo(...)
}
next()
})
典型问题:
- 重复请求 :多次快速切换路由,
userInfo还没写进 store,会触发多次getUserInfo - 竞态:多个请求返回顺序不确定,后返回的可能覆盖先返回的
- 阻塞路由:请求较慢时,用户会觉得「点了没反应」
正确思路:登录态、用户信息等在进入应用或登录成功后统一拉取,守卫只做「读状态 + 判断」。
[⬆ 返回目录](#⬆ 返回目录)
五、死循环:为什么会发生?怎么避免?
5.1 常见死循环场景
场景 1:未登录 → 去 /login,又在 /login 里再次重定向
js
// ❌ 死循环写法
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (!userStore.isLoggedIn) {
next('/login') // 未登录 → 去登录页
} else {
next()
}
})
如果 /login 也被标记为 requiresAuth: true,或者你的判断逻辑把 /login 也当成「需要登录」的页面,就会出现:
- 进入
/login→ 判断未登录 →next('/login')→ 再次进入/login→ 无限循环
场景 2:重定向到自身
js
// ❌ 死循环写法
router.beforeEach((to, from, next) => {
if (!hasPermission(to)) {
next(to.fullPath) // 没权限却重定向到当前页,相当于循环
}
next()
})
[⬆ 返回目录](#⬆ 返回目录)
5.2 正确写法:白名单 + 明确分支
js
// ✅ 正确:对登录页等做白名单处理
const whiteList = ['/login', '/register', '/forgot-password']
router.beforeEach((to, from, next) => {
const userStore = useUserStore()
if (whiteList.includes(to.path)) {
// 白名单直接放行
if (userStore.isLoggedIn && to.path === '/login') {
next('/dashboard')
} else {
next()
}
return
}
if (!userStore.isLoggedIn) {
next(`/login?redirect=${encodeURIComponent(to.fullPath)}`)
return
}
next()
})
核心:对 /login 等页面做白名单,避免「进登录页 → 再判断未登录 → 再进登录页」的循环。
[⬆ 返回目录](#⬆ 返回目录)
六、重复请求:如何避免?
6.1 问题示例
js
// ❌ 每次进页面都可能请求一次
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.userInfo) {
await userStore.fetchUserInfo() // 并发、重复、竞态
}
next()
})
多次快速切换路由时,fetchUserInfo 可能被调用多次。
[⬆ 返回目录](#⬆ 返回目录)
6.2 正确做法:在 Store 里做「请求去重」
js
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
userInfo: null,
userInfoPromise: null // 用 Promise 做去重
}),
actions: {
async fetchUserInfo() {
if (this.userInfo) return this.userInfo
if (this.userInfoPromise) return this.userInfoPromise
this.userInfoPromise = api.getUserInfo()
try {
this.userInfo = await this.userInfoPromise
return this.userInfo
} finally {
this.userInfoPromise = null
}
}
}
})
守卫里只做「是否需要用户信息」的判断,拉取逻辑全部在 store 中,且通过 userInfoPromise 保证同一时刻只会有一个请求。
[⬆ 返回目录](#⬆ 返回目录)
6.3 更推荐:在应用启动或登录后拉取
js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { useUserStore } from './stores/user'
const app = createApp(App)
app.use(router)
router.isReady().then(() => {
const userStore = useUserStore()
if (userStore.token) {
userStore.fetchUserInfo() // 有 token 时预拉取
}
app.mount('#app')
})
这样:
- 用户信息在应用初始化时或登录后统一拉取
beforeEach只读userStore.userInfo,不做请求- 避免在路由切换时频繁发请求
[⬆ 返回目录](#⬆ 返回目录)
七、状态管理与路由守卫的配合规范
7.1 职责划分
| 层级 | 职责 | 示例 |
|---|---|---|
| 路由守卫 | 读状态、做校验、决定是否放行 | 判断 isLoggedIn、hasPermission |
| Store | 存状态、发请求、提供方法 | fetchUserInfo、logout |
| 组件 | 业务 UI、交互、调用 store | 调用 userStore.fetchUserInfo 等 |
[⬆ 返回目录](#⬆ 返回目录)
7.2 推荐的项目流程
用户打开页面
↓
main.js:有 token 时预拉取用户信息(store)
↓
路由跳转
↓
beforeEach:读 store 判断是否登录、是否有权限
↓
放行 → 进入目标组件
[⬆ 返回目录](#⬆ 返回目录)
八、完整实战示例
下面是一个「登录校验 + 动态标题 + 登录后回跳」的示例。
js
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/Login.vue'),
meta: { title: '登录', requiresAuth: false }
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
meta: { requiresAuth: true },
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: { title: '工作台' }
},
{
path: 'profile',
name: 'Profile',
component: () => import('@/views/Profile.vue'),
meta: { title: '个人中心' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
const whiteList = ['/login']
router.beforeEach((to, from, next) => {
// 1. 设置标题
const title = to.meta.title
document.title = title ? `${title} - 我的应用` : '我的应用'
// 2. 白名单直接放行(但要处理已登录访问登录页)
if (whiteList.includes(to.path)) {
const userStore = useUserStore()
if (to.path === '/login' && userStore.isLoggedIn) {
const redirect = (to.query.redirect || '/dashboard').toString()
next(redirect)
return
}
next()
return
}
// 3. 需要登录的路由
const userStore = useUserStore()
const requiresAuth = to.matched.some(r => r.meta.requiresAuth)
if (requiresAuth && !userStore.isLoggedIn) {
next({
path: '/login',
query: { redirect: to.fullPath }
})
return
}
next()
})
export default router
js
// stores/user.js
import { defineStore } from 'pinia'
export const useUserStore = defineStore('user', {
state: () => ({
token: localStorage.getItem('token'),
userInfo: null
}),
getters: {
isLoggedIn: (state) => !!state.token
},
actions: {
setUser(info, token) {
this.userInfo = info
this.token = token
if (token) localStorage.setItem('token', token)
},
logout() {
this.userInfo = null
this.token = null
localStorage.removeItem('token')
}
}
})
js
// main.js
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
import router from './router'
import { useUserStore } from './stores/user'
const app = createApp(App)
app.use(createPinia())
app.use(router)
router.isReady().then(() => {
const userStore = useUserStore()
if (userStore.token) {
userStore.fetchUserInfo?.() // 如有封装,在此预拉取
}
app.mount('#app')
})
[⬆ 返回目录](#⬆ 返回目录)
九、小结:beforeEach 使用清单
| 类别 | 内容 |
|---|---|
| 应该做 | 登录/权限校验、标题设置、白名单、轻量逻辑 |
| 不应该做 | 在守卫里发请求、复杂业务、重状态修改 |
| 避免死循环 | 白名单处理登录页、不要重定向到当前页 |
| 避免重复请求 | 请求放到 store,用 Promise 去重;优先在启动/登录后拉取 |
| next 使用 | 必须调用、只调用一次、每个分支都要调用 |
按以上规范来写 beforeEach,可以避免绝大多数路由守卫相关的坑,逻辑也会更清晰、好维护。
[⬆ 返回目录](#⬆ 返回目录)
🔍 系加粗样式列模块导航
📝 状态管理与路由规范
一、《Vue3 Pinia 状态管理规范:状态拆分、Actions 写法、持久化实战,避坑状态污染|状态管理与路由规范篇》
二、《Vue3 Pinia 状态管理规范:何时用 Pinia 何时用本地状态|状态管理与路由规范篇》
三、《Vue Router 实战规范:path/name/meta 配置 + 动态 / 嵌套路由,统一团队标准|状态管理与路由规范篇》
四、《Vue3 + Vue Router + Pinia 路由守卫规范:beforeEach 应做 / 不应做,避死循环、防重复请求|状态管理与路由规范篇》
五、《Vue keep-alive 实战避坑:include/exclude + 路由 meta 标记,中后台路由缓存精准可控|状态管理与路由规范篇》
👉 跟着系列慢慢学,把技术功底扎扎实实地打牢~
📚 系列总览
「前端规范实战系列 」正在持续更新中,后续会整理一篇《前端规范实战系列全系列目录导航》,包含每篇文章简介 + 直达链接,方便大家按顺序、体系化学习。
更新中,敬请期待~
[⬆ 返回目录](#⬆ 返回目录)
技术成长,从来不是比谁写得快,而是比谁写得稳、规范、可维护。
哪怕每次只吃透一条规范,长期下来,差距会非常明显。
后续我会持续更新前端规范、工程化、可维护代码相关实战干货,帮你告别面条代码、维护噩梦,在开发与面试中更有底气。
觉得有用欢迎 点赞 + 收藏 + 关注,不错过每一篇实战内容。
我是 Eugene,与你一起写规范、写优质代码,我们下篇干货见~