vue-router

vue分析的差不多了,那么我们来看看最重要的两个周边产品,先看vue-router。这块的例子直接使用vue-router官网的例子就行了, 如果用vue-cli新建的项目,记得在vue.config.js中配置runtimeCompilertrue

初始化

vue-router的例子中我们可以看到,首先通过Vue.use注册router,然后new一个实例,并传入路由配置数组。那么我们就可以从这两个方面入手

首先是Vue.use,该方法全局方法,可以在global-api/use.js查看,就是调用了install方法,传入了当前Vue。 所以vue-router其实就是vue插件的调用方法,那么我们直接看routerinstall方法。

install方法比较长,但是总结起来也就五点

  1. 确保只install一次,即多次install只会缓存第一次的Vue
  2. 声明registerInstance方法
  3. 使用Vue.mixin混入beforeCreatedestroyed,这里使用了mergeOptions,并且混入的是Vue所以在每个组件上都会执行
  4. 定义Vue.prototype.$routerVue.prototype.$route的拦截器,指向this._routerRoot.xx
  5. 注册RouterViewRouterLink组件

install的核心就这些,接下来要看new VueRouter的过程。

它也可以总结为几个点

  1. 定义实例变量
  2. 执行createMatcher该方法是创建路由映射和获取路由方法的核心
  3. 根据mode实例化history,这里注意在使用history模式的时候,如果浏览器不支持router会自动降级为hash

createMatcher

这个方法传入两个参数一个是routes一个是当前实例,然后我们看下源码

export function createMatcher(routes, router) {
  // 创建一个路由映射表
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
  ...
  return {
    match,
    addRoute,
    getRoutes,
    addRoutes
  }
}
1
2
3
4
5
6
7
8
9
10
11

就做了两件事

  1. 通过createRouteMap创建路由映射表
  2. 返回route相关方法和match方法,match方法很重要,等调用的时候再提

根据流程,我们进入createRouteMap方法看看,这个方法核心就一段代码

routes.forEach(route => {
 addRouteRecord(pathList, pathMap, nameMap, route, parentRoute)
})
1
2
3

我们进入addRouteRecord看看

function addRouteRecord (
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>,
  route: RouteConfig,
  parent?: RouteRecord,
  matchAs?: string
) {
  const { path, name } = route

  const pathToRegexpOptions: PathToRegexpOptions =
    route.pathToRegexpOptions || {}
  const normalizedPath = normalizePath(path, parent, pathToRegexpOptions.strict)

  // record 对象 是路由核心的描述
  const record: RouteRecord = {...}

  if (route.children) {
    route.children.forEach(child => {
      const childMatchAs = matchAs
        ? cleanPath(`${matchAs}/${child.path}`)
        : undefined
      addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
    })
  }

  if (!pathMap[record.path]) {
    pathList.push(record.path)
    pathMap[record.path] = record
  }

  if (name) {
    if (!nameMap[name]) {
      nameMap[name] = record
    }
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

该方法主要就是循环执行一个route对象,然后序列化成vue-router需要的record对象,然后将其存入pathMap、pathList中,如果route中定义了 namenameMap中也保存一份,这里主要注意两个地方

  1. 命名视图,这个功能可以看文档介绍,即路由对象中可以写components: {},如果定义component在源码中也是components: { default: route.component }
  2. normalizePath方法在嵌套路由时候返回的差别,如果是子级路由,返回的就是/foo/bar

这样初始化就完成了。

如何开始执行的?

别忘记我们在new Vue的时候需要传入router实例,而且在之前通过Vue.mixin混入了beforeCreatedestroyed。 那么很显然在运行到beforeCreate的时候,就会执行。我们进入这个方法看看

beforeCreate: function beforeCreate () {
   if (isDef(this.$options.router)) {
     this._routerRoot = this
     this._router = this.$options.router
     this._router.init(this)
     Vue.util.defineReactive(this, '_route', this._router.history.current)
   } else {
     this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
   }
   registerInstance(this, this)
},
1
2
3
4
5
6
7
8
9
10
11

因为在new Vue中传入了router实例,所以我们会走if分支,else是做什么的?当我们执行到组件的 beforeCreate的时候,当前实例就不存在router了。赋值好变量之后,就是执行init方法和_route响应式

这里先不说init,我们先看defineReactive,为什么要对_route做响应式,可以看到this._route会返回this._router.history.current也就是当前路由。 而之前我们看到两个拦截器,this.$route返回的是this._routerRoot._route,而根据之前的定义this._routerRoot = this,那么很显然了 this.$route其实就是返回的current当前路由,知道了这个我们之后再回来看。

init方法中,会第一次调用history.transitionTo。那么在解析transitionTo之前,我们需要先去history的定义。

本例子使用的hash模式,那么我们就分析HashHistory,在history文件夹中几种模式都是用的class定义,并且都extendHistory类,那么直接进去看 History中的构造函数,可以发现其实就是定义类一些属性和方法,因为太简单了,这里就不赘述了。

我们看init方法

init (app) {
 this.apps.push(app)

 if (this.app) {
   return
 }

 this.app = app

 const history = this.history

 if (history instanceof HTML5History || history instanceof HashHistory) {
   history.transitionTo(
     history.getCurrentLocation(),
     setupListeners,
     setupListeners
   )
 }

 history.listen(route => {
   this.apps.forEach(app => {
     app._route = route
   })
 })
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

这里稍微讲解一下,通过判断使后面的内容只会在new Vue的时候执行一次。而我们主要要了解的就是切换路由的transitionTo方法。

transitionTo

transitionTo定义在history/base.js中,该方法其实就关注两点

  1. 通过this.router.match获取计算后的路由
  2. 通过this.confirmTransition切换路由

先看match,在router类中可以看到,match其实调用的就是matcher.match

function match (
 raw: RawLocation,
 currentRoute?: Route,
 redirectedFrom?: Location
): Route {
 const location = normalizeLocation(raw, currentRoute, false, router)

 if (location.path) {
   location.params = {}
   for (let i = 0; i < pathList.length; i++) {
     const path = pathList[i]
     const record = pathMap[path]
     if (matchRoute(record.regex, location.path, location.params)) {
       return _createRoute(record, location, redirectedFrom)
     }
   }
 }
 // no match
 return _createRoute(null, location)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

因为例子中没有定义name,所以相关代码先删除。先看normalizeLocation方法,该方法是对当前router做了一次计算,在这里我们可以不去看源码实现, 看看他的单元测试,在location.spec.js中,很明显就是对location进行了序列化,然后通过matchRoute去和之前的pathList匹配,初始化的话就会 直接生成route

初始化的route我们知道就是/。完成第一步,我们看第二步调用confirmTransition

该方法很长,但是也能分成四块,(这里建议自己去打上断点分析,方法和异步组件分析一致)

  1. 通过resolveQueue方法解析出回调
  2. 生成queue
  3. 声明iterator函数
  4. 并且执行runQueue

这里重点提一下:runQueue是一个异步函数的队列化执行函数

export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) {
  const step = index => {
    if (index >= queue.length) {
      cb()
    } else {
      if (queue[index]) {
        fn(queue[index], () => {
          step(index + 1)
        })
      } else {
        step(index + 1)
      }
    }
  }
  step(0)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

我们观察他的入参,queue是之前生成的一位函数队列,是一个定义的导航队列,fn是传入的iterator,cb是定义好的回调函数, 当我们执行完队列会调用cb进行首次渲染的结束,fn的第二个参数是下一个步进器,显然就是传入的next

查看iterator方法的入参(hook: NavigationGuard, next),非常明显了,执行hook,然后在结束后执行next到下一个。 这也就是为啥每次导航守卫需要next原因。

稍微了解了这些,我们就可以结合官网说明的导航解析流程分析

  1. 在失活的组件里调用 beforeRouteLeave 守卫。
  2. 调用全局的 beforeEach 守卫。
  3. 在重用的组件里调用 beforeRouteUpdate 守卫 (2.2+)。
  4. 在路由配置里调用 beforeEnter
  5. 解析异步路由组件。

首先在首次渲染中,是没有失活组件的,那么直接跳过。beforeEach将被触发。触发完成后注意runQueue的第三个参数

onComplete(route)
if (this.router.app) {
 this.router.app.$nextTick(() => {
   handleRouteEntered(route)
 })
}
1
2
3
4
5
6

我们看其中的这一段代码,首先执行了onComplete,在该方法中对route做了一些处理,并且执行了beforeEnter,这两个都是全局路由钩子 这样首次渲染的路由钩子就执行完了。然后执行了$nextTick

两个函数组件

这里主要看路由跳转时,路由守卫的执行过程,其他的建议自己debugger尝试

router-link组件应该就是a组件的包装,当我们点击的时候,其实就是触发点击事件,然后去执行router.push()。看render部分的相关源码

const handler = e => {
   if (guardEvent(e)) {
     if (this.replace) {
       router.replace(location, noop)
     } else {
       router.push(location, noop)
     }
   }
}
const on = { click: guardEvent }
data.on = on
data.attrs = { href, 'aria-current': ariaCurrentValue }
1
2
3
4
5
6
7
8
9
10
11
12

push方法内部,执行的还是transitionTo执行过程和之前分析的一样,重点还是看queue、runQueue、iterator这个三执行的过程。

在这里我们会再次执行全局前置路由beforeEach,然后调用路由配置中定义的beforeEnter,之后就是执行异步组件。执行完queue队列就执行完了,那么就和上面一样,执行 runQueue定义的cb

const enterGuards = extractEnterGuards(activated)
const queue = enterGuards.concat(this.router.resolveHooks)
runQueue(queue, iterator, () => {
  if (this.pending !== route) {
    return abort(createNavigationCancelledError(current, route))
  }
  this.pending = null
  onComplete(route)
  if (this.router.app) {
    this.router.app.$nextTick(() => {
      handleRouteEntered(route)
    })
  }
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在该方法的执行过程中,我们将拿到了路由中定义的组件对象,进行Vue.extend。所以可以看到,路由上的组件其实是全局混入的。并且返回了routerEnter,也就是说我们在之后执行的 runQueue中,必然有beforeRouteEnter钩子,但是当前组件没有被实例化,所以我们拿不到this。在onComplete方法中,将完成url的更新和全局后置路由afterEach的执行。

那为什么next中可以访问到this

首先根据runQueue可以知道,next其实就是执行了下一步,如果queue执行完了,就是执行cb,也就是runQueue的第三个参数,在这里,可以看到$nextTick。重新渲染组件, 在这个过程中render函数中有_c("router-view"),因此我们会运行到router-view组件的创建过程。

router-view

这是一个函数式组件,如果不明白可以查看官网对于functional组件的定义。函数式组件主要看render函数,在render函数中有这么一段代码

const route = parent.$route
1

之前已经说过,this.$route会进行一次依赖收集,收集的就是当前的route。并且会缓存当前组件。新增data.hook.initdata.hook.prepatch。最后就是执行h函数渲染, 这里component就是路由配置中的组件,在组建执行过程中,会执行data.hook.init,而在这个方法中会执行handleRouteEntered。 在这个方法中会执行之前保存的cbs,也就是next的回调。也就是说这里可以访问到组件的this了。

这样路由所有组件的首次渲染就完成了,还有一个点

在被激活的组件里调用 beforeRouteEnter

当我们再次切换到已经初始化过的组件中时,这时候resolveQueue解析出来的就存在updated,也就是说会执行beforeRouteLeave钩子。

总结

  1. vue-router使用Vue.use的方式注册,通过Vue.mixin混入beforeCreate,在其中执行init方法
  2. 如果浏览器不支持history模式会自动降级,并且所有的路由跳转最终都是transitionTo方法的调用
  3. 通过类似generator函数的runQueue执行钩子函数队列queue。在runQueue的最后执行全局afterEachnextTick
Last Updated: