路由守卫

路由守卫用于在路由跳转过程中执行特定逻辑,如权限验证、数据预取、页面标题设置等。理解路由守卫,是构建安全、可控应用的关键。

守卫类型

全局前置守卫

在路由跳转前触发,常用于权限验证:

const router = new VueRouter({ /* ... */ })

router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !isAuthenticated()) {
    next('/login')
  } else {
    next()
  }
})

全局解析守卫

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

router.beforeResolve((to, from, next) => {
  console.log('导航即将完成')
  next()
})

全局后置钩子

导航完成后触发,没有 next 函数:

router.afterEach((to, from) => {
  document.title = to.meta.title || '默认标题'
})

路由独享守卫

在路由配置中定义:

const routes = [
  {
    path: '/admin',
    component: Admin,
    beforeEnter: (to, from, next) => {
      if (isAdmin()) {
        next()
      } else {
        next('/403')
      }
    }
  }
]

组件内守卫

在组件内部定义:

export default {
  beforeRouteEnter(to, from, next) {
    console.log('进入路由前')
    next(vm => {
      console.log('组件已创建')
    })
  },
  
  beforeRouteUpdate(to, from, next) {
    console.log('路由参数变化')
    next()
  },
  
  beforeRouteLeave(to, from, next) {
    if (this.hasUnsavedChanges) {
      const answer = window.confirm('确定离开?')
      if (answer) next()
      else next(false)
    } else {
      next()
    }
  }
}

守卫执行顺序

1. 触发导航
2. 调用失活组件的 beforeRouteLeave
3. 调用全局 beforeEach
4. 重用组件调用 beforeRouteUpdate
5. 路由配置调用 beforeEnter
6. 解析异步路由组件
7. 激活组件调用 beforeRouteEnter
8. 调用全局 beforeResolve
9. 导航确认
10. 调用全局 afterEach
11. 触发 DOM 更新
12. beforeRouteEnter 的 next 回调执行

next 函数

用法

next()
next(false)
next('/')
next({ name: 'home' })
next(error)

注意事项

确保 next 只被调用一次

router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth) {
    if (isAuthenticated()) {
      next()
    } else {
      next('/login')
    }
  } else {
    next()
  }
})

实战示例

登录验证

import store from './store'

router.beforeEach(async (to, from, next) => {
  const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
  const isLoggedIn = store.getters.isLoggedIn
  
  if (requiresAuth && !isLoggedIn) {
    next({
      path: '/login',
      query: { redirect: to.fullPath }
    })
  } else if (to.path === '/login' && isLoggedIn) {
    next('/')
  } else {
    next()
  }
})

页面标题

router.beforeEach((to, from, next) => {
  const title = to.meta.title
  if (title) {
    document.title = `${title} - 我的网站`
  } else {
    document.title = '我的网站'
  }
  next()
})

进度条

import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

router.beforeEach((to, from, next) => {
  NProgress.start()
  next()
})

router.afterEach(() => {
  NProgress.done()
})

数据预取

router.beforeEach(async (to, from, next) => {
  if (to.meta.preFetch) {
    try {
      await store.dispatch(to.meta.preFetch, to.params)
    } catch (error) {
      console.error('数据预取失败', error)
    }
  }
  next()
})

权限控制

router.beforeEach(async (to, from, next) => {
  const requiredPermissions = to.meta.permissions || []
  
  if (requiredPermissions.length === 0) {
    next()
    return
  }
  
  const userPermissions = store.getters.permissions
  const hasPermission = requiredPermissions.some(
    perm => userPermissions.includes(perm)
  )
  
  if (hasPermission) {
    next()
  } else {
    next('/403')
  }
})

动态路由

let routesAdded = false

router.beforeEach(async (to, from, next) => {
  if (routesAdded) {
    next()
    return
  }
  
  try {
    const permissions = await store.dispatch('fetchPermissions')
    const dynamicRoutes = generateRoutes(permissions)
    router.addRoutes(dynamicRoutes)
    routesAdded = true
    next({ ...to, replace: true })
  } catch (error) {
    next('/login')
  }
})

页面缓存控制

const cachePages = ['home', 'list']

router.beforeEach((to, from, next) => {
  if (!cachePages.includes(to.name)) {
    store.commit('CLEAR_CACHE')
  }
  next()
})

访问日志

router.afterEach((to, from) => {
  const log = {
    from: from.path,
    to: to.path,
    timestamp: Date.now(),
    userId: store.getters.userId
  }
  
  api.logVisit(log)
})

防止重复导航

const originalPush = VueRouter.prototype.push
const originalReplace = VueRouter.prototype.replace

VueRouter.prototype.push = function push(location) {
  return originalPush.call(this, location).catch(error => {
    if (error.name === 'NavigationDuplicated') {
      return this.currentRoute
    }
    return Promise.reject(error)
  })
}

VueRouter.prototype.replace = function replace(location) {
  return originalReplace.call(this, location).catch(error => {
    if (error.name === 'NavigationDuplicated') {
      return this.currentRoute
    }
    return Promise.reject(error)
  })
}

组件内守卫详解

beforeRouteEnter

在渲染组件前调用,此时组件实例还未创建,无法访问 this

export default {
  beforeRouteEnter(to, from, next) {
    console.log(this)
    
    next(vm => {
      console.log(vm)
      vm.fetchData()
    })
  }
}

beforeRouteUpdate

路由参数变化时调用,组件复用:

export default {
  beforeRouteUpdate(to, from, next) {
    if (to.params.id !== from.params.id) {
      this.fetchUser(to.params.id)
    }
    next()
  }
}

beforeRouteLeave

离开路由时调用,常用于防止用户未保存离开:

export default {
  data() {
    return {
      form: {},
      originalForm: {}
    }
  },
  computed: {
    hasChanges() {
      return JSON.stringify(this.form) !== JSON.stringify(this.originalForm)
    }
  },
  beforeRouteLeave(to, from, next) {
    if (this.hasChanges) {
      const answer = window.confirm('有未保存的更改,确定离开吗?')
      if (answer) {
        next()
      } else {
        next(false)
      }
    } else {
      next()
    }
  }
}

完整示例

import Vue from 'vue'
import VueRouter from 'vue-router'
import store from './store'
import NProgress from 'nprogress'

Vue.use(VueRouter)

const routes = [
  {
    path: '/login',
    name: 'login',
    component: () => import('./views/Login.vue'),
    meta: { title: '登录' }
  },
  {
    path: '/',
    component: () => import('./views/Layout.vue'),
    meta: { requiresAuth: true },
    children: [
      {
        path: '',
        name: 'home',
        component: () => import('./views/Home.vue'),
        meta: { title: '首页' }
      },
      {
        path: 'admin',
        name: 'admin',
        component: () => import('./views/Admin.vue'),
        meta: { 
          title: '管理后台',
          permissions: ['admin']
        }
      }
    ]
  }
]

const router = new VueRouter({
  mode: 'history',
  routes
})

router.beforeEach((to, from, next) => {
  NProgress.start()
  
  document.title = to.meta.title || '我的应用'
  
  const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
  const isLoggedIn = store.getters.isLoggedIn
  
  if (requiresAuth && !isLoggedIn) {
    next({
      path: '/login',
      query: { redirect: to.fullPath }
    })
  } else if (to.path === '/login' && isLoggedIn) {
    next('/')
  } else {
    next()
  }
})

router.beforeResolve((to, from, next) => {
  const permissions = to.meta.permissions || []
  
  if (permissions.length > 0) {
    const userPermissions = store.getters.permissions
    const hasPermission = permissions.some(p => userPermissions.includes(p))
    
    if (!hasPermission) {
      next('/403')
      return
    }
  }
  
  next()
})

router.afterEach(() => {
  NProgress.done()
})

export default router

小结

路由守卫要点:

  1. 全局守卫:beforeEach、beforeResolve、afterEach
  2. 路由守卫:beforeEnter
  3. 组件守卫:beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave
  4. 执行顺序:从外到内,依次执行
  5. next 函数:控制导航流程