VueRouter 原理解读 - 导航守卫的实现

962 阅读23分钟

一、导航守卫知识

1.1 官方概念解释

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

1.2 基本使用

全局守卫:

  • beforeEach
  • beforeResolve
  • afterEach
const router = createRouter({ ... })

// 全局前置守卫
router.beforeEach((to, from, next) => {
  if (to.name !== 'Login' && !isAuthenticated) next({ name: 'Login' })
  else next()
})

// 全局解析守卫
router.beforeResolve(async to => {
  if (to.meta.requiresCamera) {
    try {
      await askForCameraPermission()
    } catch (error) {
      if (error instanceof NotAllowedError) {
        // ... 处理错误,然后取消导航
        return false
      } else {
        // 意料之外的错误,取消导航并把错误传给全局处理器
        throw error
      }
    }
  }
})

// 全局后置钩子
router.afterEach((to, from, failure) => {
  if (!failure) sendToAnalytics(to.fullPath)
})

路由守卫:

  • beforeEnter
function removeQueryParams(to) {
  if (Object.keys(to.query).length)
    return { path: to.path, query: {}, hash: to.hash }
}

function removeHash(to) {
  if (to.hash) return { path: to.path, query: to.query, hash: '' }
}

const routes = [
  {
    path: '/users/:id',
    component: UserDetails,
    beforeEnter: [removeQueryParams, removeHash],
  },
  {
    path: '/about',
    component: UserDetails,
    beforeEnter: [removeQueryParams],
  },
]

组件守卫:

  • beforeRouteEnter
  • beforeRouteUpdate
  • beforeRouteLeave
const UserDetails = {
  template: `...`,
  beforeRouteEnter(to, from) {
    // 在渲染该组件的对应路由被验证前调用
    // 不能获取组件实例 `this` !
    // 因为当守卫执行时,组件实例还没被创建!
  },
  beforeRouteUpdate(to, from) {
    // 在当前路由改变,但是该组件被复用时调用
    // 举例来说,对于一个带有动态参数的路径 `/users/:id`,在 `/users/1` 和 `/users/2` 之间跳转的时候,
    // 由于会渲染同样的 `UserDetails` 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
    // 因为在这种情况发生的时候,组件已经挂载好了,导航守卫可以访问组件实例 `this`
  },
  beforeRouteLeave(to, from) {
    // 在导航离开渲染该组件的对应路由时调用
    // 与 `beforeRouteUpdate` 一样,它可以访问组件实例 `this`
  },
}

1.3 导航解析流程

先来看看 VueRouter 官方对导航守卫的一个完整流程的解析:

  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 的回调函数,创建好的组件实例会作为回调函数的参数传入。





二、源码实现解析

2.1 前情回顾

在过往的 “VueRouter 初始化” 的这篇原理解析的文章当中有提及到一部分的导航守卫的相关解析但是当时也只是浅析了逻辑,这里就跟着之前的思路再进一步进行分析具体的详细实现。

首先我们来回顾一下在createRouter方法当中对导航守卫所做的处理。

  • 首先是在createrouter方法内部通过useCallbacks方法注册了三个全局守卫的方法beforeEachbeforeResolveafterEach并且将其注册的方法作为 router 对象同名属性抛出;
// vuejs:router/packages/router/src/utils/callbacks.ts

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,
  }
}
// vuejs:router/packages/router/src/router.ts

import { useCallbacks } from './utils/callbacks'

export function createRouter(options: RouterOptions): Router {
  const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const afterGuards = useCallbacks<NavigationHookAfter>()

  // ··· ···

  const router: Router = {
    // ··· ···

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

  return router
}
  • 然后在调用 push 或者 replace 方法进行路由跳转的时候的会调用createrouter内部的pushWithRedirect方法,在这个方法当中除了处理浏览器的historypushStatereplaceState这两个 api 的前端路由处理外还会调用同样createrouter内部的navigate方法,而该navigate方法是专门进行订阅的发布回调执行处理逻辑,使用runGuardQueue方法触发对应的导航守卫注册的订阅回调事件。
// vuejs:router/packages/router/src/router.ts

function navigate(
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded
): Promise<any> {
  let guards: Lazy<any>[]

  // 根据 to - 要跳转的路由 以及 from - 要提取相关路由信息
  //		leavingRecords:当前即将离开的路由
  //		updatingRecords:要更新的路由
  //		enteringRecords:要跳转的目标路由
  const [leavingRecords, updatingRecords, enteringRecords] = extractChangingRecords(to, from)

  // extractComponentsGuards 方法用于提取不同的路由钩子,第二个参数可传值:beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave
  guards = extractComponentsGuards(
    leavingRecords.reverse(), // 这里因为Vue组件销毁顺序是从子到父,因此使用reverse反转数组保证子路由钩子顺序在前
    'beforeRouteLeave',
    to,
    from
  )

  // 将失活组件的 onBeforeRouteLeave 导航守卫都提取并且添加到 guards 里
  for (const record of leavingRecords) {
    record.leaveGuards.forEach(guard => {
      guards.push(guardToPromiseFn(guard, to, from))
    })
  }

  // 检查当前正在处理的目标跳转路由和 to 是否相同路由,如果不是的话则抛除 Promise 异常
  const canceledNavigationCheck = checkCanceledNavigationAndReject.bind(null, to, from)

  guards.push(canceledNavigationCheck)

  return (
    runGuardQueue(guards) // 作为启动 Promise 开始执行失活组件的 beforeRouteLeave 钩子
    .then(() => {
      // 执行全局 beforeEach 钩子
      guards = []
      for (const guard of beforeGuards.list()) {
        guards.push(guardToPromiseFn(guard, to, from))
      }
      guards.push(canceledNavigationCheck)

      return runGuardQueue(guards)
    })
    .then(() => {
      // 执行重用组件的 beforeRouteUpdate 钩子
      guards = extractComponentsGuards(
        updatingRecords,
        'beforeRouteUpdate',
        to,
        from
      )

      for (const record of updatingRecords) {
        record.updateGuards.forEach(guard => {
          guards.push(guardToPromiseFn(guard, to, from))
        })
      }
      guards.push(canceledNavigationCheck)

      return runGuardQueue(guards)
    })
    .then(() => {
      // 执行全局 beforeEnter 钩子
      guards = []
      for (const record of to.matched) {
        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)

      return runGuardQueue(guards)
    })
    .then(() => {
      // 清除已经存在的 enterCallbacks, 因为这些已经在 extractComponentsGuards 里面添加
      to.matched.forEach(record => (record.enterCallbacks = {}))

      // 执行被激活组件的 beforeRouteEnter 钩子
      guards = extractComponentsGuards(
        enteringRecords,
        'beforeRouteEnter',
        to,
        from
      )
      guards.push(canceledNavigationCheck)

      return runGuardQueue(guards)
    })
    .then(() => {
      // 执行全局 beforeResolve 钩子
      guards = []
      for (const guard of beforeResolveGuards.list()) {
        guards.push(guardToPromiseFn(guard, to, from))
      }
      guards.push(canceledNavigationCheck)

      return runGuardQueue(guards)
    })
    .catch(err =>
      // 处理在过程当中抛除的异常或者取消导航跳转操作
      isNavigationFailure(err, ErrorTypes.NAVIGATION_CANCELLED)
      ? err
      : Promise.reject(err)
          )
  )
}

前情回顾大致如此,是时候回收之前埋下的伏笔(未填的坑)了,之前仅仅是讲述了全局导航守卫的一个大致的流程,但是具体里面许多细节、具体的runGuardQueueguardToPromiseFn这些方法的实现逻辑还是没有交代清楚还没进行仔细分析的、以及其他路由守卫、组件守卫的实现与触发都还没深入了解,因此接下来就正式进入到这块源码解读的旅程当中。

2.2 相关方法解析

我们先来分解下相关的一些底层方法的作用和实现原理,对后面整个导航守卫的执行的理解会有极大的帮助作用。

extractChangingRecords:提取路由信息与相关的守卫导航钩子

  • extractChangingRecords 方法是在路由导航跳转时候对相关需要处理的路由进行提取,包括当前要离开的旧路由、要更新的路由、要跳转进入的新路由这三类路由信息。

相关的源码解析都已经在该 VueRouter 源码解析系列的上一篇 “路由匹配器” 解析文章当中进行剖析了,因此这里就不再进行源码分析了。传送门:juejin.cn/post/722914…

extractComponentsGuards:提取组件守卫导航钩子

在 VueRouter 进行路由切换跳转时需要执行一系列的导航守卫,这时候就会调用这个extractComponentsGuards方法来获取所需的相关组件导航守卫订阅回调列表的数据。

// vuejs:router/packages/router/src/navigationGuards.ts

export function extractComponentsGuards(
  matched: RouteRecordNormalized[],
  guardType: GuardType,
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded
) {
  const guards: Array<() => Promise<void>> = []

  // 遍历路由匹配器数组
  for (const record of matched) {
    // 遍历路由匹配项对应的视图组件
    for (const name in record.components) {
      let rawComponent = record.components[name]

      // guardType 仅接收三个参数:beforeRouteUpdate、beforeRouteEnter、beforeRouteLeave
      //  其中仅 beforeRouteEnter 是可以在组件还没初始化完成挂载前处理,其他都需要组件完成初始化挂载的操作
      if (guardType !== 'beforeRouteEnter' && !record.instances[name]) continue

      if (isRouteComponent(rawComponent)) { // 是已经加载了的路由组件的情况 - 直接从组件配置当中获取对应的导航钩子
        const options: ComponentOptions = (rawComponent as any).__vccOpts || rawComponent
        const guard = options[guardType]
        guard && guards.push(guardToPromiseFn(guard, to, from, record, name))
      } else { // 否则通过 Lazy 异步请求加载组件后再通过组件配置获取对应的导航钩子
        let componentPromise: Promise<RouteComponent | null | undefined | void> = (rawComponent as Lazy<RouteComponent>)()

        guards.push(() =>
          componentPromise.then(resolved => {
            const resolvedComponent = isESModule(resolved) ? resolved.default : resolved
            record.components![name] = resolvedComponent
            
            const options: ComponentOptions = (resolvedComponent as any).__vccOpts || resolvedComponent
            const guard = options[guardType]
            
            return guard && guardToPromiseFn(guard, to, from, record, name)()
          })
        )
      }
    }
  }

  return guards
}

函数接受四个参数:matched 表示匹配到的路由记录数组,guardType 表示需要提取的导航守卫类型,to 和 from 表示目标路由和当前路由。

  • matched 是通过上面的extractChangingRecords方法所提取出的 leavingRecords、updatingRecords、enteringRecords 的相关路由匹配器数组
  • guardType 仅接收四种类型的导航守卫 - beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave

函数首先创建一个空数组guards,用于存放转换后的导航守卫函数;

然后,函数遍历路由记录的每一个组件,查找包含指定类型导航守卫函数的组件:

  • 如果该组件是已经挂载的路由组件,就直接通过组件的 options 提取导航守卫函数,并将其使用guardToPromiseFn转换为 Promise 形式添加到 guards 数组中。
  • 如果该组件是异步加载的组件,则需要先请求该组件的代码块,并在代码块加载完成后再同样从组件 options 中提取导航守卫函数,并将其使用guardToPromiseFn包装转换为 Promise 形式添加到 guards 数组中。

最后,函数返回这个guards数组,其中每个元素都是转换后的 Promise 形式的组件导航守卫函数。

guardToPromiseFn:使用 Promise 包装守卫回调

export function guardToPromiseFn(
  guard: NavigationGuard,
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded
): () => Promise<void>
export function guardToPromiseFn(
  guard: NavigationGuard,
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded,
  record: RouteRecordNormalized,
  name: string
): () => Promise<void>
export function guardToPromiseFn(
  guard: NavigationGuard,
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded,
  record?: RouteRecordNormalized,
  name?: string
): () => Promise<void> {
  // 存储各类导航守卫的回调数组,并且其赋值给当前路由记录对象的 enterCallbacks 对应名字key的导航守卫属性上
  const enterCallbackArray = record && (record.enterCallbacks[name!] = record.enterCallbacks[name!] || [])

  // 返回一个函数,函数执行后返回一个 Promise 方法
  return () => new Promise((resolve, reject) => {
    // 封装 next 方法
    const next: NavigationGuardNext = (valid?: boolean | RouteLocationRaw | NavigationGuardNextCallback | Error) => {
      // ··· ··· 判断参数进行不同的处理逻辑
    }

    // 使用 call 方法修改 this 指向为当前的路由组件来执行导航守卫,
    // 并将 to 跳转路由、from 当前跳转前路由、next 确认跳转前回调作为参数传递进入执行
    const guardReturn = guard.call(record && record.instances[name!], to, from, next)
    let guardCall = Promise.resolve(guardReturn)

    // 参数小于三个时(即没有使用 next 参数时候)
    if (guard.length < 3) guardCall = guardCall.then(next)

    // 处理异常
    guardCall.catch(err => reject(err))
  })
}

guardToPromiseFn返回一个函数,该函数返回一个 Promise,执行过程如下:

  1. 创建enterCallbackArray变量记录导航守卫的回调并将当前路由记录的enterCallbacks属性赋值给该变量。
    1. 这个enterCallbackArray变量会在对next方法进行详解时候进行提及分析使用,目前在此可能涉及不多。
  1. 创建和返回一个 Promise 方法:
    1. Promise 内创建一个next函数,
      1. 这个方法是用于在导航守卫中控制跳转行为的,这里先留有一个印象,下面的next回调方法的章节当中再进一步对该方法进行解析讲解;
    1. 使用 call 方法修改 this 指向为当前的路由组件来执行导航守卫并将 to 跳转路由、from 当前跳转前路由、next 确认跳转前回调作为参数传递来执行;
    2. 之后将执行结果赋值给 guardReturn 变量,然后进一步将 guardReturn 作为新的 Promise.resolve 方法的返回值并将该新的 Promise 方法赋值给 guardCall;
    3. 判断如果导航守卫的回调没使用next参数则直接使用上面声明好的next方法来承载导航守卫的next回调,并把guardReturn作为参数传进next中。
      1. 可以使用函数的长度 length 属性来获取判断导航守卫的形参数量;
      2. 导航回调 NavigationGuard 是有三个参数 to/from/next ,当 length 属性小于 3 证明没使用 next 参数;

总的来说,guardToPromiseFn的作用就是将导航守卫函数转化成一个返回 Promise 的函数,并提供了一个next函数用于在守卫中控制跳转行为。

runGuardQueue:执行守卫回调队列

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

该方法的实现并不难理解,就是使用reduce方法对要执行的守卫订阅回调列表进行遍历,对每一个回调guard进行执行调用。

  • 这里使用reduce好处是能够将上一个回调的执行返回传递给遍历的下一项;
  • 通过对上一个遍历项返回值进行Promise.then调用来判断上一个回调的执行效果是否有报错,如果上一个回调执行当中抛出了异常错误的话则能够停止后面守卫回调方法的执行。





三、导航守卫的执行流程解析

理解了上面的 promise 包装和回调订阅队列回调的等相关方法逻辑后,对于 VueRouter 进行路由导航跳转时候执行一系列导航守卫流程的具体源码实现也浮出水面了。

在这里我是将 VueRouter 路由跳转过程当中相关导航守卫的执行进行了一个以流程为分界线的划分:

  • 路由跳转前的导航守卫执行;
    • 其中简略划分启动执行与后续系列执行;
  • 路由跳转后的导航守卫执行;

3.1 导航守卫订阅回调的启动执行

在前情回顾当中我们了解到在路由导航跳转会在navigate执行相关的导航守卫,因此一系列的导航守卫的启动执行基本都在这个navigate方法内。

// vuejs:router/packages/router/src/router.ts

function navigate(
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded
): Promise<any> {
  let guards: Lazy<any>[]

  // 根据 to - 要跳转的路由 以及 from - 要提取相关路由信息
  //		leavingRecords:当前即将离开的路由
  //		updatingRecords:要更新的路由
  //		enteringRecords:要跳转的目标路由
  const [leavingRecords, updatingRecords, enteringRecords] = extractChangingRecords(to, from)

  // extractComponentsGuards 方法用于提取不同的路由钩子,第二个参数可传值:beforeRouteEnter、beforeRouteUpdate、beforeRouteLeave
  guards = extractComponentsGuards(
    leavingRecords.reverse(), // 这里因为Vue组件销毁顺序是从子到父,因此使用reverse反转数组保证子路由钩子顺序在前
    'beforeRouteLeave',
    to,
    from
  )

  // 将失活组件的 onBeforeRouteLeave 导航守卫都提取并且添加到 guards 里
  for (const record of leavingRecords) {
    record.leaveGuards.forEach(guard => {
      guards.push(guardToPromiseFn(guard, to, from))
    })
  }

  // 检查当前正在处理的目标跳转路由和 to 是否相同路由,如果不是的话则抛除 Promise 异常
  const canceledNavigationCheck = checkCanceledNavigationAndReject.bind(null, to, from)

  guards.push(canceledNavigationCheck)

  return (
    runGuardQueue(guards) // 作为启动 Promise 开始执行失活组件的 beforeRouteLeave 钩子
    .then(() => {
      // ··· ···
    })
    // ··· ··· 按顺序逻辑执行各类导航守卫 guards 订阅回调
    .catch(err =>
      // 处理在过程当中抛除的异常或者取消导航跳转操作
      isNavigationFailure(err, ErrorTypes.NAVIGATION_CANCELLED)
      ? err
      : Promise.reject(err)
          )
  )
}

结合着前面章节对导航守卫执行的流程和提前讲述的几个方法来重新看naviagte这个方法就很浅显的能够看出启动流程了:

  • 首先是通过extractChangingRecords方法获取到三个不同状态的路由匹配数组:
    • 包括 leavingRecords:当前即将离开的路由、updatingRecords:要更新的路由、enteringRecords:要跳转的目标路由;
  • 接着使用extractComponentsGuards方法传入leavingRecords要跳转离开路由数组变量来提取对应离开路由的组件守卫回调beforeRouteLeave
    • 这里有一个细节就是因为Vue组件销毁顺序是从子到父,因此需要使用reverse反转leavingRecords数组保证获取到的组件导航钩子是子路由顺序在前;
  • 除了使用extractComponentsGuards方法获取组件当中 options 写的beforeRouteLeave钩子回调,在 VueRouter 4.x 当中是兼容 Vue 3.x 的 hooks 方法,因此提供了两个onBeforeRouteLeaveonBeforeRouteUpdate组合式 api 来设置对应的导航守卫回调(VueRouter 4.x 针对 Vue.js 的 3.x 提供的组合式 api 后续会整合一篇单独的文章进行详解),因此这块也是需要处理:
    • 通过组合式 api onBeforeRouteLeave声明的守卫回调会处理设置到路由配置的leaveGuards这个属性当中,因此这里直接获取并处理,
    • 在获取到跳转离开的组件守卫onBeforeRouteLeave回调leaveGuards属性后先进行遍历并对每一项回调使用guardToPromiseFn进行一个 Promise 和 next 方法参数注入的包装处理,处理后将其放入前面extractComponentsGuards获取到的同beforeRouteLeave类型组件守卫回调数组当中;
  • 紧接着就是调用runGuardQueue方法,参数传入两个方面获取和包装后的beforeRouteLeave导航守卫回调数组;
    • runGuardQueue方法在前面章节当中也提前解析了,就是通过reduce逐个对导航守卫回调方法进行调用执行,都执行成功则正常处理返回 Promise.resolve 状态,这样外部就能通过 then 继续往下执行。
    • 接下来 then 里面就是继续执行接下来的一连串的导航守卫回调了(其他的导航守卫的回调执行后面再一步步进行分析

好了,这个就是 VueRouter 中一系列的导航守卫的启动流程,经过前面方法的解析和一步步逻辑进行分解已经变得非常好理解了。

3.2 跳转前导航守卫系列的顺序执行

通过前面对导航守卫启动的执行的逻辑分析我们知道是在naviagte方法里面是通过runGuardQueue执行 beforeRouteLeave 组件导航守卫作为一系列路由跳转的导航守卫回调执行的启动点,接下来就是在 then 的回调里面继续进行跳转前的其他导航守卫回调。

// vuejs:router/packages/router/src/router.ts

function navigate(
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded
): Promise<any> {
  let guards: Lazy<any>[]

  // ··· ···

  return (
    runGuardQueue(guards) // 作为启动 Promise 开始执行失活组件的 beforeRouteLeave 钩子
    .then(() => {
      // 执行全局 beforeEach 钩子
      guards = []
      for (const guard of beforeGuards.list()) {
        guards.push(guardToPromiseFn(guard, to, from))
      }
      guards.push(canceledNavigationCheck)

      return runGuardQueue(guards)
    })
    .then(() => {
      // 执行重用组件的 beforeRouteUpdate 钩子
      guards = extractComponentsGuards(
        updatingRecords,
        'beforeRouteUpdate',
        to,
        from
      )

      for (const record of updatingRecords) {
        record.updateGuards.forEach(guard => {
          guards.push(guardToPromiseFn(guard, to, from))
        })
      }
      guards.push(canceledNavigationCheck)

      return runGuardQueue(guards)
    })
    .then(() => {
      // 执行全局 beforeEnter 钩子
      guards = []
      for (const record of to.matched) {
        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)

      return runGuardQueue(guards)
    })
    .then(() => {
      // 执行被激活组件的 beforeRouteEnter 钩子
      guards = extractComponentsGuards(
        enteringRecords,
        'beforeRouteEnter',
        to,
        from
      )
      guards.push(canceledNavigationCheck)

      return runGuardQueue(guards)
    })
    .then(() => {
      // 执行全局 beforeResolve 钩子
      guards = []
      for (const guard of beforeResolveGuards.list()) {
        guards.push(guardToPromiseFn(guard, to, from))
      }
      guards.push(canceledNavigationCheck)

      return runGuardQueue(guards)
    })
    .catch(err =>
      // 处理在过程当中抛除的异常或者取消导航跳转操作
      isNavigationFailure(err, ErrorTypes.NAVIGATION_CANCELLED) ? err : Promise.reject(err))
  )
}

让我们回顾一下导航守卫的执行流程:

beforeRouteLeave -> beforeEach -> beforeRouteUpdate -> beforeEnter -> beforeRouteEnter -> beforeResolve -> afterEach -> beforeRouteEnter 的 next 回调

根据这个流程回归到navigate方法当中,在 then 的回调当中首先就是beforeEach的执行,因为beforeEach是全局守卫,在前面已经通过useCallbackshook 方法创建了beforeEach对应的全局导航回调变量beforeGuards,因此这里直接使用这个beforeGuards进行遍历并使用guardToPromiseFn对每一项回调进行 Promise 形式包装,都包装完成后同样使用runGuardQueue执行包装完成的回调数组;

执行beforeEach全局守卫之后就是beforeRouteUpdate这个组件守卫的回调执行,和前面beforeRouteLeave这个组件守卫类似,该组件守卫是存在两种形式:

    • 先通过extractComponentsGuards传入updatingRecords获取组件内的beforeRouteUpdate的组件导航守卫回调钩子;
    • 接着通过updateGuards属性来获取使用onBeforeRouteUpdate组合式 api 创建的守卫回调,获取到相关守卫钩子后使用guardToPromiseFn包装,包装处理好两种形式的守卫回调为一个数组后使用runGuardQueue来遍历钩子逐个执行;

beforeRouteUpdate之后就是beforeEnter路由守卫,由于beforeEnter在初始化调用createRouter传入的routes路由配置参数中定义的,因此直接从需要跳转的路由项配置当中beforeEnter属性将守卫取出,判断类型进行guardtoPromiseFn包装与runGuardQueue执行;

beforeEnter后紧接着是组件守卫beforeRouteEnter,同样这里首先还是通过extractComponentsGuards传入enteringRecords获取对应的导航钩子回调然后使用runGuardQueue对获取到的beforeRouteEnter守卫钩子进行执行;

在导航确认跳转前执行的最后一个就是beforeResolve这个全局守卫钩子,执行逻辑基本上和beforeEachbeforeEnter一样,一开始useCallbackshooks 方法声明创建钩子变量,接着使用guardToPromiseFn包装,包装完使用runGuardQueue执行。

至此,在 VueRouter 一次路由导航跳转的确认跳转前执行的导航守卫基本上就是按照这流程简略的分析理解了一波,接下来就是导航跳转的确认 afterEach 和跳转后的 beforeRouteEnter next 回调的执行。

3.3 路由跳转后的导航守卫的执行

在上一个小节当中我们已经分析了在路由跳转确认前相关的导航守卫的执行了(基本就是createrouter内部真正处理跳转的pushWithRedirect方法所调用的navigate的逻辑),但是在官方文档当中我们能够得知一次完整的路由跳转当中还有一些导航守卫的钩子(路由确认并跳转后的钩子)并未在navigate方法内部中执行,下面就来分析这部分路由确认跳转后相关的导航守卫的执行实现逻辑。

// vuejs:router/packages/router/src/router.ts

function pushWithRedirect(
  to: RouteLocationRaw | RouteLocation,
  redirectedFrom?: RouteLocation
): Promise<NavigationFailure | void | undefined> {
  // ··· ···

  return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
    .catch((error: NavigationFailure | NavigationRedirectError) =>
      // ··· ··· 处理导航跳转异常
    ).then((failure: NavigationFailure | NavigationRedirectError | void) => {
			// ··· ···

      // 手动触发 afterEach 全局守卫
      triggerAfterEach(toLocation as RouteLocationNormalizedLoaded, from, failure)

      return failure
    })
}
// vuejs:router/packages/router/src/router.ts

function triggerAfterEach(
  to: RouteLocationNormalizedLoaded,
  from: RouteLocationNormalizedLoaded,
  failure?: NavigationFailure | void
): void {
  for (const guard of afterGuards.list()) guard(to, from, failure)
}

我们回归到 VueRouter 当中处理路由跳转的pushWithRedirect方法当中去,能看到在执行navigate方法后会在 then 的回调当中调用triggerAfterEach方法来手动触发afterEach全局守卫的钩子回调。

triggerAfterEach方法的实现也就及其简单,直接遍历使用useCallbacks专门给afterEach全局守卫创建的钩子回调,对遍历的每一项直接进行调用处理,afterEach这个全局守卫的执行实现逻辑也就如此。

3.4 导航守卫 next 回调相关知识

在前面已经将 VueRouter 一系列的导航守卫的实现原理讲述的差不多了,但是针对导航守卫的next回调这块在前面的章节当中仅在guardToPromiseFn方法源码当中解析了,具体的执行和细节可能还没完整解析,因此这个章节对导航守卫的 next 参数和 next 回调的执行进行一个更为详细的讲解。

next 回调函数的参数及效用

在导航守卫当中,其实还有一个可选的第三个参数next,这是一个方法函数,会根据调用该方法时候传递不同的参数进行不同的反馈效用:

next有5种不同形式的参数(NavigationGuardNext 的定义):

export interface NavigationGuardNext {
  (): void
  (error: Error): void
  (location: RouteLocationRaw): void
  (valid: boolean | undefined): void
  (cb: NavigationGuardNextCallback): void
}
  • next():不传任何参数值表示无任何拦截
  • next(new Error('error message')):传递 Error 错误表示拦截成功,终止路由跳转
  • next(ture || false):传递 Boolean 值的情况 ture 允许跳转,false 终止跳转
  • next('/index') 或 next({ path: '/index' }):传递参数为路由路径或者路由对象时候会让其进行重定向(底层实现会往外抛出重定向的信息)
  • next(callback):传递一个函数作为参数的话会在后续进行回调调用

next 回调的实现原理

在了解next方法的几种不同的参数传递调用后我们再回归到前面的guardToPromiseFn方法的详细源码当中,导航守卫的next方法就是在这方法当中进行处理的。前面的章节当中我们了解到这个方法是对导航守卫的回调方法进行一个包装处理,里面声明了同名的一个next方法,并且也是作为参数传递给了导航守卫回调进行使用,接下来我们来具体分析下这个next方法的具体内部逻辑实现是怎样的。

export function guardToPromiseFn(
  guard: NavigationGuard,
  to: RouteLocationNormalized,
  from: RouteLocationNormalizedLoaded,
  record?: RouteRecordNormalized,
  name?: string
): () => Promise<void> {
  // 存储各类导航守卫的回调数组,并且其赋值给当前路由记录对象的 enterCallbacks 同导航守卫名的属性上
  const enterCallbackArray = record && (record.enterCallbacks[name!] = record.enterCallbacks[name!] || [])

  // 返回一个函数,函数执行后返回一个 Promise 方法
  return () => new Promise((resolve, reject) => {
    // 封装 next 方法
    const next: NavigationGuardNext = (valid?: boolean | RouteLocationRaw | NavigationGuardNextCallback | Error) => {
      if (valid === false) { // 处理 valid 参数为 false 情况,reject 抛出导航跳转取消错误
        reject(createRouterError<NavigationFailure>(ErrorTypes.NAVIGATION_ABORTED, { from, to, }))
      } else if (valid instanceof Error) { // 处理执行报错情况,直接 reject 抛出该报错
        reject(valid)
      } else if (isRouteLocation(valid)) { // 处理参数能够匹配为路由配置当中配置的路由路径时会进行重定向,因此 reject 抛出重定向的错误
        reject(createRouterError<NavigationRedirectError>(ErrorTypes.NAVIGATION_GUARD_REDIRECT, { from: to, to: valid, }))
      } else { // 正常运行,无拦截操作
        if (
          enterCallbackArray &&
          record!.enterCallbacks[name!] === enterCallbackArray &&
          typeof valid === 'function'
        ) { // 处理 valid 参数为函数(也就是回调函数)情况将回调函数推入存储各类导航守卫的回调数组中
          enterCallbackArray.push(valid)
        }
        resolve()
      }
    }

    // ··· ···
  })
}

通过上面对next方法的代码的实现能够看到,这个方法对调用传入的参数进行了不同类型的判断处理操作:

  • 判断 valid 参数为 boolean 类型并且值为 false 情况,reject 抛出导航跳转取消错误;
  • 判断 valid 参数为异常错误 Error 类型则处理执行报错情况,直接 reject 抛出该报错;
  • 判断 valid 参数能够匹配为路由配置当中配置的路由路径时会进行重定向,因此 reject 抛出重定向的错误类型;
  • 前面条件都不成立的情况下则会正常进行导航跳转的确认,并且在其中会判断 valid 参数是否是函数类型,如果是函数类型则将该函数推入到前面定义的回调函数列表变量enterCallbackArray当中,等待后续导航确认后再进行执行。

从上面next方法的具体实现逻辑能够看到传递了回调函数之后是将其存到了当前路由记录对象的 enterCallbacks 属性上,等待着后续执行,那么具体是在哪里进行执行这个回调呢?接下来就让我们来看看这个回调是在哪里被执行。

next 传递 callback 函数形式参数的回调函数执行:

export const RouterViewImpl = /*#__PURE__*/ defineComponent({
  name: 'RouterView',
  // #674 we manually inherit them
  inheritAttrs: false,
  props: {
    name: {
      type: String as PropType<string>,
      default: 'default',
    },
    route: Object as PropType<RouteLocationNormalizedLoaded>,
  },

  compatConfig: { MODE: 3 },

  setup(props, { attrs, slots }) {
    // ··· ···

    const routeToDisplay = computed<RouteLocationNormalizedLoaded>(
      () => props.route || injectedRoute.value
    )
    const matchedRouteRef = computed<RouteLocationMatched | undefined>(
      () => routeToDisplay.value.matched[depth.value]
    )
    const viewRef = ref<ComponentPublicInstance>()

    watch(
      () => [viewRef.value, matchedRouteRef.value, props.name] as const,
      ([instance, to, name], [oldInstance, from, oldName]) => {
        // ··· ···

        // trigger beforeRouteEnter next callbacks
        if (instance && to && (!from || !isSameRouteRecord(to, from) || !oldInstance)) {
          ;(to.enterCallbacks[name] || []).forEach(callback =>
            callback(instance)
          )
        }
      },
      { flush: 'post' } // 会在触发完微任务时候才对 watch 回调进行触发
    )

    // ··· ···
  },
})

RouterView组件的源码当中能够看到next传递的回调函数是会在路由进行变化更新组件渲染时候进行触发执行的(废话, VueRouter 官方文档都是这么说的。

  • RouterView 使用watchapi 对组件相关路由、组件实例、组件名字等进行监听,并且设置flush参数为post会在触发完微任务时候才对 watch 回调进行触发,因此当路由变化后,RouterView重新渲染完 UI 后再进行触发watch变化的回调;
  • watch监听变化回调执行时候,会从目标跳转路由to当中获取enterCallbacks属性,再对其进行遍历执行;

PS:这里就简单针对这个next回调对 RouterView 这个 VueRouter 的内置组件进行部分源码的分析,系列接下来的一篇文章再对RouterView这个内置组件进行详细的源码的分析。





参考资料

相关的系列文章

相关的参考资料