vue-router 提供的导航守卫主要用来通过跳转或取消的方式守卫导航。我们能够在全局、单个路由、单个组件内增加导航守卫,对于路由事件的处理非常灵活。
导航守卫有以下几种:

  • 全局前置守卫 beforeEach
  • 全局解析守卫 beforeResolve
  • 全局后置钩子 afterEach
  • 路由前置守卫 beforeEnter
  • 组件前置守卫 beforeRouteEnter
  • 组件更新钩子 beforeRouteUpdate
  • 组件后置钩子 beforeRouteLeave

beforeResolve、beforeResolve、beforeRouteEnter、beforeRouteLeave 这几个导航守卫,个人在项目中还没有特别好的实践,希望小伙伴们在评论区留下你的想法。

导航守卫 | Vue Router

项目实践

在项目中我们可以利用全局前置守卫 beforeEach 进行路由验证,通过全局守卫 beforeEach 和 afterEach 控制进度条打开和关闭,通过导航守卫对单个路由、组件路由进行处理。

登录验证

我们一般用 beforeEach 这个全局前置守卫来做路由跳转前的登录验证。

来看一下具体配置:

  1. const router = new VueRouter({
  2. routes: [
  3. {
  4. path: '/login',
  5. name: 'login',
  6. meta: { name: '登录' },
  7. component: () => import('./views/login.vue')
  8. },
  9. {
  10. path: '/welcome',
  11. name: 'welcome',
  12. meta: { name: '欢迎', auth: true },
  13. component: () => import('./views/welcome.vue')
  14. }
  15. ]
  16. })

在上面的 routers 中我们配置了 2 个路由,login 和 welcome,welcome 需要登录认证,我们在 welcome 路由的 meta 中加入了 auth: true 作为需要认证的标识。

beforeEach 全局前置守卫的配置:

  1. router.beforeEach((to, from, next) => {
  2. if (to.meta.auth) {
  3. if (getToken()) {
  4. next()
  5. } else {
  6. next('/login')
  7. }
  8. } else {
  9. next()
  10. }
  11. })

在 beforeEach 中,router 会按照创建顺序调用,如果 getToken 能够获取到 token,我们就让路由跳转到 to 所对应的 path,否则强制跳转到 login 路由,进行登录认证。

  1. import Cookies from 'js-cookie'
  2. const TokenKey = 'TOKEN_KEY'
  3. export function getToken() {
  4. return Cookies.get(TokenKey)
  5. }

PS: getToken 函数引用 js-cookie 库,用来获取 cookie 中的 token 。

进度条

我们采用 NProgress.js 轻量级的进度条组件,支持自定义配置。

NProgress.js

  1. import NProgress from 'nprogress'
  2. NProgress.configure({ showSpinner: false })
  3. // 全局前置守卫
  4. router.beforeEach((to, from, next) => {
  5. NProgress.start()
  6. next()
  7. })
  8. // 全局后置钩子
  9. router.afterEach(() => {
  10. NProgress.done()
  11. })

我们通过在全局后置钩子 beforeEach、全局前置守卫 afterEach 的回调中调用 NProgress 暴露的 start、done 函数,来控制进度条的显示和隐藏。

组件守卫

  1. const Foo = {
  2. template: `...`,
  3. created() {
  4. console.log('this is created')
  5. this.searchData()
  6. },
  7. mounted() {
  8. console.log('this is mounted')
  9. },
  10. updated() {
  11. console.log('this is updated')
  12. },
  13. destroyed() {
  14. console.log('this is destroyed')
  15. },
  16. beforeRouteUpdate(to, from, next) {
  17. next()
  18. }
  19. }

带有动态参数的路径 /foo/:id,在 /foo/1/foo/2 之间跳转的时候,由于会渲染同样的 Foo 组件,因此组件实例会被复用。此时组件内的生命周期 created、mounted、destroyed、updated 均不会执行。

在 vue 文档中这样解释: 由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。
_
但是我们只想监听路由变化呢,那么就得用到 beforeRouteUpdate 组件导航守卫。

  1. const Foo = {
  2. template: `...`,
  3. beforeRouteUpdate(to, from, next) {
  4. this.searchData()
  5. next()
  6. }
  7. }

/foo/1/foo/2 之间跳转的时候,会调用 this.searchData() 函数更新数据。

源码解析

那么既然 router 的导航守卫这么神奇,那在 vue-router 中是怎么实现的呢?

阅读 vue-router 源码的思维导图

导航守卫实践与解析 - 图1

vue-router 的文档 对辅助看源码有不小的帮助,不妨在看源码之前仔细地撸一遍文档。

VueRouter

vue-routerindex.js 中默认导出了 VueRouter 类。

  1. export default class VueRouter {
  2. constructor (options: RouterOptions = {}) {
  3. ...
  4. this.beforeHooks = []
  5. this.resolveHooks = []
  6. this.afterHooks = []
  7. }
  8. // 全局前置守卫
  9. beforeEach (fn: Function): Function {
  10. return registerHook(this.beforeHooks, fn)
  11. }
  12. // 全局解析守卫
  13. beforeResolve (fn: Function): Function {
  14. return registerHook(this.resolveHooks, fn)
  15. }
  16. // 全局后置钩子
  17. afterEach (fn: Function): Function {
  18. return registerHook(this.afterHooks, fn)
  19. }
  20. }

在 VueRouter 类中申明了 beforeHooks、resolveHooks、afterHooks 数组用来储存全局的导航守卫函数,还申明了 beforeEach、beforeResolve、afterEach 这 3 个全局的导航守卫函数。

在这 3 个全局导航守卫函数中都调用了 registerHook 函数:

  1. function registerHook(list: Array<any>, fn: Function): Function {
  2. list.push(fn)
  3. return () => {
  4. const i = list.indexOf(fn)
  5. if (i > -1) list.splice(i, 1)
  6. }
  7. }

registerHook 函数会将传入的 fn 守卫函数推入对应的守卫函数队列 list,并返回可以删除此 fn 导航守卫的函数。

History

我们先来看看 History 类,在 src/history/base.js 文件。这里主要介绍 2 个核心函数 transitionTo、confirmTransition。

核心函数 transitionTo

transitionTo 是 router 进行跳转的核心函数,我们来看一下它是如何实现的。

  1. export class History {
  2. ...
  3. // 跳转核心处理
  4. transitionTo (location: RawLocation, onComplete ?: Function, onAbort ?: Function) {
  5. // 调用 match 函数生成 route 对象
  6. const route = this.router.match(location, this.current)
  7. this.confirmTransition(route, () => {
  8. // 更新当前 route
  9. this.updateRoute(route)
  10. // 路由更新后的回调
  11. // 就是 HashHistory类的 setupListeners
  12. // 做了滚动处理 hash 事件监听
  13. onComplete && onComplete(route)
  14. // 更新 url (在子类申明的方法)
  15. this.ensureURL()
  16. // fire ready cbs once
  17. if (!this.ready) {
  18. this.ready = true
  19. this.readyCbs.forEach(cb => { cb(route) })
  20. }
  21. }, err => {
  22. if (onAbort) {
  23. onAbort(err)
  24. }
  25. if (err && !this.ready) {
  26. this.ready = true
  27. this.readyErrorCbs.forEach(cb => { cb(err) })
  28. }
  29. })
  30. }
  31. }

transitionTo 函数接收 3 个函数,location 跳转路由、onComplete 成功回调、onAbort 中止回调。

vue-router 的 3 种 history 模式,AbstractHistory、HashHistory、HTML5History 类中都是通过 extends 继承了 History 类的 transitionTo 函数。

在 transitionTo 函数中首先会调用 match 函数生成 route 对象:

  1. {
  2. fullPath: "/"
  3. hash: ""
  4. matched: [{…}]
  5. meta:
  6. __proto__: Object
  7. name: undefined
  8. params: {}
  9. path: "/"
  10. query: {}
  11. }

得到一个路由对象 route,然后调用 this.confirmTransition 函数,传入 route、成功回调、失败回调。

在成功回调中会调用 updateRoute 函数:

  1. updateRoute (route: Route) {
  2. const prev = this.current
  3. this.current = route
  4. this.cb && this.cb(route)
  5. this.router.afterHooks.forEach(hook => {
  6. hook && hook(route, prev)
  7. })
  8. }

updateRoute 函数会更新当前 route,并遍历执行全局后置钩子函数 afterHooks 队列,该队列是通过
router 暴露的 afterEach 函数推入的。

PS: afterEach 别没有在迭代函数调用,因此没有传入 next 函数。
**
在失败回调中会调用中止函数 onAbort。

确认跳转函数 confirmTransition

confirmTransition 顾名思义,是来确认当前路由是否能够进行跳转,那么在函数内部具体做了哪些事情?

  1. export class History {
  2. ...
  3. confirmTransition (route: Route, onComplete: Function, onAbort ?: Function) {
  4. const current = this.current
  5. // 封装中止函数,循环调用 errorCbs 任务队列
  6. const abort = err => {
  7. ...
  8. }
  9. // 路由相同则返回
  10. if (
  11. isSameRoute(route, current) &&
  12. // in the case the route map has been dynamically appended to
  13. route.matched.length === current.matched.length
  14. ) {
  15. this.ensureURL()
  16. return abort()
  17. }
  18. // 用 resolveQueue 来做做新旧对比 比较后返回需要更新、激活、卸载 3 种路由状态的数组
  19. const {
  20. updated,
  21. deactivated,
  22. activated
  23. } = resolveQueue(this.current.matched, route.matched)
  24. // 提取守卫的钩子函数 将任务队列合并
  25. const queue: Array<?NavigationGuard> = [].concat(
  26. // in-component leave guards
  27. // 获得组件内的 beforeRouteLeave 钩子函数
  28. extractLeaveGuards(deactivated),
  29. // global before hooks
  30. this.router.beforeHooks,
  31. // in-component update hooks
  32. // 获得组件内的 beforeRouteUpdate 钩子函数
  33. extractUpdateHooks(updated),
  34. // in-config enter guards
  35. activated.map(m => m.beforeEnter),
  36. // async components
  37. // 异步组件处理
  38. resolveAsyncComponents(activated)
  39. )
  40. this.pending = route
  41. // 申明一个迭代函数,用来处理每个 hook 的 next 回调
  42. const iterator = (hook: NavigationGuard, next) => {
  43. ...
  44. }
  45. // 执行合并后的任务队列
  46. runQueue(queue, iterator, () => {
  47. ...
  48. })
  49. }
  50. }

函数内部首先申明 current 变量,保存当前路由信息,并封装了 abort 中止函数。

  1. // 封装中止函数,循环调用 errorCbs 任务队列
  2. const abort = err => {
  3. if (isError(err)) {
  4. if (this.errorCbs.length) {
  5. this.errorCbs.forEach(cb => {
  6. cb(err)
  7. })
  8. } else {
  9. warn(false, 'uncaught error during route navigation:')
  10. console.error(err)
  11. }
  12. }
  13. onAbort && onAbort(err)
  14. }

abort 函数接收 err 参数,如果传入了 err 会执行 this.errorCbs 推入的 error 任务,并且执行 onAbort 中止函数。

接着会判断当前路由与跳转路由是否相同,如果相同,返回并执行中止函数 abort。

  1. const { updated, deactivated, activated } = resolveQueue(
  2. this.current.matched,
  3. route.matched
  4. )

这里会调用 resolveQueue 函数,将当前的 matched 与跳转的 matched 进行比较,matched 是在 src/util/route.js 中 createRoute 函数中增加,用数组的形式记录当前 route 以及它的上级 route。

resolveQueue 函数主要用来做新旧对比,通过遍历 matched 数组,比较后返回需要更新、激活、卸载 3 种路由状态的数组。

接下来会有一个关键的操作,将所有的导航守卫函数合并成一个 queue 的任务队列。

  1. // 提取守卫的钩子函数 将任务队列合并
  2. const queue: Array<?NavigationGuard> = [].concat(
  3. // in-component leave guards
  4. // 获得组件内的 beforeRouteLeave 钩子函数
  5. extractLeaveGuards(deactivated),
  6. // global before hooks
  7. this.router.beforeHooks,
  8. // in-component update hooks
  9. // 获得组件内的 beforeRouteUpdate 钩子函数
  10. extractUpdateHooks(updated),
  11. // in-config enter guards
  12. activated.map(m => m.beforeEnter),
  13. // async components
  14. // 异步组件处理
  15. resolveAsyncComponents(activated)
  16. )

合并的顺序分别是组件内的 beforeRouteLeave、全局的 beforeHooks、组件内的 beforeRouteUpdate、组件内的 beforeEnter 以及异步组件的导航守卫函数。

迭代函数 iterator

合并 queue 导航守卫任务队列后,申明了 iterator 迭代函数,该函数会作为参数传入 runQueue 方法。

  1. // 申明一个迭代函数,用来处理每个 hook 的 next 回调
  2. const iterator = (hook: NavigationGuard, next) => {
  3. if (this.pending !== route) {
  4. return abort()
  5. }
  6. try {
  7. // 这就是调用导航守卫函数的地方
  8. // 传入了 route, current,next
  9. hook(route, current, (to: any) => {
  10. // next false 终止导航
  11. if (to === false || isError(to)) {
  12. // next(false) -> abort navigation, ensure current URL
  13. this.ensureURL(true)
  14. abort(to)
  15. } else if (
  16. typeof to === 'string' ||
  17. (typeof to === 'object' &&
  18. (typeof to.path === 'string' || typeof to.name === 'string'))
  19. ) {
  20. // 传入了 url 进行路由跳转
  21. // next('/') or next({ path: '/' }) -> redirect
  22. abort()
  23. if (typeof to === 'object' && to.replace) {
  24. this.replace(to)
  25. } else {
  26. this.push(to)
  27. }
  28. } else {
  29. // confirm transition and pass on the value
  30. // next step(index + 1)
  31. next(to)
  32. }
  33. })
  34. } catch (e) {
  35. abort(e)
  36. }
  37. }

iterator 迭代函数接收 hook 函数、next 回调作为参数,在函数内部会用 try catch 包裹 hook 函数的调用,这里就是我们执行导航守卫函数的地方,传入了 route、current,以及 next 回调。

在 next 回调中, 会对传入的 to 参数进行判断,分别处理,最后的 next(to) 调用的是 runQueue 中的:

  1. fn(queue[index], () => {
  2. step(index + 1)
  3. })

这样会继续调用下一个导航守卫。

递归调用任务队列 runQueue

  1. /* @flow */
  2. // 从第一个开始,递归调用任务队列
  3. export function runQueue(
  4. queue: Array<?NavigationGuard>,
  5. fn: Function,
  6. cb: Function
  7. ) {
  8. const step = index => {
  9. if (index >= queue.length) {
  10. cb()
  11. } else {
  12. if (queue[index]) {
  13. fn(queue[index], () => {
  14. step(index + 1)
  15. })
  16. } else {
  17. step(index + 1)
  18. }
  19. }
  20. }
  21. step(0)
  22. }

runQueue 函数很简单,就是递归依次执行 queue 任务队列中的导航守卫函数,如果执行完毕,调用 cb 函数。

  1. // 执行合并后的任务队列
  2. runQueue(queue, iterator, () => {
  3. const postEnterCbs = []
  4. const isValid = () => this.current === route
  5. // wait until async components are resolved before
  6. // extracting in-component enter guards
  7. // 拿到组件 beforeRouteEnter 钩子函数,合并任务队列
  8. const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
  9. const queue = enterGuards.concat(this.router.resolveHooks)
  10. runQueue(queue, iterator, () => {
  11. if (this.pending !== route) {
  12. return abort()
  13. }
  14. this.pending = null
  15. onComplete(route)
  16. if (this.router.app) {
  17. this.router.app.$nextTick(() => {
  18. postEnterCbs.forEach(cb => {
  19. cb()
  20. })
  21. })
  22. }
  23. })
  24. })

这里是正真调用 runQueue 函数的地方,传入了 queue、iterator 迭代函数、cb 回调函数,当 queue 队列的函数都执行完毕,调用传入的 cb 回调函数。

在 cb 函数中,调用 extractEnterGuards 函数拿到组件 beforeRouteEnter 钩子函数数组 enterGuards,然后与 resolveHooks 全局解析守卫进行合并,得到新的 queue,再次调用 runQueue 函数,最后调用 onComplete 函数,完成路由的跳转。

总结

在这里稍微总结下导航守卫,在 vue-router 中通过数组的形式模拟导航守卫任务队列,在 transitionTo 跳转核心函数中调用确认函数 confirmTransition,在 confirmTransition 函数中会递归 queue 导航守卫任务队列,通过传入 next 函数的参数来判断是否继续执行导航守卫任务,如果 queue 任务全部执行完成,进行路由跳转。

感谢 vue-router 中提供的各种导航钩子,让我们能够更加灵活地控制、处理路由。

导航守卫的执行顺序

一般路由跳转

例如从 / Home 页面跳转到 /foo Foo,导航守卫执行顺序大概是这样的。

全局导航守卫

  1. router.beforeEach((to, from, next) => {
  2. console.log('in-global beforeEach hook')
  3. next()
  4. })
  5. router.beforeResolve((to, from, next) => {
  6. console.log('in-global beforeResolve hook')
  7. next()
  8. })
  9. router.afterEach((to, from) => {
  10. console.log('in-global afterEach hook')
  11. })

组件导航守卫

  1. const Home = {
  2. template: '<div>home</div>',
  3. beforeRouteEnter(to, from, next) {
  4. console.log('in-component Home beforeRouteEnter hook')
  5. next()
  6. },
  7. beforeRouteUpdate(to, from, next) {
  8. console.log('in-component Home beforeRouteUpdate hook')
  9. next()
  10. },
  11. beforeRouteLeave(to, from, next) {
  12. console.log('in-component Home beforeRouteLeave hook')
  13. next()
  14. }
  15. }
  16. const Foo = {
  17. template: '<div>foo</div>',
  18. beforeRouteEnter(to, from, next) {
  19. console.log('in-component Foo beforeRouteEnter hook')
  20. next()
  21. },
  22. beforeRouteUpdate(to, from, next) {
  23. console.log('in-component Foo beforeRouteUpdate hook')
  24. next()
  25. },
  26. beforeRouteLeave(to, from, next) {
  27. console.log('in-component Foo beforeRouteLeave hook')
  28. next()
  29. }
  30. }

路由导航守卫

  1. const router = new VueRouter({
  2. mode: 'history',
  3. routes: [
  4. {
  5. path: '/',
  6. component: Home,
  7. beforeEnter: function guardRoute(to, from, next) {
  8. console.log('in-router Home beforeEnter hook')
  9. next()
  10. }
  11. },
  12. {
  13. path: '/foo',
  14. component: Foo,
  15. beforeEnter: function guardRoute(to, from, next) {
  16. console.log('in-router Foo beforeEnter hook')
  17. next()
  18. }
  19. }
  20. ]
  21. })

输出

  1. in-component Home beforeRouteLeave hook
  2. in-global beforeEach hook
  3. in-router Foo beforeEnter hook
  4. in-component Foo beforeRouteEnter hook
  5. in-global beforeResolve hook
  6. in-global afterEach hook

组件复用的情况

在当前路由改变,但是该组件被复用时调用,举例来说,对于一个带有动态参数的路径 /foo/:id,在 /foo/1/foo/2 之间跳转的时候,由于会渲染同样的 Foo 组件,因此组件实例会被复用。beforeRouteUpdate 钩子就会在这个情况下被调用。

组件导航守卫

  1. const Foo = {
  2. template: `<div>Foo</div>`,
  3. created() {
  4. console.log('this is Foo created')
  5. },
  6. mounted() {
  7. console.log('this is Foo mounted')
  8. },
  9. updated() {
  10. console.log('this is Foo updated')
  11. },
  12. destroyed() {
  13. console.log('this is Foo destroyed')
  14. },
  15. beforeRouteEnter(to, from, next) {
  16. console.log('in-component Foo beforeRouteEnter hook')
  17. next()
  18. },
  19. beforeRouteUpdate(to, from, next) {
  20. console.log('in-component Foo beforeRouteUpdate hook')
  21. next()
  22. },
  23. beforeRouteLeave(to, from, next) {
  24. console.log('in-component Foo beforeRouteLeave hook')
  25. next()
  26. }
  27. }

路由导航守卫

  1. const router = new VueRouter({
  2. mode: 'history',
  3. routes: [
  4. // in-component beforeRouteUpdate hook
  5. {
  6. path: '/foo/:id',
  7. component: Foo,
  8. beforeEnter: function guardRoute(to, from, next) {
  9. console.log('in-router Foo beforeEnter hook')
  10. next()
  11. }
  12. }
  13. ]
  14. })

输出

  1. in-global beforeEach hook
  2. in-component Foo beforeRouteUpdate hook
  3. in-global beforeResolve hook
  4. in-global afterEach hook