vue router 4 源码篇:导航守卫该如何设计(一)

Vue
483
0
0
2022-12-01

img

开场

哈喽大咖好,我是跑手,本次给大家继续探讨vue-router@4.x源码中有关导航守卫部分。

官方定义

导航守卫主要用来通过跳转或取消的方式守卫导航。这里有很多方式植入路由导航中:全局的,单个路由独享的,或者组件级的。

讲起导航守卫大家并不陌生,举个最常遇到的例子:在路由跳转时一般要判断用户是否登录或者有没有权限进入目标路由,这时候可以创建判断逻辑并放到router.beforeEach回调中,通过则跳转,否则拦截。也有些开发者会把这些逻辑放到组件里实现,其实这些都统称为导航守卫。

可获得的增益

在这章节中,你可以更系统并全面学习vue router的路由拦截模式和守卫设计模式,并可获得以下增益:

全面了解导航守卫核心源码; 掌握导航守卫设计模式; 全局导航守卫与路由独享守卫执行过程;

导航守卫分类

imgimage.png

总的来讲,vue-router@4.x的导航守卫可以分三大类:

  1. 全局守卫:挂载在全局路由实例上,每个导航更新时都会触发。
  2. 路由独享守卫:挂载在路由配置表上,当指定路由进入时触发。
  3. 组件内守卫:定义在vue组件中,当加载或更新指定组件时触发。

完整的导航解析流程

先上图:

imgimage.png

解析:

  1. 首先是vue-router的history监听器监致使导航被触发,触发形式包括但不局限于router.pushrouter.replacerouter.go等等。
  2. 调用全局的 beforeEach 守卫,开启守卫第一道拦截。
  3. 审视新组件,判断新旧组件一致时(一般调用replace方法),先执行步骤2,再调用组件级钩子beforeRouteUpdate拦截。
  4. 若新旧组件不一致时,先执行步骤2,再调用路由配置表中的beforeEnter钩子进行拦截。
  5. 接下来在组件beforeCreate周期调用组件级beforeRouteEnter钩子,在组件渲染前拦截。
  6. 执行解析守卫 beforeResolve
  7. 在导航被确认后,就是组件的this对象生成后,可以使用全局的 afterEach 钩子拦截。
  8. 触发 DOM 更新。
  9. 销毁组件前(执行unmounted),会调用beforeRouteLeave 守卫进行拦截。

拓展阅读:

官方流程描述: 1. 导航被触发。 2. 在失活的组件里调用 beforeRouteLeave 守卫。 3. 调用全局的 beforeEach 守卫。 4. 在重用的组件里调用 beforeRouteUpdate 守卫(2.2+)。 5. 在路由配置里调用 beforeEnter。 6. 解析异步路由组件。 7. 在被激活的组件里调用 beforeRouteEnter。 8. 调用全局的 beforeResolve 守卫(2.5+)。 9. 导航被确认。 10. 调用全局的 afterEach 钩子。 11. 触发 DOM 更新。 12. 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。

源码解析

全局守卫

全局导航守卫挂载在router实例上,有3个: 

  • beforeEach:前置守卫。当一个导航触发时按顺序调用。
  • beforeResolve:解析守卫。当一个导航触发时按顺序调用。触发时机为导航被确认之前,并且在所有组件内守卫和异步路由组件被解析之后。
  • afterEach:后置守卫。导航被确认后触发,不会改变导航本身,多用于给页面辅助函数。

在源码层面,因为全局守卫是挂载到router实例上的,因此我们可以在createRouter方法中中找到他们。全局的导航守卫是通过注册useCallbacks监听实现的,可以重新看下源码:

const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
const afterGuards = useCallbacks<NavigationHookAfter>()

// ...

return {
  // ...
  beforeEach: beforeGuards.add,
  beforeResolve: beforeResolveGuards.add,
  afterEach: afterGuards.add,
}

useCallbacks的定义:

/**
 * Create a list of callbacks that can be reset. Used to create before and after navigation guards list
 */
export function useCallbacks<T>() {
  let handlers: T[] = []

  function add(handler: T): () => void {
    handlers.push(handler)
    return () => {
      const i = handlers.indexOf(handler)
      if (i > -1) handlers.splice(i, 1)
    }
  }

  function reset() {
    handlers = []
  }

  return {
    add,
    list: () => handlers,
    reset,
  }
}

执行机制

细心的读者或许发现了,全局守卫只返回了xxx.add注册回调的方法,那在哪里执行呢?其实大家无需疑惑,执行在内部已经自动处理好了。整个守卫的执行机制大概是这样的,拿最简单的beforeEach举例:

第一步,使用者可以通过调用守卫钩子注册自己的回调逻辑,这时候其实是调用了beforeGuards.add方法,这样在beforeGuards.list就会把你的回调逻辑push进去。例如

router.beforeEach((to, from) => {
  console.log('注册自己回调逻辑')
})

第二步,在navigate被调用时(路由跳转时),会把list抽取出来逐个顺序执行:

runGuardQueue(guards)
  .then(() => {
    // check global guards beforeEach
    guards = []
    for (const guard of beforeGuards.list()) {
      guards.push(guardToPromiseFn(guard, to, from))
    }
    guards.push(canceledNavigationCheck)

    return runGuardQueue(guards)
  })

这里涉及2个比较重要的方法,guardToPromiseFnrunGuardQueue,前者是将回调逻辑组装成标准化的Promise执行链,后者是执行组装好的Promise序列,这两块也是今天要讲的内容。

最后,当runGuardQueue执行完,beforeEach的执行流程也随之结束。

guardToPromiseFn

  • 描述:将导航守卫回调封装成Promise,以便后续链式调用。
  • 入参:
  • guard: 其定义的导航守卫逻辑
  • to: 目标路由
  • from: 当前离开的路由
  • record(可选): 路由record,用于组件内守卫时回调处理
  • name:(可选): 路由名称,用于组件内守卫时回调处理
  • 返回:Promise封装的守卫回调

enterCallbackArray处理

首先,保存enterCallbackArray序列的引用,保证组件内守卫回调不丢失。

// keep a reference to the enterCallbackArray to prevent pushing callbacks if a new navigation took place
const enterCallbackArray =
  record &&
  // name is defined if record is because of the function overload
  (record.enterCallbacks[name!] = record.enterCallbacks[name!] || [])

next函数声明

其次就是return 封装好的Promise,它包含:

我们常用的next函数:

const next: NavigationGuardNext = (
  valid?: boolean | RouteLocationRaw | NavigationGuardNextCallback | Error
) => {
  // 参数为false时,抛出异常拦截路由跳转 
  if (valid === false) {
    reject(
      createRouterError<NavigationFailure>(
        ErrorTypes.NAVIGATION_ABORTED,
        {
          from,
          to,
        }
      )
    )
  } else if (valid instanceof Error) {
    // 参数为Error时,抛出异常拦截路由跳转 
    reject(valid)
  } else if (isRouteLocation(valid)) {
    // 参数为路由路径时会进行重定向,此时要抛出异常并拦截 
    reject(
      createRouterError<NavigationRedirectError>(
        ErrorTypes.NAVIGATION_GUARD_REDIRECT,
        {
          from: to,
          to: valid,
        }
      )
    )
  } else {
    // 如果参数为回调函数,会将这个函数添加到record.enterCallbacks[name]中,等待导航确认后再执行 
    if (
      enterCallbackArray &&
      // since enterCallbackArray is truthy, both record and name also are
      record!.enterCallbacks[name!] === enterCallbackArray &&
      typeof valid === 'function'
    ) {
      enterCallbackArray.push(valid)
    }
    resolve()
  }
}

这里说下next()常规的入参方式,有5种:

  • next():表示无任何拦截。
  • next(new Error('error message')):表示拦截成功,终止路由跳转。
  • next(ture || false):ture允许跳转,false终止跳转。
  • next('/index') 或 next({path: '/index'}):参数为路由对象,这种情况会导致跳转死循环,也会被程序拦截。
  • next(callback):参数为回调函数

在上面的逻辑中,if (valid === false)会命中next(false),直接抛出reject异常拦截路由跳转。

往下走是else if (valid instanceof Error),命中next(new Error('error message'))这种情况,也会抛出reject异常拦截路由跳转。

再次,else if (isRouteLocation(valid))则会命中next('/index') 或 next({path: '/index'}),这种情况也要抛出异常进行拦截,不然会导致页面不断跳转,如下面例子:

imgimage.png

最后,如果参数为回调函数,会将这个函数添加到record.enterCallbacks[name]中,等待导航确认后再执行。

Promise封装

接下来就是把守卫封装到Promise里面了,使得它同时支持同步和异步回调。

// wrapping with Promise.resolve allows it to work with both async and sync guards

// 将当前组件绑定到导航守卫上,返回给guardReturn变量
const guardReturn = guard.call(
  record && record.instances[name!],
  to,
  from,
  __DEV__ ? canOnlyBeCalledOnce(next, to, from) : next
)
// 组装包含当前组件的守卫到Promise.resolve中去
let guardCall = Promise.resolve(guardReturn)

// ①当传进来的导航守卫参数少于3个时(即没有使用next参数),直接使用上面声明好的next方法来承载回调,并把guardReturn作为参数传进next中
if (guard.length < 3) guardCall = guardCall.then(next)

// 如果使用到next参数时
if (__DEV__ && guard.length > 2) {
  const message = `The "next" callback was never called inside of ${
    guard.name ? '"' + guard.name + '"' : ''
  }:\n${guard.toString()}\n. If you are returning a value instead of calling "next", make sure to remove the "next" parameter from your function.` 
  if (typeof guardReturn === 'object' && 'then' in guardReturn) {
    guardCall = guardCall.then(resolvedValue => {
      // ②如果守卫逻辑中返回Promise且未调用next()方法时,抛出异常 
      // @ts-expect-error: _called is added at canOnlyBeCalledOnce 
      if (!next._called) {
        warn(message)
        return Promise.reject(new Error('Invalid navigation guard'))
      }
      // ③合法守卫 
      return resolvedValue
    })
  } else if (guardReturn !== undefined) {
    // @ts-expect-error: _called is added at canOnlyBeCalledOnce 
    if (!next._called) {
      // ④当有返回值,并且未调用next()也会抛出异常 
      warn(message)
      reject(new Error('Invalid navigation guard'))
      return
    }
  }
}
guardCall.catch(err => reject(err))

为了方便大家理解,以下使用场景分别会命中上面对应逻辑:

// 命中①,good
router.beforeEach((to, form) => {
  return Promise.resolve(() => {
    console.log(1)
  })
  // or 
  // return 1
})

// 命中②,bad
router.beforeEach((to, form, next) => {
  return Promise.resolve(() => {
    console.log(1)
  })
})

// 命中③,good
router.beforeEach((to, form, next) => {
  return next(() => 
    Promise.resolve(() => {
      console.log(1)
    })
  )
})

// 命中④,bad
router.beforeEach((to, form, next) => {
  return 1
})

总结一下,只要我们在导航守卫中用到了next参数,都应该在函数体使用next,否则就会报错;假如没使用next参数,那么必须在函数体中有返回值,因为这个值会以参数形式传递给guardToPromiseFn中声明的next方法,以保证导航正确执行。

runGuardQueue

  • 描述:链式调用导航守卫。
  • 入参:guards(导航守卫回调序列)
  • 返回:Promise

源码:

function runGuardQueue(guards: Lazy<any>[]): Promise<void> {
  return guards.reduce(
    (promise, guard) => promise.then(() => guard()),
    Promise.resolve()
  )
}

将封装好的导航守卫序列逐一执行。

beforeEach执行

.then(() => {
  // check global guards beforeEach
  guards = []
  for (const guard of beforeGuards.list()) {
    guards.push(guardToPromiseFn(guard, to, from))
  }
  guards.push(canceledNavigationCheck)

  return runGuardQueue(guards)
})

beforeResolve执行

.then(() => {
  // check global guards beforeResolve
  guards = []
  for (const guard of beforeResolveGuards.list()) {
    guards.push(guardToPromiseFn(guard, to, from))
  }
  guards.push(canceledNavigationCheck)

  return runGuardQueue(guards)
})

afterEach执行

function triggerAfterEach(
  to: RouteLocationNormalizedLoaded,
  from: RouteLocationNormalizedLoaded,
  failure?: NavigationFailure | void
): void {
  // navigation is confirmed, call afterGuards 
  // TODO: wrap with error handlers 
  for (const guard of afterGuards.list()) guard(to, from, failure)
}

路由独享守卫beforeEnter

由于beforeEnter在路由配置表中定义的,因此直接读取record.beforeEnter将守卫取出。源码如下:

.then(() => {
  // check the route beforeEnter
  guards = []
  for (const record of to.matched) {
    // do not trigger beforeEnter on reused views 
    if (record.beforeEnter && !from.matched.includes(record)) {
      if (isArray(record.beforeEnter)) {
        for (const beforeEnter of record.beforeEnter)
          guards.push(guardToPromiseFn(beforeEnter, to, from))
      } else {
        guards.push(guardToPromiseFn(record.beforeEnter, to, from))
      }
    }
  }
  guards.push(canceledNavigationCheck)

  // run the queue of per route beforeEnter guards 
  return runGuardQueue(guards)
})

当然,路由独享守卫的封装和全局守卫一致,都是通过guardToPromiseFn完成。

落幕

此致当前,我们已经把导航守卫的核心机制、全局守卫和路由独享守卫的原理都剖析过了,下一节继续把组件内守卫给大家讲解,最后感谢大家阅览并欢迎纠错。