[vue-router]02-导航守卫与死循环陷阱

2 阅读18分钟

【Vue 路由系列 02】Vue Router 的导航系统:路由守卫与死循环陷阱

"针对登录页放行,非登录页才检验登录状态。" —— 这是我在模拟面试中对死循环问题的回答,方向对了但不够完整。

在上一篇中,我们搞清楚了前端路由的"物理层"——Hash 和 History 模式分别依赖什么浏览器 API、pushState 为什么不会触发 popstate。有了这个基础,本篇进入 Vue Router 的**"交通管制系统"**——路由守卫(Navigation Guards)。用不好会死循环,用好了一行代码就能实现权限控制。这篇文章把守卫体系、执行流程、导航过程中能做的所有事情(包括数据预取)、以及那些经典的坑全部讲清楚。


一、导航是什么

在 Vue Router 中,导航(Navigation) 指的是路由正在发生改变的过程:

用户点击链接 / 调用 router.push / 浏览器后退
    ↓
触发一个"导航"
    ↓
依次通过一系列的"钩子函数"(路由守卫)
    ↓
最终确认:导航成功(渲染新组件)或导航失败(留在当前页/跳到别处)

可以把导航想象成过安检

入口 → 安检1 → 安检2 → 安检3 → 登机口
         ↑        ↑        ↑
     全局守卫   路由独享   组件内守卫

任何一个安检点说"不行",你就过不去。


二、路由守卫的完整分类

Vue Router 提供三种层级的路由守卫:

2.1 全局前置守卫 router.beforeEach

const router = createRouter({ ... })

// 注册全局前置守卫(每次导航都会经过这里)
router.beforeEach((to, from, next) => {
  // to: 即将进入的目标路由对象
  // from: 当前正要离开的路由对象
  // next: 函数,调用后才能继续下一步导航
  
  console.log(`从 ${from.path} 导航到 ${to.path}`)
  
  next()  // 必须调用 next(),否则导航会卡住
})

2.2 全局解析守卫 router.beforeResolve

// 在导航被确认之前、所有组件内守卫和异步路由组件被解析之后调用
router.beforeResolve((to, from) => {
  // 常用于数据预取等需要在进入页面前完成的操作
  if (to.meta.requiresData) {
    return fetchData(to.params.id)
  }
})

2.3 全局后置钩子 router.afterEach

// 导航完成后调用(不接受 next 函数,因为这时已经结束了)
router.afterEach((to, from) => {
  // 常用于修改页面标题、上报埋点等
  document.title = to.meta.title || '默认标题'
})

2.4 路由独享守卫 beforeEnter

const routes = [
  {
    path: '/admin',
    component: Admin,
    // 只在进入 /admin 时生效
    beforeEnter: (to, from) => {
      if (!isAdmin()) {
        return { name: 'login' }  // 重定向到登录页
      }
    }
  }
]

2.5 组件内守卫

export default {
  // 进入组件时(在渲染该组件的路由被验证前调用)
  beforeRouteEnter(to, from) {
    // ⚠️ 此时组件实例还未创建!不能访问 this!
    
    // 如果需要访问组件实例,可以通过回调传递:
    return (vm) => {
      // vm 就是组件实例
      vm.setData(someData)
    }
  },
  
  // 路由改变但组件被复用时(如 /user/1 → /user/2)
  beforeRouteUpdate(to, from) {
    // ✅ 可以访问 this
    this.userId = to.params.id
    this.fetchUser()
  },
  
  // 离开组件时
  beforeRouteLeave(to, from) {
    // ✅ 可以访问 this
    
    // 典型场景:用户在有未保存的表单时试图离开
    if (this.hasUnsavedChanges && !confirm('确定要离开吗?未保存的数据会丢失')) {
      return false  // 取消导航
    }
  }
}

三、完整的执行顺序 —— ⚠️ 面试必考

这是整篇文章最重要的图。假设你从 /home 点击链接跳转到 /user/123


                 导航开始 (用户点击链接 / router.push / 浏览器后退)
                       ↓
 ① beforeRouteLeave (在离开的组件中,如果有)
                       ↓
   ② router.beforeEach (全局前置守卫)
                       ↓
   【目标组件是重用的吗?(如 /user/1 → /user/2)】
         ├─ 是 → ③ beforeRouteUpdate (组件内更新守卫)
         └─ 否 → 跳过
                       ↓
     ④ beforeEnter (路由独享守卫,在目标路由配置中)
                       ↓
     (异步路由组件开始加载,如有需要)
                       ↓
    ⑤ beforeRouteEnter (在进入的组件中,如果有)
                       ↓
     ⑥ router.beforeResolve (全局解析守卫)
                       ↓
                   导航被确认 ✓
                       ↓
     ⑦ router.afterEach (全局后置钩子)
                       ↓
                     DOM 更新
                       ↓
     ⑧ beforeRouteEnter 的回调执行(如果传入了回调)

记忆口诀(基于 Vue Router 官方文档):离 → 全局前 → 更新(如适用)→ 独享 → 入 → 全局解 → 全局后 → 入回调

📌 官方参考顺序:Vue Router 导航守卫的执行顺序是固定的,官方文档给出了明确的 12 步流程。掌握这个顺序是面试中的绝对加分项。

关键时间线示例(验证官方顺序)

// 场景:从 /a 导航到 /b(组件不同)

// 遵循 Vue Router 官方顺序:
// 1. 首先执行:离开组件的 beforeRouteLeave
const ComponentA = {
  beforeRouteLeave() { console.log(1, 'A组件离开(beforeRouteLeave)') }
}

// 2. 然后:全局 beforeEach
router.beforeEach((to, from) => {
  console.log(2, '全局前置(beforeEach):', to.path)
})

// 3. 如果 /b 配置了 beforeEnter(路由独享守卫)
{ path: '/b', component: B, beforeEnter: () => { console.log(3, '路由独享守卫(beforeEnter)') } }

// 4. 接下来:进入组件的 beforeRouteEnter(组件尚未创建,无法访问 this)
const ComponentB = {
  beforeRouteEnter(to, from) { 
    console.log(4, 'B组件进入(beforeRouteEnter)')
    // 如果需要访问组件实例,可以传回调:
    // next(vm => { console.log('组件实例:', vm) })
  }
}

// 5. 然后:全局 beforeResolve(在所有异步路由组件和组件内守卫解析之后调用)
router.beforeResolve(() => { console.log(5, '全局解析(beforeResolve)') })

// 6. 最后:全局 afterEach(导航完成后)
router.afterEach(() => { console.log(6, '全局后置(afterEach)') })

// 输出顺序:1 → 2 → 3 → 4 → 5 → 6

💡 注意:如果目标组件是重用的(例如 /user/1/user/2),那么会先执行 beforeRouteUpdate,而不是 beforeRouteLeavebeforeRouteEnter


四、next() 的用法详解 —— ⚠️ 另一个面试重灾区

4.1 Vue Router 3 vs Vue Router 4 的变化

这是很多人不知道的重要差异:

Vue Router 3(next 是必须的)

// Vue Router 3 的写法
router.beforeEach((to, from, next) => {
  if (to.meta.auth && !isLoggedIn()) {
    next('/login')     // 重定向
    // 或
    next(false)        // 取消导航
    // 或
    next()             // 放行
  } else {
    next()
  }
})

⚠️ Vue Router 3 中如果不调用 next(),导航会永远挂起——白屏!

Vue Router 4(推荐 return,不再强制要求 next)

// Vue Router 4 的新写法(更清晰)
router.beforeEach((to, from) => {
  if (to.meta.auth && !isLoggedIn()) {
    // 直接 return 重定向目标
    return { name: 'login', query: { redirect: to.fullPath } }
  }
  
  // 或者 return true 表示放行
  return true
  
  // 或者 return false 表示取消
  // return false
})

// 当然,next 仍然兼容(向后支持)
router.beforeEach((to, from, next) => {
  if (to.meta.auth && !isLoggedIn()) {
    next('/login')
  } else {
    next()
  }
})

4.2 next() 的所有用法一览

调用方式效果
next()放行,导航正常进行
next(false)取消导航,留在当前页面
next('/home')next({ path: '/home' })重定向到 /home
next(error)导航终止,传递给 router.onError 回调

4.3 ⚠️ 经典错误:多次调用 next

// ❌ 错误写法:条件分支都调用了 next
router.beforeEach((to, from, next) => {
  if (to.path === '/login') {
    next()          // 第一次调用
  }
  // 这里没有 else!所以下面的代码也会执行
  if (!isLogin()) {
    next('/login')  // 第二次调用 💥 报错!
  }
  next()           // 可能第三次调用 💥
})

// ✅ 正确写法:确保 next 只调用一次
router.beforeEach((to, from, next) => {
  if (to.path === '/login') {
    return next()  // return 确保后续代码不执行
  }
  if (!isLogin()) {
    return next('/login')
  }
  next()
})

Vue Router 4 用 return 替代 next 很大程度上就是为了避免这个问题。


五、死循环陷阱 —— 我在面试中答对的题

5.1 经典死循环场景

这是最常见的面试题之一:

// ❌ 死循环代码
router.beforeEach((to, from, next) => {
  if (!isLogin()) {
    next('/login')  // 未登录 → 跳转登录页
  } else {
    next()
  }
})

为什么死循环?

用户未登录,访问 /dashboard
    ↓
beforeEach 触发:!isLogin() === true
    ↓
next('/login') → 开始跳转到 /login
    ↓
beforeEach 再次触发(因为又是新导航)!
    ↓
!isLogin() === true(还是未登录)
    ↓
next('/login') → 又开始跳转到 /login
    ↓
beforeEach 又触发…… ♻️♻️♻️ 无限循环!

5.2 我的回答 vs 标准答案

我在面试中的回答

"针对登录页放行,非登录页才检验登录状态。"

方向完全正确! 但需要补充完整:

// ✅ 正确写法:对登录页放行
router.beforeEach((to, from) => {
  const whiteList = ['/login', '/register']  // 白名单
  
  // 白名单内的路径直接放行
  if (whiteList.includes(to.path)) {
    return true
  }
  
  // 非白名单检查登录状态
  if (!isLogin()) {
    // 未登录,重定向到登录页,并记录原路径用于登录后回跳
    return {
      path: '/login',
      query: { redirect: to.fullPath }
    }
  }
  
  // 已登录,放行
  return true
})

5.3 还有一个隐蔽的死循环场景

// ❌ 同样是死循环,但更隐蔽
router.beforeEach((to, from, next) => {
  if (to.path !== '/login' && !isLogin()) {
    next('/login')
  } else {
    next()
  }
})

// 问题在哪?
// 当 isLogin() 内部依赖 token 判断,
// 而 token 存储在 Pinia/Vuex 里,
// F5 刷新后 store 被重置,isLogin() 返回 false
// 即使已经在 /login 页面了,to.path !== '/login' 为 true
// 又要 next('/login')……

防御性写法

// ✅ 双重保险:白名单 + 目标路径判断
router.beforeEach((to, from) => {
  // 1. 先看是不是公开页面(白名单优先级最高)
  if (isPublicPage(to)) {
    return true
  }
  
  // 2. 再检查是否已登录
  if (!isLogin()) {
    // ⚠️ 只有当目标不是登录页时才重定向
    if (to.name !== 'login') {
      return { name: 'login', query: { redirect: to.fullPath } }
    }
    // 已经要去登录页了,直接放行(避免循环)
    return true
  }
  
  return true
})

六、并发竞态问题 —— 我在这里翻了大车

6.1 面试题回顾

Qwen 问:多个路由几乎同时触发 beforeEach(比如快速连续点击),你的权限校验逻辑怎么避免竞态?

我的回答

"把 Promise 缓存在 localStorage 里……"

❌ 这完全是错误的!

6.2 为什么 localStorage 不能存 Promise

特性内存变量localStorage
数据类型任意 JS 对象只能存字符串
序列化不需要自动 JSON.stringify/parse
生命周期F5 刷新后消失永久存储(除非手动删)
能否存 Promise✅ 可以不可以! Promise 无法序列化
// ❌ 完全无效的代码
const p = fetch('/api/user/token')
localStorage.setItem('authPromise', p)  // 存进去的是 "[object Promise]" 字符串!
const restored = localStorage.getItem('authPromise')  // 取出来是字符串,不是 Promise
await restored  // 不会有任何等待效果,因为它不是 Promise

而且即使能存,F5 刷新后整个 JS 运行时都被销毁了,内存里的 Promise 早就没了。

6.3 正确的解决方案

方案一:内存单例 Promise(适用于 SPA 内部导航)
// 在模块级别维护一个 Promise 引用
let authCheckPromise = null

router.beforeEach(async (to) => {
  if (isPublicPage(to)) return true
  
  // 如果已经有一个正在进行的校验请求,直接复用它
  if (authCheckPromise) {
    await authCheckPromise
    return isLogin() ? true : { name: 'login' }
  }
  
  // 发起新的校验请求
  authCheckPromise = checkAuth()  // checkAuth 返回 Promise
  
  try {
    await authCheckPromise
    return isLogin() ? true : { name: 'login' }
  } finally {
    authCheckPromise = null  // 无论成败,都要清空引用
  }
})

这个方案如何解决竞态?

时刻 T=0ms:   用户点击 /dashboard
              ↓
              beforeEach 触发
              authCheckPromise = fetch('/api/check-auth')
              ↓
时刻 T=50ms:  用户又点击 /settings(快速连点)
              ↓
              beforeEach 触发
              发现 authCheckPromise 不为 null!
              ↓
              await authCheckPromise  ← 复用同一个 Promise!
              (不会发第二个请求,等第一个的结果就行)
              
时刻 T=100ms: 第一个 fetch 返回
              两个 beforeEach 同时拿到结果
              各自独立决定放行还是拦截
方案二:F5 刷新后的处理

F5 刷新后 JS 上下文重建,内存中的 authCheckPromise 肯定是空的。这种情况下:

// 方案 A:让后端做(推荐)
// 后端接口设置 Cache-Control: max-age=300(5分钟强缓存)
// 这样短时间内重复请求不会真正到达后端

// 方案 B:前端请求去重
function createDedupedFetch(key, fn) {
  const cache = new Map()
  return async (...args) => {
    if (cache.has(key)) return cache.get(key)
    const promise = fn(...args).finally(() => cache.delete(key))
    cache.set(key, promise)
    return promise
  }
}

6.4 完整的权限校验模板

综合以上所有要点,一份生产级别的 beforeEach 写法:

// auth.js
let pendingCheck = null

export function setupRouterGuards(router) {
  router.beforeEach(async (to, from) => {
    // ===== 第一关:白名单放行 =====
    const publicPages = ['/login', '/register', '/forgot-password']
    if (publicPages.includes(to.path)) {
      // 已登录状态下访问登录页 → 重定向首页
      if (isLogin() && to.path === '/login') {
        return { path: '/' }
      }
      return true
    }
    
    // ===== 第二关:Token 存在性检查(同步,立即返回)=====
    const token = getToken()
    if (!token) {
      return { 
        path: '/login', 
        query: { redirect: to.fullPath } 
      }
    }
    
    // ===== 第三关:Token 有效性检查(异步,可能网络请求)=====
    // 使用单例 Promise 避免快速导航时的重复请求
    if (!pendingCheck) {
      pendingCheck = validateToken(token).finally(() => {
        pendingCheck = null
      })
    }
    
    try {
      const isValid = await pendingCheck
      if (!isValid) {
        clearToken()
        return { 
          path: '/login', 
          query: { redirect: to.fullPath } 
        }
      }
      return true
    } catch (error) {
      // 网络异常等情况:允许通行还是阻止?根据业务决定
      console.error('Token 校验失败:', error)
      return true  // 保守策略:网络问题时先让用户进
    }
  })
  
  router.afterEach((to) => {
    // 设置页面标题
    document.title = to.meta.title || 'My App'
  })
}

七、导航过程中还能做什么:数据预取模式

守卫不只是用来做权限校验的。在导航流程中,什么时候获取页面数据是一个经典的前端架构决策。

7.1 三种数据预取模式对比

模式获取时机优点缺点适用场景
导航时获取(守卫中)beforeEach / beforeResolve数据就绪后才渲染,体验一致导航前有等待时间,用户可能觉得"卡了"首屏数据必须就绪的页面
组件内获取(created/mounted)组件生命周期导航立即完成,先展示骨架屏数据到达前是空态,需要 loading 状态大多数 CRUD 页面
导航后获取(异步)组件 mounted + 异步请求最快的首屏渲染布局闪烁(FOUC)非关键数据、次要内容

7.2 模式一:导航时获取(在守卫中预取)

这是"数据没到就不让进页面"的策略:

// 方式 A:使用 beforeResolve(推荐用于数据预取)
// beforeResolve 在所有组件内守卫之后、导航确认之前触发
// 此时已经知道要进入哪个组件,适合做数据预取
router.beforeResolve(async (to) => {
  // 只有目标路由标记了需要预取数据才执行
  if (to.meta.requiresData) {
    const store = useDataStore()
    
    // 如果数据还没加载过,则加载
    if (!store.hasData(to.params.id)) {
      // 显示全局 loading(可选)
      showLoading()
      
      try {
        await store.fetchDetail(to.params.id)
      } finally {
        hideLoading()
      }
    }
  }
})
// 路由配置中标记需要预取
{
  path: '/user/:id',
  component: UserDetail,
  meta: { requiresData: true, title: '用户详情' }
}

⚠️ 注意:不要在 beforeEach 中做重量级数据请求!

beforeEach 是全局前置守卫,每次导航都会经过。如果把数据请求放在这里:

  • 即使只是从 /user/1 切换到 /user/2(同一组件复用),也会重新请求
  • 如果用户快速连续点击,会触发多个并发请求

正确的分层

  • beforeEach → 只做轻量级判断(登录检查、权限验证)
  • beforeResolve → 做数据预取(此时已确定目标组件)

7.3 模式二:组件内获取(最常用)

这是大多数 Vue 项目采用的策略:

<template>
  <div v-if="loading" class="skeleton">骨架屏...</div>
  <div v-else-if="error" class="error">加载失败: {{ error }}</div>
  <div v-else>
    <!-- 真实内容 -->
    <h1>{{ user.name }}</h1>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { api } from '@/api'

const route = useRoute()
const loading = ref(true)
const error = ref(null)
const user = ref({})

onMounted(async () => {
  try {
    user.value = await api.getUser(route.params.id)
  } catch (e) {
    error.value = e.message
  } finally {
    loading.value = false
  }
})
</script>

为什么这种方案最流行?

  1. 导航即时响应——用户点击后立刻切换页面,感知延迟低
  2. 配合 Suspense——Vue 3 的 <Suspense> 可以优雅处理异步组件的加载态
  3. 关注点分离——数据逻辑在组件内部,不在路由配置里

7.4 模式对比:如何选择?

你的页面属于哪种类型?
    │
    ├─ SEO 要求高?(SSR 场景)
    │   └─ → 必须用"导航时获取",服务端等数据到了再返回 HTML
    │
    ├─ 首屏数据必须是完整的?(如报表页、审批详情)
    │   └─ → 用 beforeResolve 预取 + 全局 loading
    │
    ├─ 用户可容忍短暂空态?(列表页、表单页)
    │   └─ → 用组件内 onMounted 获取 + 骨架屏/Loading 态
    │
    └─ 数据量大且非核心?(评论、推荐)
        └─ → 用导航后懒加载,甚至滚动到底部时再触发

八、Navigation Failure:导航失败的类型判别

8.1 什么是 Navigation Failure

当路由守卫返回 false 或重定向路径时,导航会被取消或中断。Vue Router 4 会把这个行为包装成一个特殊的错误对象:

// ❌ 这种写法无法区分"真正的错误"和"正常的导航取消"
router.beforeEach(async (to) => {
  if (!isLogin()) {
    return { name: 'login' }
  }
})

// 在 router.push 之后
try {
  await router.push('/admin')
} catch (e) {
  console.error('导航失败:', e)  
  // 但这个 e 到底是网络错误?还是被 beforeEach 拦截了?
  // 无法区分!
}

8.2 isNavigationFailure 来救场

import { isNavigationFailure, NavigationFailureType } from 'vue-router'

try {
  await router.push('/dashboard')
} catch (e) {
  // 判断是不是"导航失败"(而非真正的异常)
  if (isNavigationFailure(e)) {
    // 是导航层面的失败(被拦截/重定向/取消),不是 bug
    console.log('导航被拦截,原因:', e.type)
    
    // 还可以精确判断是哪种类型
    if (e.type === NavigationFailureType.redirected) {
      console.log('被重定向到了:', e.to.path)
    } else if (e.type === NavigationFailureType.aborted) {
      console.log('导航被取消了(return false)')
    } else if (e.type === NavigationFailureType.duplicated) {
      console.log('重复导航到相同路径(无操作)')
    }
  } else {
    // 这是真正的异常!需要上报或提示用户
    console.error('导航过程出现异常:', e)
  }
}

8.3 四种 Navigation Failure 类型

类型常量触发场景是否应该视为错误
redirectedNavigationFailureType.redirected守卫返回了重定向对象❌ 正常业务行为
abortedNavigationFailureType.aborted守卫返回了 false❌ 正常业务行为
cancelledNavigationFailureType.cancelled一个新的导航取代了当前进行中的导航(如快速连点)⚠️ 视情况处理
duplicatedNavigationFailureType.duplicated导航到的位置与当前完全相同❌ 完全无害

8.4 实际应用场景

// 封装一个安全的导航方法
async function safeNavigate(router, target) {
  try {
    await router.push(target)
    return true  // 导航成功
  } catch (e) {
    if (isNavigationFailure(e, NavigationFailureType.redirected)) {
      // 被重定向(如跳转到登录页),不需要报错
      return false
    }
    if (isNavigationFailure(e, NavigationFailureType.duplicated)) {
      // 重复导航,忽略即可
      return true
    }
    if (isNavigationFailure(e, NavigationFailureType.aborted)) {
      // 被守卫取消,可能有业务逻辑需要处理
      console.warn('导航被守卫取消')
      return false
    }
    // 其他是真正需要关注的错误
    throw e
  }
}

// 使用
const success = await safeNavigate(router, { name: 'dashboard' })
if (!success) {
  // 可以在这里做降级处理
}

面试加分点:提到 isNavigationFailure 说明你对 Vue Router 的错误处理机制有深入理解。很多人只知道 beforeEach 能拦截导航,但不知道如何在 router.push() 的调用端区分"被拦截"和"真报错"。


九、组合式 API 中的路由守卫

9.1 Composition API 的路由钩子函数

Vue Router 4 提供了一组专门用于 <script setup> / setup() 的组合式函数:

<script setup>
import { 
  onBeforeRouteLeave,   // 对应 Options API 的 beforeRouteLeave
  onBeforeRouteUpdate   // 对应 Options API 的 beforeRouteUpdate
} from 'vue-router'
import { ref, onMounted } from 'vue'

const userId = ref(null)

// ✅ 组合式 API 中没有 onBeforeRouteEnter!
// 因为 setup() 在组件创建后执行,不存在"进入前"的概念
// 如果需要类似功能,请用 onMounted 替代

// 路由改变但组件被复用时(如 /user/1 → /user/2)
onBeforeRouteUpdate((to) => {
  // ✅ 这里可以访问 this(通过变量闭包)
  userId.value = to.params.id
  fetchUserData(to.params.id)
})

// 离开当前路由时
onBeforeRouteLeave((to) => {
  // ✅ 也可以访问组件内部状态
  if (hasUnsavedChanges.value && !confirm('确定离开?')) {
    return false  // 取消导航
  }
})
</script>

9.2 ⚠️ 没有 onBeforeRouteEnter

这是一个重要的区别:

守卫Options APIComposition API
beforeRouteEnter✅ 有没有替代品
beforeRouteUpdate✅ 有onBeforeRouteUpdate
beforeRouteLeave✅ 有onBeforeRouteLeave

为什么没有 onBeforeRouteEnter

因为 beforeRouteEnter 的设计初衷是在组件实例创建之前执行回调,它通过回调参数传递组件实例:

// Options API 的写法(Composition API 不支持)
beforeRouteEnter(to, from) {
  // 这里的 this 是 undefined!组件还没创建
  // 只能通过回调访问实例
  return (vm) => {
    vm.setData(someData)  // vm 就是组件实例
  }
}

而在 <script setup> 中,代码本身就在 setup() 里执行,相当于组件已经在初始化过程中了,不存在"进入之前"的时间点。

如果确实需要在进入前做某些事

<script setup>
// 方案一:用 onMounted 代替(绝大多数场景够用)
import { onMounted } from 'vue'

onMounted(() => {
  // 组件首次挂载时执行,效果类似于 beforeRouteEnter
  initData()
})

// 方案二:配合 inject 从路由守卫中接收数据
// 在 beforeEach 中把数据存入 route.meta 或 provide
</script>

十、导航流程的状态机视角

还记得我们在静默搜索复盘笔记中讨论过的"隐式状态机"吗?路由导航同样可以用状态机建模:

                        触发导航(push/点击链接/后退)
                                   ↓
                              PENDING(待决态)
                                   ↓
                         ┌─────────┴─────────┐
                         ↓                   ↓
                  通过所有守卫              被某个守卫拦截
                         ↓                   ↓
                      CONFIRMED             CANCELLED
                    (确认导航)           (取消/重定向)
                         ↓                   
                    组件加载+渲染                  
                         ↓                   
                    COMPLETED(完成)            

面试中如果被问到"你的路由守卫是怎么组织的",你可以这样描述

我的路由守卫分为三层:第一层是全局 beforeEach 做统一的鉴权逻辑(白名单放行 + Token 校验),第二层是路由级别的 beforeEnter 做特定页面的权限粒度控制(如管理员页面),第三层是组件内的 beforeRouteLeave 做脏数据保护。导航过程中使用内存单例 Promise 来防止并发竞态,确保同一时刻只有一个 Token 校验请求在飞行。


十一、本篇小结

概念一句话记忆
路由守卫执行顺序(官方)离 → 全局前 → 更新(如适用)→ 独享 → 入 → 全局解 → 全局后 → 入回调
① beforeRouteLeave → ② beforeEach → ③ beforeRouteUpdate(如适用)→ ④ beforeEnter → ⑤ beforeRouteEnter → ⑥ beforeResolve → ⑦ afterEach → ⑧ beforeRouteEnter回调
Vue Router 4 新特性推荐 return true/false/路由对象,不再强制 next()
next() 多次调用绝对禁止!用 return 确保只执行一次
死循环原因登录拦截器没有排除登录页本身 → 跳转到登录页又触发拦截器 → 再跳转到登录页 → 无限循环
解决死循环白名单放行:登录页/注册页不检查登录状态
并发竞态快速连续导航导致多次权限校验请求
❌ localStorage 存 Promise不可能!localStorage 只能存字符串,且 Promise 无法序列化
✅ 正确做法模块级别单例 Promiselet pendingCheck = null,复用同一个 Promise
beforeRouteEnter 不能访问 this因为此时组件还没创建;如需访问用回调:return (vm) => { ... }
beforeRouteLeave 的经典用途有未保存表单时阻止用户离开
数据预取三模式导航时(beforeResolve) vs 组件内(onMounted) vs 导航后(异步);按需选择
isNavigationFailure区分"导航被拦截"(正常业务)和"真正的异常"(需要处理)
组合式 API 守卫onBeforeRouteUpdate + onBeforeRouteLeave没有 onBeforeRouteEnter(用 onMounted 代替)

下一篇预告:搞清楚了导航流程中的守卫系统,下一篇我们进入动态路由与权限控制——这是 Vue Router 在企业级 SaaS 项目中最核心的应用场景。addRoute 怎么用?F5 刷新后为什么 404?404 通配符应该在什么时候挂载?以及——在讲 addRoute 之前,我会先把 Vue Router 的完整路由体系(嵌套路由、编程式导航、命名路由、动态参数)系统梳理一遍。

👉 Vue 路由系列 03:动态路由与权限控制 —— 路由体系全貌 + addRoute + 404 问题标准解法

🔗 回顾上一篇:Vue 路由系列 01:前端路由的本质 —— Hash vs History vs Abstract 三种模式


参考来源:Gemini 3.1 Pro 模拟面试记录(路由与单页应用章节)、Vue Router 4 官方文档