前置知识: JavaScript、HTML5、CSS

Vue-Router 导航守卫

10 min高级

Vue Router 导航守卫详解:全局守卫、路由独享守卫、组件内守卫、触发顺序与鉴权实践。

前置知识

学习目标

  • 掌握「1. 历史动机与发展脉络」的核心机制、典型用法与常见陷阱
  • 掌握「2. 形式化定义」的核心机制、典型用法与常见陷阱
  • 掌握「3. 理论推导与原理解析」的核心机制、典型用法与常见陷阱
  • 掌握「4. 代码示例(带详尽注释)」的核心机制、典型用法与常见陷阱
  • 掌握「5. 对比分析」的核心机制、典型用法与常见陷阱

1. 历史动机与发展脉络

Vue Router 从 0.x 时代起就提供导航守卫,用于在路由切换前执行校验。Vue Router 3(配合 Vue 2)使用 next() 回调风格:守卫函数接收 (to, from, next) 三个参数,必须调用 next() 放行,否则导航悬挂。这种风格的问题在于:忘记调用 next() 导致页面白屏;异步逻辑中重复调用 next() 导致不可预测行为;next('error') 等特殊用法晦涩。

Vue Router 4(2021 年随 Vue 3 发布)改进了守卫协议:守卫函数返回一个值来描述导航结果,不再依赖回调。返回值协议如下:

当前稳定版为 Vue Router 5,返回值协议与 4.x 保持一致,本文示例在 5.x 下同样成立。

返回 true 或 undefined:放行导航;

返回 false:取消当前导航,URL 不变;

返回一个路由地址对象(字符串路径、{ name: ... } 或 { path: ... }):重定向到该地址;

返回一个 Error 实例或 { error: Error }:导航失败,错误由 router.isReady 或导航 promise 捕获。

Vue Router 4.1 之后,next 回调风格被标记为遗留 API,官方文档明确推荐返回值风格。Vue Router 4.5 时代(2025 年后)进一步强化了 TypeScript 类型支持与 RouteLocationRaw 推导,守卫中的类型错误可以在编译期暴露。

timeline
    title Vue Router 守卫协议演进
    2016 : Vue Router 2 引入 next() 回调
    2020 : Vue Router 4 引入返回值协议
    2021 : Vue Router 4.0 正式随 Vue 3 发布
    2022 : 4.1 标记 next() 为遗留 API
    2025 : 守卫类型推导完善,推荐返回值风格

2. 形式化定义

导航守卫是挂载在路由解析管线上的函数序列。一次导航 N(从路由 from 到路由 to)的完整执行顺序如下:

第一步,失活组件触发 beforeRouteLeave 守卫(从 from 的组件树中按深度优先从内到外执行);

第二步,全局 beforeEach 守卫按注册顺序执行;

第三步,目标路由配置中的 beforeEnter 守卫执行(若路由有多个记录,按记录顺序执行);

第四步,被复用的组件触发 beforeRouteUpdate;

第五步,目标组件的 beforeRouteEnter 执行;

第六步,全局 beforeResolve 守卫执行(此时所有异步组件与懒加载路由已解析);

第七步,导航被确认;

第八步,全局 afterEach 执行(不接收返回值,不能取消导航);

第九步,DOM 更新完成,beforeRouteEnter 中传入的 next 回调(若有)接收组件实例执行。

守卫执行模型:每个守卫的返回值决定导航状态机转移。若任一守卫返回 false 或抛出错误,导航中止,from 路由保持不变;若返回路由地址,则以该地址为目标重新执行完整管线(会再次触发全局守卫,因此需要防循环设计)。

flowchart TD
    A["导航开始"] --> B["beforeRouteLeave(失活组件)"]
    B --> C["全局 beforeEach"]
    C --> D["路由独享 beforeEnter"]
    D --> E["beforeRouteUpdate(复用组件)"]
    E --> F["beforeRouteEnter(目标组件)"]
    F --> G["全局 beforeResolve"]
    G --> H{"守卫链结果"}
    H -- "全部放行" --> I["确认导航,DOM 更新"]
    H -- "返回 false 或抛错" --> J["取消导航,URL 不变"]
    H -- "返回路由地址" --> K["重定向,重新执行管线"]
    I --> L["afterEach(全局后置)"]

3. 理论推导与原理解析

3.1 导航状态机

Vue Router 4 内部用 promise 链串联守卫。每个守卫被包装为 guard(to, from) 调用,返回值按协议归一化:false 映射为取消信号,字符串/对象映射为 NavigationFailure 或新地址,错误映射为失败。

推导:设守卫序列 G1..Gn,导航成功当且仅当所有 Gi 返回真值或 undefined,且重定向目标 R 与当前目标不同。若 Gk 返回地址 R,则管线在第 k 步停止并重启:navigate(R, from=to)。为了避免无限循环,Vue Router 在重定向超过一定次数或回到同一地址时抛出 NavigationFailureType.duplicated 或重定向循环错误。

3.2 为什么 afterEach 不能取消导航

afterEach 在导航确认后执行,此时 URL 已更新、组件即将挂载。如果允许取消,会产生状态不一致:URL 显示新地址而组件仍是旧组件。因此 afterEach 只用于副作用(埋点、标题、滚动位置),返回值被忽略。

3.3 守卫中的异步与加载态

守卫函数可以返回 promise,管线会 await。工程上需要在等待期间展示加载状态,例如用全局 loading bar。Vue Router 4 没有内置 loading 组件,通常结合 router.beforeEach 的开始事件与 afterEach/onError 的结束事件控制进度条,这也是 nprogress 集成的标准做法。

4. 代码示例(带详尽注释)

4.1 全局前置守卫:登录鉴权

import { createRouter, createWebHistory } from 'vue-router'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/login', name: 'login', component: Login },
    {
      path: '/admin',
      component: AdminLayout,
      // meta 字段存放自定义权限信息,供守卫读取
      meta: { requiresAuth: true, roles: ['admin'] },
      children: [{ path: '', name: 'admin', component: AdminHome }]
    }
  ]
})

// 全局前置守卫:每次导航都会执行
router.beforeEach(async (to, from) => {
  // 白名单:登录页不需要鉴权
  if (to.name === 'login') return true

  // 读取本地登录令牌;真实项目应校验令牌有效性
  const token = localStorage.getItem('token')
  if (!token) {
    // 未登录:重定向到登录页,并携带来源路径便于登录后跳回
    return { name: 'login', query: { redirect: to.fullPath } }
  }

  // 路由需要权限但当前用户角色不满足
  const userRole = localStorage.getItem('role')
  const requiredRoles = to.meta.roles as string[] | undefined
  if (requiredRoles && (!userRole || !requiredRoles.includes(userRole))) {
    // 无权限:返回 false 取消导航,停留在当前页
    return false
  }

  // 默认放行
  return true
})

export default router

讲解:守卫通过 to.meta 读取路由配置中的自定义字段,实现声明式权限声明。return { name: 'login', query: { redirect: to.fullPath } } 使用返回值协议完成重定向;return false 直接取消导航。整个守卫是纯 async 函数,逻辑清晰且可测试。

4.2 动态加载用户信息的守卫

// 使用 Pinia 或全局状态保存用户信息
import { useUserStore } from '@/stores/user'

router.beforeEach(async (to) => {
  const userStore = useUserStore()

  // 已加载过用户信息则直接放行,避免每次导航都请求
  if (userStore.loaded) return true

  // 首次导航时拉取用户信息;失败则跳转登录
  try {
    await userStore.fetchProfile()
    return true
  } catch {
    return { name: 'login' }
  }
})

讲解:把“用户信息初始化”放进守卫,保证任何页面进入前数据就绪,页面组件不再各自处理加载失败。userStore.loaded 标志避免重复请求。

4.3 路由独享守卫 beforeEnter

const routes = [
  {
    path: '/reports/:id',
    component: ReportView,
    // 只对该路由生效
    beforeEnter: (to) => {
      const id = Number(to.params.id)
      // 参数不是正整数时直接取消导航
      if (!Number.isInteger(id) || id <= 0) {
        return { name: 'not-found' }
      }
      return true
    }
  }
]

讲解:beforeEnter 只在直接进入该路由时执行;从该路由切换到该路由(仅参数变化)时不会重新执行,此时应使用组件内 beforeRouteUpdate。这是初学者最容易混淆的点。

4.4 组件内守卫:离开确认

<script setup>
import { onBeforeRouteLeave } from 'vue-router'

// 表单脏数据标记:有未保存修改时提示
const dirty = ref(false)

// 离开前确认:返回 false 阻止离开
onBeforeRouteLeave((to, from) => {
  if (!dirty.value) return true
  // 浏览器原生 confirm 弹窗;工程中可替换为自定义对话框
  const ok = window.confirm('当前修改尚未保存,确定离开吗?')
  return ok
})
</script>

讲解:onBeforeRouteLeave 在组合式 API 中直接调用,无需组件选项。返回 false 阻止离开;返回 true 放行。注意该守卫不能阻止浏览器刷新或关闭标签页,那需要 beforeunload 事件配合。

4.5 组件内守卫:参数变化响应

<script setup>
import { ref } from 'vue'
import { onBeforeRouteUpdate } from 'vue-router'

const articleId = ref(null)

// 同一组件在不同路由参数间切换时触发
onBeforeRouteUpdate(async (to, from) => {
  // 参数变化时重新加载数据
  articleId.value = to.params.id
  await loadArticle(to.params.id)
})
</script>

讲解:/article/1 切换到 /article/2 时组件会被复用而非重新创建,onMounted 不会再次执行,因此必须用 beforeRouteUpdate 或监听 route.params 处理数据刷新。

4.6 全局后置守卫:页面标题

// 根据路由 meta.title 设置 document.title
router.afterEach((to) => {
  const baseTitle = 'FANDEX 学习平台'
  document.title = to.meta.title ? `${to.meta.title} - ${baseTitle}` : baseTitle
})

讲解:afterEach 适合做与导航结果无关的副作用。标题设置不依赖返回值,即使导航失败也最好不执行——注意 afterEach 在导航失败时不会触发,错误需由 router.onError 处理。

4.7 异步权限 + 动态路由

// 登录后根据角色动态添加路由
router.beforeEach(async (to) => {
  const userStore = useUserStore()
  if (!userStore.token) return { name: 'login' }

  // 尚未注册动态路由时,请求权限列表并注册
  if (!userStore.routesLoaded) {
    const routes = await fetchUserRoutes(userStore.role)
    routes.forEach((r) => router.addRoute(r))
    userStore.routesLoaded = true
    // 重新导航到目标,此时新路由已注册
    return { ...to, replace: true }
  }
  return true
})

讲解:router.addRoute 动态注册路由后,必须重新发起导航,否则目标路由仍找不到。{ ...to, replace: true } 保留目标地址并避免历史记录污染。这是大型后台系统的标准权限路由模式。

5. 对比分析

5.1 守卫层级对比

守卫作用域触发时机典型用途
beforeEach全局每次导航开始登录态、白名单、埋点开始
beforeEnter单路由直接进入该路由参数校验、单路由权限
beforeRouteUpdate组件参数变化但组件复用数据刷新
beforeRouteEnter组件进入组件前进入前校验(拿不到 this)
beforeResolve全局所有异步解析后数据预取、最终确认
afterEach全局导航确认后标题、埋点、滚动

5.2 返回值风格与 next 回调风格对比

维度返回值风格next 回调风格
可读性返回即结果需要跟踪调用位置
错误处理try/catch 自然生效容易漏调或重复调用
TypeScript类型推导完整类型弱
官方态度推荐遗留 API

5.3 守卫与中间件对比

守卫本质上是路由级中间件。与 Express/Koa 中间件相比,Vue Router 守卫少了 next 链式调用,多了返回值协议;与 Nuxt 的 route middleware 相比,Vue Router 守卫更底层,Nuxt 在其上封装了 definePageMeta 声明式中间件。理解底层守卫后,上层框架的中间件行为可以自然推导。

6. 常见陷阱与最佳实践

陷阱一:忘记返回真值。守卫函数没有 return 时返回 undefined,等价放行——但若逻辑分支遗漏,会出现“看起来没执行校验”的问题。最佳实践:让每个分支显式 return。

陷阱二:守卫循环。登录页守卫又重定向到登录页,形成死循环。最佳实践:对登录页等公开路由提前放行。

陷阱三:在 beforeRouteEnter 中访问 this。组件实例尚未创建,this 为 undefined。最佳实践:通过 (to, from, next) => { next((vm) => { /* 此时才能访问组件实例 */ }) },或改用组合式 API 的 onBeforeRouteEnter 并配合外部状态。

陷阱四:把数据请求放进守卫导致白屏过长。最佳实践:区分“必须前置的数据”(权限、登录态)与“可以后置的数据”(列表内容),后者交给组件内部加载。

陷阱五:beforeEach 中重复注册。模块热更新或代码重复执行会导致守卫栈叠加。最佳实践:守卫注册放在路由实例创建处,一次注册。

陷阱六:忽略 router.onError。异步守卫抛出未捕获异常时,导航失败且无提示。最佳实践:全局注册 router.onError 统一处理,并区分鉴权过期与网络错误。

7. 工程实践

7.1 守卫文件组织

大型项目按职责拆分守卫:

src/router/
  index.ts          # 创建路由实例并汇总守卫
  guards/
    auth.ts         # 登录鉴权守卫
    permission.ts   # 角色权限守卫
    progress.ts     # 进度条与埋点守卫
    title.ts        # 页面标题守卫

每个守卫文件导出 export const authGuard = (to, from) => {...} 形式的纯函数,在 index.ts 中按顺序 router.beforeEach(authGuard); router.beforeEach(permissionGuard)。这种组织方式让守卫可单测、可复用。

7.2 守卫的可测试性

守卫是纯函数(除副作用外),可以脱离路由实例测试:

// 测试 authGuard:未登录重定向到 login
import { describe, it, expect, vi } from 'vitest'
import { authGuard } from './auth'

describe('authGuard', () => {
  it('未登录时重定向到登录页', async () => {
    localStorage.clear()
    const to = { name: 'admin', fullPath: '/admin' } as any
    const from = {} as any
    const result = await authGuard(to, from)
    expect(result).toMatchObject({ name: 'login' })
  })
})

讲解:测试替身对象模拟 to/from,断言返回值。守卫逻辑越纯,测试成本越低,这也是官方推荐返回值风格带来的工程红利。

7.3 与 Teleport/KeepAlive 的协作

路由切换会卸载旧组件、挂载新组件。若项目使用 KeepAlive 缓存页面,beforeRouteLeave 不会销毁组件,而是进入缓存;此时配合 onActivated/onDeactivated 处理数据刷新。浮层类组件(模态框)在路由离开时应关闭,可以在 beforeRouteLeave 中同步状态或让模态框组件监听路由变化。

8. 案例研究:完整权限系统

需求:实现包含登录、角色权限、动态路由、无权限页面、登录过期处理的完整权限链路。

// guards/permission.ts:权限守卫完整实现
import type { NavigationGuard } from 'vue-router'

// 公开路由白名单:不经过任何权限校验
const PUBLIC_ROUTES = ['login', 'register', 'home']

export const permissionGuard: NavigationGuard = async (to, from) => {
  const userStore = useUserStore()

  // 公开路由直接放行
  if (PUBLIC_ROUTES.includes(to.name as string)) return true

  // 无 token:跳登录并记录来源
  if (!userStore.token) {
    return { name: 'login', query: { redirect: to.fullPath } }
  }

  // 动态路由未注册时先注册
  if (!userStore.dynamicRoutesReady) {
    const menus = await userStore.fetchMenus()
    menus.forEach((route) => router.addRoute(route))
    userStore.dynamicRoutesReady = true
    return { ...to, replace: true }
  }

  // 无权限页面
  if (to.meta.roles && !to.meta.roles.some((r) => userStore.roles.includes(r))) {
    return { name: 'forbidden' }
  }

  return true
}

讲解:该守卫把鉴权、动态路由、角色校验集中在一条链路,白名单优先、登录态其次、动态路由第三、角色最后。所有分支显式 return,配合 router.onError 与 afterEach 完成埋点,形成完整闭环。

9. 知识要点总结与深入讲解

导航守卫的本质是“路由状态机中的决策钩子”。初学者应记住执行顺序口诀:失活组件 → 全局前 → 路由独享 → 组件更新/进入 → 全局解析 → 确认 → 全局后。

返回值协议可以归纳为“三放行一取消一重定向”:true/undefined 放行,false 取消,地址对象重定向,Error 失败。这个协议把原来的 next 迷宫简化为普通函数返回值。

为什么要有 beforeResolve:因为组件可以异步加载(懒加载路由),在 beforeEach 阶段目标组件可能尚未下载完成;beforeResolve 保证所有异步依赖就绪后再做最终决策,适合放“必须等数据到齐”的校验。

为什么 beforeEnter 不重复触发:路由记录级守卫只在初始进入时执行,参数变化属于同一路由记录内的更新,应使用组件内守卫。这是面试与实战中的高频易错点。

1. 全局守卫

1.1 beforeEach

router.beforeEach((to, from) => {
  const auth = useAuthStore();
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    return { name: 'login', query: { redirect: to.fullPath } };
  }
});

1.2 afterEach

router.afterEach((to, from, failure) => {
  if (!failure) {
    document.title = to.meta.title || 'App';
  }
});

1. 路由独享守卫

const routes = [
  {
    path: '/admin',
    component: Admin,
    beforeEnter: (to, from) => {
      if (!isAdmin()) return '/login';
    },
  },
];

2. 组件内守卫

export default {
  beforeRouteEnter(to, from, next) {
    // 无法访问 this
    next((vm) => {
      // 通过 vm 访问组件实例
    });
  },
  beforeRouteUpdate(to, from) {
    // 路由参数变化时(如 /user/1 → /user/2)
  },
  beforeRouteLeave(to, from) {
    // 离开前确认
    if (hasUnsavedChanges) {
      return window.confirm('确认离开?');
    }
  },
};

3. 守卫执行顺序

  1. beforeRouteLeave(离开组件)
  2. beforeEach(全局)
  3. beforeRouteUpdate(复用组件)
  4. beforeEnter(路由配置)
  5. beforeRouteEnter(进入组件)
  6. afterEach(全局)

4. 返回值

返回值效果
true / undefined允许导航
false取消导航
路由对象重定向