Vue.js导航守卫

导航守卫是 Vue Router 提供的钩子函数,用于在路由导航过程中进行拦截和控制。它们允许我们在路由跳转前、跳转后或跳转失败时执行自定义逻辑,是实现权限控制、数据预加载等功能的关键。

导航守卫概述

导航守卫主要分为三类:

  • 全局守卫:作用于所有路由跳转
  • 路由独享守卫:作用于特定的路由配置
  • 组件内守卫:作用于特定的组件
  • 核心概念

    导航守卫的本质是在路由导航的不同阶段插入钩子函数,通过这些钩子函数可以:

    • 控制导航是否继续
    • 重定向到其他路由
    • 取消导航
    • 执行异步操作
    • 处理错误

    导航守卫执行流程

    当触发路由导航时,导航守卫会按照特定顺序执行:

    1. 导航被触发

    用户点击链接或调用 router.push()

    2. 调用失活组件的离开守卫

    beforeRouteLeave(组件内守卫)

    3. 调用全局的 beforeEach 守卫

    全局前置守卫

    4. 调用重用组件的 beforeRouteUpdate 守卫

    组件内守卫(仅当组件复用时)

    5. 调用路由配置中的 beforeEnter 守卫

    路由独享守卫

    6. 解析异步路由组件

    加载异步组件

    7. 调用激活组件的 beforeRouteEnter 守卫

    组件内守卫

    8. 调用全局的 beforeResolve 守卫

    全局解析守卫

    9. 导航被确认

    所有守卫通过,开始导航

    10. 调用全局的 afterEach 钩子

    全局后置钩子

    11. 触发 DOM 更新

    组件渲染完成

    12. 调用 beforeRouteEnter 中传给 next 的回调

    可以访问组件实例

    全局守卫

    全局守卫作用于所有路由导航,是最常用的守卫类型。

    全局前置守卫 beforeEach

    在路由跳转前执行,常用于权限验证。

    示例全局前置守卫基本用法
    
    import { createRouter, createWebHistory } from 'vue-router'
    
    const router = createRouter({
      history: createWebHistory(),
      routes: [...]
    })
    
    // 全局前置守卫
    router.beforeEach((to, from, next) => {
      // to: 即将要进入的目标路由对象
      // from: 当前导航正要离开的路由对象
      // next: 回调函数,必须调用以继续或中断导航
    
      console.log(`从 ${from.path} 导航到 ${to.path}`)
    
      // 1. 继续导航
      next()
    
      // 2. 中断导航,停留在当前页面
      // next(false)
    
      // 3. 跳转到指定路由
      // next('/login')
      // next({ path: '/login' })
      // next({ name: 'login', query: { redirect: to.fullPath } })
    
      // 4. 抛出错误,取消导航并调用 onError 回调
      // next(new Error('导航被拒绝'))
    })
    
    export default router
                            
    示例使用 beforeEach 实现权限控制
    
    // 模拟用户认证状态
    let isAuthenticated = false
    let userRole = null
    
    // 登录函数
    function login(role = 'user') {
      isAuthenticated = true
      userRole = role
    }
    
    // 登出函数
    function logout() {
      isAuthenticated = false
      userRole = null
    }
    
    // 权限检查函数
    function checkPermission(requiredRole) {
      if (!requiredRole) return true
      if (!userRole) return false
    
      // 简单的角色权限检查
      const roleHierarchy = {
        'user': 1,
        'editor': 2,
        'admin': 3
      }
    
      return roleHierarchy[userRole] >= roleHierarchy[requiredRole]
    }
    
    // 全局前置守卫 - 权限控制
    router.beforeEach((to, from, next) => {
      // 检查路由是否需要认证
      if (to.meta.requiresAuth && !isAuthenticated) {
        // 保存用户想要访问的地址,登录后重定向回来
        next({
          name: 'login',
          query: { redirect: to.fullPath }
        })
        return
      }
    
      // 检查路由角色权限
      if (to.meta.roles && !checkPermission(to.meta.roles)) {
        // 权限不足,重定向到无权限页面
        next({ name: 'forbidden' })
        return
      }
    
      // 检查是否只允许未登录用户访问(如登录页)
      if (to.meta.guestOnly && isAuthenticated) {
        // 已登录用户访问登录页,重定向到首页
        next({ name: 'home' })
        return
      }
    
      // 设置页面标题
      if (to.meta.title) {
        document.title = `${to.meta.title} - 我的应用`
      }
    
      // 所有检查通过,继续导航
      next()
    })
                            

    全局解析守卫 beforeResolve

    在导航被确认之前,同时在所有组件内守卫和异步路由组件被解析之后调用。

    示例全局解析守卫用法
    
    router.beforeResolve((to, from) => {
      // 在导航被确认之前调用
      // 此时组件已经解析完成
    
      // 检查页面是否需要某些数据
      if (to.meta.requiresData) {
        // 可以在这里进行数据预加载
        return loadRequiredData(to.params.id)
          .then(() => {
            // 数据加载成功,导航继续
            console.log('数据预加载完成')
          })
          .catch(error => {
            // 数据加载失败,取消导航
            console.error('数据加载失败:', error)
            return false
          })
      }
    })
    
    // 辅助函数:加载数据
    async function loadRequiredData(id) {
      // 模拟异步数据加载
      return new Promise((resolve) => {
        setTimeout(() => {
          console.log(`已加载数据: ${id}`)
          resolve()
        }, 100)
      })
    }
                            

    全局后置钩子 afterEach

    在导航完成后调用,没有 next 参数,不能改变导航。

    示例全局后置钩子用法
    
    router.afterEach((to, from, failure) => {
      // to: 进入的目标路由
      // from: 离开的路由
      // failure: 导航失败的错误信息(如果有)
    
      if (failure) {
        console.error('导航失败:', failure)
        // 可以在这里记录错误或显示错误信息
        showErrorMessage(failure.message)
        return
      }
    
      // 导航成功后的操作
      console.log(`成功从 ${from.path} 导航到 ${to.path}`)
    
      // 1. 滚动到页面顶部
      window.scrollTo(0, 0)
    
      // 2. 发送页面浏览统计
      sendPageView(to.path)
    
      // 3. 关闭加载提示
      hideLoading()
    
      // 4. 更新面包屑导航
      updateBreadcrumb(to.matched)
    })
    
    // 辅助函数示例
    function sendPageView(path) {
      // 发送页面浏览统计到分析服务
      if (window.ga) {
        window.ga('send', 'pageview', path)
      }
    }
    
    function updateBreadcrumb(matchedRoutes) {
      // 根据匹配的路由更新面包屑
      const breadcrumbs = matchedRoutes.map(route => ({
        name: route.meta.breadcrumb || route.name,
        path: route.path
      }))
    
      // 更新面包屑组件
      // ...
    }
                            

    路由独享守卫 beforeEnter

    在路由配置上直接定义的守卫,只对该路由生效。

    示例路由独享守卫用法
    
    const routes = [
      {
        path: '/',
        name: 'home',
        component: Home,
        meta: { title: '首页' }
      },
      {
        path: '/dashboard',
        name: 'dashboard',
        component: Dashboard,
        meta: {
          title: '控制面板',
          requiresAuth: true,
          roles: ['admin', 'editor']
        },
        // 路由独享守卫
        beforeEnter: (to, from, next) => {
          console.log('进入控制面板前检查')
    
          // 检查用户是否已登录
          if (!isUserLoggedIn()) {
            next({ name: 'login' })
            return
          }
    
          // 检查用户是否有权限
          if (!hasPermission('dashboard')) {
            next({ name: 'forbidden' })
            return
          }
    
          // 检查是否需要完成引导
          if (needsOnboarding()) {
            next({ name: 'onboarding' })
            return
          }
    
          // 所有检查通过
          next()
        }
      },
      {
        path: '/user/:id',
        name: 'user',
        component: UserProfile,
        // 支持多个守卫函数
        beforeEnter: [
          // 第一个守卫:验证参数
          (to, from, next) => {
            const id = to.params.id
            if (!isValidUserId(id)) {
              next({ name: 'not-found' })
              return
            }
            next()
          },
          // 第二个守卫:检查权限
          (to, from, next) => {
            if (!canViewUser(to.params.id)) {
              next({ name: 'forbidden' })
              return
            }
            next()
          }
        ]
      },
      {
        path: '/admin',
        name: 'admin',
        component: Admin,
        meta: { roles: ['admin'] },
        // 异步守卫
        beforeEnter: async (to, from, next) => {
          try {
            // 异步检查权限
            const hasAccess = await checkAdminAccess()
            if (hasAccess) {
              next()
            } else {
              next({ name: 'forbidden' })
            }
          } catch (error) {
            console.error('权限检查失败:', error)
            next({ name: 'error', query: { code: '403' } })
          }
        }
      }
    ]
                            

    组件内守卫

    在组件内部定义的守卫,只对该组件生效。

    1. beforeRouteEnter

    在渲染该组件的对应路由被验证前调用,不能访问 this

    示例beforeRouteEnter 用法
    
    <!-- UserProfile.vue -->
    <template>
      <div>
        <h2>用户资料</h2>
        <p>用户名: {{ user.name }}</p>
      </div>
    </template>
    
    <script>
    // Options API
    export default {
      name: 'UserProfile',
      data() {
        return {
          user: {}
        }
      },
      // 组件内守卫 - 进入前
      beforeRouteEnter(to, from, next) {
        // 不能访问 this,因为组件实例还没创建
    
        console.log('准备进入用户资料页面')
    
        // 1. 验证用户ID
        const userId = to.params.id
        if (!userId || userId === '0') {
          next({ name: 'not-found' })
          return
        }
    
        // 2. 预加载用户数据
        fetchUserData(userId)
          .then(user => {
            // 通过 next 的回调访问组件实例
            next(vm => {
              // vm 是组件实例
              vm.user = user
              console.log('用户数据已设置:', user.name)
            })
          })
          .catch(error => {
            console.error('加载用户数据失败:', error)
            // 重定向到错误页面
            next({ name: 'error', query: { message: '用户不存在' } })
          })
      }
    }
    </script>
    
    <!-- Composition API -->
    <script setup>
    import { onBeforeRouteEnter } from 'vue-router'
    
    // 使用 onBeforeRouteEnter
    onBeforeRouteEnter((to, from, next) => {
      console.log('准备进入用户资料页面 (Composition API)')
    
      // 预加载数据
      fetchUserData(to.params.id)
        .then(user => {
          next(vm => {
            // vm 是组件实例
            // 可以通过 vm.setupState 访问 Composition API 的状态
            console.log('组件实例:', vm)
          })
        })
        .catch(error => {
          next({ name: 'error' })
        })
    })
    </script>
                            

    2. beforeRouteUpdate

    在当前路由改变,但是该组件被复用时调用。

    示例beforeRouteUpdate 用法
    
    <!-- UserProfile.vue -->
    <template>
      <div>
        <h2>用户资料</h2>
        <p>用户ID: {{ userId }}</p>
        <p>用户名: {{ userName }}</p>
      </div>
    </template>
    
    <script>
    // Options API
    export default {
      name: 'UserProfile',
      data() {
        return {
          userId: null,
          userName: ''
        }
      },
      // 组件内守卫 - 路由更新(组件复用时)
      beforeRouteUpdate(to, from, next) {
        // 可以访问 this
        console.log(`用户ID从 ${from.params.id} 变为 ${to.params.id}`)
    
        // 当用户ID变化时,重新加载数据
        if (to.params.id !== from.params.id) {
          this.loadUserData(to.params.id)
            .then(() => {
              next()
            })
            .catch(error => {
              console.error('重新加载用户数据失败:', error)
              // 可以取消导航或重定向
              next(false)
            })
        } else {
          // 参数未变化,直接继续
          next()
        }
      },
      methods: {
        async loadUserData(userId) {
          // 加载用户数据
          const user = await fetchUserData(userId)
          this.userId = user.id
          this.userName = user.name
        }
      }
    }
    </script>
    
    <!-- Composition API -->
    <script setup>
    import { ref, watch } from 'vue'
    import { useRoute, onBeforeRouteUpdate } from 'vue-router'
    
    const route = useRoute()
    const userId = ref(route.params.id)
    const userName = ref('')
    
    // 使用 onBeforeRouteUpdate
    onBeforeRouteUpdate((to, from, next) => {
      console.log('路由更新 (Composition API)')
    
      // 当用户ID变化时重新加载数据
      if (to.params.id !== from.params.id) {
        loadUserData(to.params.id)
          .then(() => next())
          .catch(() => next(false))
      } else {
        next()
      }
    })
    
    // 或者使用 watch 监听路由参数变化
    watch(
      () => route.params.id,
      (newId) => {
        if (newId) {
          loadUserData(newId)
        }
      }
    )
    </script>
                            

    3. beforeRouteLeave

    在导航离开该组件的对应路由时调用。

    示例beforeRouteLeave 用法
    
    <!-- Editor.vue -->
    <template>
      <div>
        <h2>编辑器</h2>
        <textarea v-model="content" rows="10" cols="50"></textarea>
        <button @click="save">保存</button>
      </div>
    </template>
    
    <script>
    // Options API
    export default {
      name: 'Editor',
      data() {
        return {
          content: '',
          hasUnsavedChanges: false
        }
      },
      watch: {
        content() {
          this.hasUnsavedChanges = true
        }
      },
      // 组件内守卫 - 离开前
      beforeRouteLeave(to, from, next) {
        // 可以访问 this
        console.log('准备离开编辑器')
    
        // 检查是否有未保存的更改
        if (this.hasUnsavedChanges) {
          // 显示确认对话框
          const answer = window.confirm(
            '您有未保存的更改。确定要离开吗?'
          )
    
          if (answer) {
            // 用户确认离开
            next()
          } else {
            // 取消导航
            next(false)
          }
        } else {
          // 没有未保存的更改,直接离开
          next()
        }
      },
      methods: {
        save() {
          // 保存内容
          saveContent(this.content)
            .then(() => {
              this.hasUnsavedChanges = false
              alert('保存成功!')
            })
            .catch(error => {
              alert('保存失败: ' + error.message)
            })
        }
      }
    }
    </script>
    
    <!-- Composition API -->
    <script setup>
    import { ref, watch } from 'vue'
    import { onBeforeRouteLeave } from 'vue-router'
    
    const content = ref('')
    const hasUnsavedChanges = ref(false)
    
    // 监听内容变化
    watch(content, () => {
      hasUnsavedChanges.value = true
    })
    
    // 使用 onBeforeRouteLeave
    onBeforeRouteLeave((to, from, next) => {
      console.log('准备离开编辑器 (Composition API)')
    
      if (hasUnsavedChanges.value) {
        if (confirm('您有未保存的更改。确定要离开吗?')) {
          next()
        } else {
          next(false)
        }
      } else {
        next()
      }
    })
    </script>
                            

    导航守卫参数详解

    导航守卫接收三个参数:
    to: RouteLocationNormalized - 即将要进入的目标路由对象
    from: RouteLocationNormalized - 当前导航正要离开的路由对象
    next: NavigationGuardNext - 回调函数,控制导航行为(afterEach 没有此参数)
    示例路由对象常用属性
    
    router.beforeEach((to, from, next) => {
      // 常用属性示例:
    
      // 1. 路径相关
      console.log('完整路径:', to.fullPath)        // "/user/123?name=张三#profile"
      console.log('路径:', to.path)                // "/user/123"
      console.log('哈希:', to.hash)                // "#profile"
    
      // 2. 参数相关
      console.log('路径参数:', to.params)          // { id: "123" }
      console.log('查询参数:', to.query)           // { name: "张三" }
    
      // 3. 路由信息
      console.log('路由名称:', to.name)            // "user"
      console.log('路由元信息:', to.meta)          // { requiresAuth: true }
    
      // 4. 匹配的路由记录
      console.log('匹配的路由记录:', to.matched)
      // 数组,包含当前路由和所有父级路由
    
      // 5. 重定向来源
      console.log('重定向来源:', to.redirectedFrom)
      // 如果当前导航是通过重定向过来的,这里会有重定向来源的路由
    
      // 6. from 对象的属性与 to 相同
      console.log('离开的页面标题:', from.meta?.title)
    
      next()
    })
                            
    示例next 函数的用法
    
    router.beforeEach((to, from, next) => {
      // next() 的几种调用方式:
    
      // 1. 继续导航(无参数)
      // next()
    
      // 2. 中断导航,停留在当前页面
      // next(false)
    
      // 3. 跳转到指定路径
      // next('/login')
      // next({ path: '/login' })
    
      // 4. 跳转到命名路由
      // next({ name: 'login' })
      // next({ name: 'user', params: { id: '123' } })
    
      // 5. 跳转并携带查询参数
      // next({
      //   path: '/search',
      //   query: { q: 'vue', page: 1 }
      // })
    
      // 6. 替换当前路由(不添加历史记录)
      // next({
      //   path: '/new-path',
      //   replace: true
      // })
    
      // 7. 抛出错误
      // next(new Error('导航被拒绝'))
      // 错误会被传递给 router.onError() 回调
    
      // 8. beforeRouteEnter 特有的回调形式
      // next(vm => {
      //   // 在组件实例创建后执行
      //   vm.loadData()
      // })
    })
                            

    导航守卫执行顺序演示

    导航守卫执行顺序演示

    点击按钮模拟路由导航,观察控制台输出:

    等待导航演示...
    模拟用户状态:已登录 (角色: user)

    实际应用场景

    场景1:完整的权限控制方案

    示例完整的权限控制实现
    
    // 权限控制工具函数
    const auth = {
      // 检查登录状态
      isAuthenticated() {
        return localStorage.getItem('token') !== null
      },
    
      // 获取用户角色
      getUserRole() {
        const user = JSON.parse(localStorage.getItem('user') || '{}')
        return user.role || null
      },
    
      // 检查权限
      hasPermission(requiredRole) {
        const userRole = this.getUserRole()
    
        if (!requiredRole) return true
        if (!userRole) return false
    
        // 角色权限映射
        const rolePermissions = {
          'user': ['dashboard', 'profile'],
          'editor': ['dashboard', 'profile', 'editor'],
          'admin': ['dashboard', 'profile', 'editor', 'admin']
        }
    
        return rolePermissions[userRole]?.includes(requiredRole) || false
      },
    
      // 检查路由权限
      canAccessRoute(route) {
        const meta = route.meta || {}
    
        // 检查是否需要登录
        if (meta.requiresAuth && !this.isAuthenticated()) {
          return { allowed: false, redirect: '/login' }
        }
    
        // 检查角色权限
        if (meta.roles && !this.hasPermission(meta.roles)) {
          return { allowed: false, redirect: '/forbidden' }
        }
    
        // 检查是否只允许未登录用户访问
        if (meta.guestOnly && this.isAuthenticated()) {
          return { allowed: false, redirect: '/' }
        }
    
        return { allowed: true }
      }
    }
    
    // 全局前置守卫
    router.beforeEach(async (to, from, next) => {
      console.log(`导航守卫: ${from.path} → ${to.path}`)
    
      // 检查路由权限
      const result = auth.canAccessRoute(to)
    
      if (!result.allowed) {
        // 权限不足,重定向
        next({
          path: result.redirect,
          query: {
            redirect: to.fullPath,
            reason: result.reason
          }
        })
        return
      }
    
      // 页面标题
      if (to.meta.title) {
        document.title = `${to.meta.title} - 我的应用`
      }
    
      // 检查是否需要预加载数据
      if (to.meta.preload) {
        try {
          await preloadData(to)
          next()
        } catch (error) {
          console.error('数据预加载失败:', error)
          next({ name: 'error', query: { code: 'LOAD_ERROR' } })
        }
      } else {
        next()
      }
    })
    
    // 全局解析守卫 - 数据预取
    router.beforeResolve(async (to, from) => {
      if (to.meta.dataLoader) {
        return to.meta.dataLoader(to.params)
      }
    })
    
    // 全局后置钩子 - 页面统计
    router.afterEach((to, from, failure) => {
      if (failure) {
        console.error('导航失败:', failure)
        trackError(failure)
        return
      }
    
      // 页面浏览统计
      trackPageView(to.path, to.name)
    
      // 滚动到顶部
      window.scrollTo(0, 0)
    })
    
    // 路由配置示例
    const routes = [
      {
        path: '/login',
        name: 'login',
        component: () => import('./views/Login.vue'),
        meta: {
          title: '登录',
          guestOnly: true
        }
      },
      {
        path: '/dashboard',
        name: 'dashboard',
        component: () => import('./views/Dashboard.vue'),
        meta: {
          title: '控制面板',
          requiresAuth: true,
          preload: true
        },
        beforeEnter: (to, from, next) => {
          // 路由独享守卫:额外检查
          console.log('进入控制面板前额外检查')
          next()
        }
      },
      {
        path: '/admin',
        name: 'admin',
        component: () => import('./views/Admin.vue'),
        meta: {
          title: '管理后台',
          requiresAuth: true,
          roles: 'admin',
          dataLoader: loadAdminData
        }
      }
    ]
    
    // 数据加载函数
    async function loadAdminData(params) {
      // 加载管理后台所需数据
      const [users, stats, logs] = await Promise.all([
        fetch('/api/admin/users'),
        fetch('/api/admin/stats'),
        fetch('/api/admin/logs')
      ])
    
      return { users, stats, logs }
    }
                            

    常见问题和解决方案

    常见问题解答
    1. 导航守卫中的异步操作如何处理?

    在守卫中返回 Promise 或使用 async/await。Vue Router 会等待 Promise 解析后再继续导航。

    2. 如何避免无限重定向循环?

    在重定向时检查目标路由,避免重定向到当前路由或产生循环。可以使用 to.redirectedFrom 检查重定向来源。

    3. beforeRouteEnter 中如何访问组件实例?

    通过 next 的回调函数:next(vm => { /* 访问 vm */ })。回调在组件创建后执行。

    4. 如何处理导航取消?

    调用 next(false) 取消导航。可以在 afterEach 中通过 failure 参数检测导航是否失败。

    5. 如何跳过某些路由的守卫?

    可以使用路由元信息标记,在全局守卫中检查并跳过:

    if (to.meta.skipGuards) {
      next()
      return
    }

    导航守卫最佳实践

    导航守卫最佳实践
    • 权限检查集中处理:在全局守卫中统一处理权限验证
    • 避免重复逻辑:合理分配守卫职责,避免在多个守卫中重复相同逻辑
    • 处理异步操作:在守卫中妥善处理异步操作,避免导航卡住
    • 错误处理:始终处理可能的错误,提供友好的用户反馈
    • 性能优化:避免在守卫中执行耗时操作,必要时使用懒加载
    • 保持守卫简洁:每个守卫只关注单一职责,复杂的逻辑提取到工具函数中
    • 测试守卫:编写单元测试验证守卫逻辑的正确性

    总结

    Vue Router 的导航守卫提供了强大的路由控制能力,允许我们在路由导航的各个阶段插入自定义逻辑。通过合理使用导航守卫,我们可以:

    • 实现完善的权限控制系统
    • 控制数据预加载和状态管理
    • 处理用户未保存的更改
    • 实现页面访问统计和埋点
    • 管理滚动行为和页面标题
    • 处理导航错误和异常情况

    理解不同守卫的执行顺序和适用场景,结合最佳实践,可以构建出安全、稳定、用户体验良好的单页面应用。

    测试你的理解

    思考题
    1. 全局守卫、路由独享守卫和组件内守卫的执行顺序是怎样的?
    2. beforeRouteEnter 中如何访问组件实例?为什么不能直接使用 this
    3. 如何实现一个防止用户意外离开编辑页面的功能?
    4. 如果在守卫中需要执行异步操作(如 API 请求),应该如何处理?
    5. 如何避免权限检查中的无限重定向循环?