vue-router 源码:导航守卫

269 阅读7分钟

前言

在看 HashHistory 和 HTML5History 的实现时,涉及到父类 History 与其 transitionTo 方法。

在路由发生跳转的时候,需要调用 transitionTo 方法,其中里面便实现了导航守卫

History

HashHistory 和 HTML5History 都是继承于 History。在调用它们构造函数的时候通过 super 也调用了 History 的构造函数,同时也用到了父类的一些方法,比如 listen 和 transitionTo。

主要来看构造函数 constructortransitionTo 方法的实现。

constructor

History 的构造函数里定义了四个属性。

  1. router
  2. base
  3. current
  4. pending
constructor (router: VueRouter, base: ?string) {
  this.router = router
  this.base = normalizeBase(base)
  // start with a route object that stands for "nowhere"
  this.current = START
  this.pending = null
}

router 保存了 Router 实例,方便接下来使用它的属性和方法。

base 是保存则应用的基路径。其中通过 normalizeBase 函数来判断处理,默认为 '/'。

current 和 pending 都是用来保存一个 Route 对象。

Route 对象在 flow 里是这样定义的:

declare type Route = {
  path: string;
  name: ?string;
  hash: string;
  query: Dictionary<string>;
  params: Dictionary<string>;
  fullPath: string;
  matched: Array<RouteRecord>;
  redirectedFrom?: string;
  meta?: any;
}

一个 Route 对象将包含这样一些属性,一个页面可以用一个 Route 对象来表示。

pending 保存着当前正在进行导航守卫的 Route 对象,current 则保存着完成导航守卫的当前 Route 对象。

current 初始化是一个 START。START 的定义如下:

// the starting route that represents the initial state
export const START = createRoute(null, {
  path: '/'
})

通过 createRoute 函数创建的 START,是一个除了 path 为 '/' 其它属性都为空的 Route 对象。

START = {
    path: '/',
    hash: '',
    query: {},
    params: {},
    ...
}

transitionTo

来看下之前的代码里是怎么地使用 transitionTo:

this.transitionTo(getHash(), route => {
  replaceHash(route.fullPath)
})

可以看到它接收两个参数,一个是路径,另一个是执行完导航守卫的回调函数。

接下来开始深入了解这个函数。

transitionTo (location: RawLocation, cb?: Function) {
  const route = this.router.match(location, this.current)
  this.confirmTransition(route, () => {
    this.updateRoute(route)
    cb && cb(route)
    this.ensureURL()
  })
}

match 方法先暂时放一放(因为这又是一大块),这里我们只要知道通过它能得到即将调整的路由对象即可。

主要关注的是这两个方法,confirmTransitionupdateRoute

updateRoute

我们先从简单的 updateRoute 开始吧,免得到后面忘了它。首先可以猜出它是在 confirmTransition 的回调函数里面执行的,说明是在它之后才会执行。

updateRoute (route: Route) {
  const prev = this.current
  this.current = route
  this.cb && this.cb(route)
  this.router.afterHooks.forEach(hook => {
    hook && hook(route, prev)
  })
}

updateRoute 主要做了三件事情:

  1. 更新保存 current 属性。
  2. 执行 cb 回调,即之前我们用 listen 监听的 cb,从而进一步更新 router-view 组件。
  3. 最后执行 afterHooks 数组里保存的全局 afterEach 钩子。

剩下的钩子触发都是发生在 confirmTransition 里面。

confirmTransition

confirmTransition 里的代码有点多,且有点复杂。

先让我大致说一下它做了哪些实现,再来结合看源代码吧。

  1. 首先会判断传入的路由对象跟当前是否是同一个,是的话,则不需要跳转页面。
  2. 通过 resolveQueue 函数找到即将要被销毁的组件,和即将要被激活的组件。
  3. 将即将销毁的组件和即将激活的组件的导航守卫收集到一个数组 queue 中。
  4. 接着定义一个迭代函数 iterator,用来在迭代 queue 里每一个导航守卫时做些事情。
  5. 调用 runQueue 开始迭代调用导航守卫,最后执行回调 cb

接下来不会逐句代码解释(添加少量注释),我们的目的是了解上面的大概实现。

实现 1

实现 1 里先是判断要跳转的页面:

const current = this.current
if (isSameRoute(route, current)) {
  this.ensureURL()
  return
}

isSameRoute 方法判断了两个 Route 对象的 name、hash、query 和 params 属性是否一致,是的话,则表示是同个路由,直接 return,否则就继续往下走。

实现 2

实现 2 里做的是找到即将销毁和即将激活的组件:

const {
  deactivated,
  activated
} = resolveQueue(this.current.matched, route.matched)

Route 对象的 matched 属性包括着每一层路由的 RouteRecord 对象(跟 Route 属性差不多,主要多了 beforeEnter 方法)。

resolveQueue 方法通过 matched 属性来来逐个遍历,找到哪一层级开始的路由是不一样的,接下来就从这一层级来更新路由组件。

实现 3

实现 3 里收集了组件的导航守卫:

const queue: Array<?NavigationGuard> = [].concat(
  // 即将销毁的组件的 beforeRouteLeave 钩子
  extractLeaveGuards(deactivated),
  // 全局的 beforeEach 钩子
  this.router.beforeHooks,
  // enter guards
  // 即将激活的组件的 beforeEnter 钩子
  activated.map(m => m.beforeEnter),
  // 解析异步路由组件
  resolveAsyncComponents(activated)
)

这里需要细读一下 flatMapComponents 函数:

function flatMapComponents (
  matched: Array<RouteRecord>,
  fn: Function
): Array<?Function> {
  return Array.prototype.concat.apply(
    [], 
    matched.map(m => {
      // 通过遍历 matched 对应的组件
      return Object.keys(m.components).map(key => fn(
        m.components[key],
        m.instances[key],
        m, key
    ))
  }))
}

flatMapComponents 函数实现的功能是,遍历传入的 matched 里的 components 里的每一个组件(即我们在 new VueRouter 传入的 components),每一个组件都调用一遍 fn 回调函数。

extractLeaveGuards 就通过了 flatMapComponents 函数来收集即将销毁的组件的 beforeRouteLeave 钩子。而 resolveAsyncComponents 也是通过它来收集异步组件。

所以实现 3 里将所有即将要执行的钩子(导航守卫)都收集到了 queue 数组里,用来接下来的遍历钩子。

实现 4

实现 4 定义一个迭代函数 iterator,到时每一次触发钩子的时候,会先执行一遍 iterator 函数,并将钩子函数传入。

iterator 函数会执行钩子函数,并实现了 next 的使用。next 的使用具体参考 vue-router 全局守卫

实现 5

前面的实现都是为了这里的调用。

runQueue(queue, iterator, () => {
  // ...
})

这里用到了 runQueue 函数,接收 3 个参数,runQueue 会迭代 queue 数组,每一次都先执行一遍 iterator,iterator 里才是真正地触发钩子。当全部钩子触发完毕,则调用第 3 个参数,即一个回调函数。

queue 里所有钩子触发完后,还需要触发激活组件的 beforeRouteEnter 钩子。

所以需要收集激活组件的 beforeRouteEnter 钩子,再执行一遍 runQueue 函数。以下代码在执行完第一个 runQueue 函数后的回调函数里执行:

// 激活的组件 beforeRouteEnter 钩子
const enterGuards = extractEnterGuards(activated, postEnterCbs, () => {
  return this.current === route
})

// wait until async components are resolved before
// extracting in-component enter guards
runQueue(enterGuards, iterator, () => {
  // ...
})

extractEnterGuards 专门收集了激活组件的 beforeRouteEnter 钩子。

当所有 beforeRouteEnter 钩子执行完毕后,就会调用 confirmTransition 的 cb 了,并将当前 Route 对象作为参数传入。

if (this.pending === route) {
  this.pending = null
  cb(route)

  // 解决 <transition> 问题
  // 等 DOM 更新后再调用 beforeRouteEnter 守卫中传给 next 的回调函数
  this.router.app.$nextTick(() => {
    postEnterCbs.forEach(cb => cb())
  })
}

时光倒流,再回到刚开始调用 confirmTransition 的时候,来瞧瞧当时传了神马作为回调函数。

this.confirmTransition(route, () => {
  this.updateRoute(route)
  cb && cb(route)
  this.ensureURL()
})

这代码应该很熟悉了,updateRoute 方法前面已经了解过了,作用是更新 router-view 组件。

至此,一套导航守卫的代码就粗略地解读结束,完整的导航解析流程可以参考 官方文档

心得

导航守卫的源码解读可以说是看 vue-router 源码中最困难的一部分,代码量不少,函数跳来跳去,刚开始确实看得我晕头转向的。

后来多看了几遍,抓住几个重点的函数,其它的暂时放一边,慢慢地也总算是掌握了思路。所以看源码不要指望自己第一遍就能够全部看明白。

以上的解读中会有许多函数没有讲解出来,一方面我觉得没必要太多细致地纠结每一行代码,另一方面函数名基本已经说明其功能。若对它们的实现感兴趣的话,可以打开 vue-router 的源码自己看下。

下期预告

还记得曾经在 Router 调用 constructor 构造函数的时候,我们暂时放一边的 createMatcher 函数吗?

constructor (options: RouterOptions = {}) {
  this.match = createMatcher(options.routes || [])
  
  // ...
}

在深入了解 transitionTo 的时候,我们就用到了这个 match 方法。

transitionTo (location: RawLocation, cb?: Function) {
  const route = this.router.match(location, this.current)
  
  // ...
}

到底 createMatcher 做了什么操作?使用了 match 方法又有什么特效?下期我们就来深入了解 match 吧。