开始

首先是碎碎念,换了工作之后,终于有些闲暇时间,突然才发现自己竟然有一年多没有写博客,回头想想这段时间似乎都没有多少新的技术积累,感觉好惭愧,无法吐槽自己了。
好吧,还是立马进入主题,今天的主角就是Vue-Router,作为Vue全家桶的一员,肯定是再熟悉不过了,但是自己却没有去阅读过源码,有些地方还不是很了解,终于在最近的项目还是遇到坑了(不遇到坑可能猴年马月都不会去看一下源码吧,哈哈),所以还是花了一些时间去学习了一下源码吧。

路由

路由在一个app开发中是不可缺少的,理论上在一个app开发中,首先就是要定义各个路由:哪些页面需要鉴权才能打开,什么时候需要重定向指定的页面,页面切换的时候怎么传递数据,等等。可以想象当应用越是庞大,路由的重要性就会发凸显,路由可以说是整个应用的骨架。但是web的路由功能相对原生应用开发是很弱的,控制不好,就会出现一些莫名其妙的跳转和交互。

构建Router

直接进入VueRouter构建流程:

  1. 调用createMatcher方法,创建route map
  2. 默认是hash模式路由
  3. 如果选择了history模式,但是刚好浏览器又不支持history,fallback选项就会设置为true,fallback选项会影响hash模式下url的处理,后面会分析到。
  4. 根据当前模式选择对应的history(hash,history,abstract)

先看第一步createMatcher方法:

export function createMatcher (
  routes: Array<RouteConfig>,
  router: VueRouter
): Matcher {
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
    
    ...
  
  return {
    match,
    addRoutes
  }
}

主要通过createRouteMap方法创建出pathList,pathMap和nameMap,pathMap和nameMap里面都是RouteRcord对象;Vue-Router会为每个路由配置项生成一个RouteRecord对象,然后就返回match和addRoutes方法;
这里match方法,就是通过当前的路由路径去返回匹配的RouteRecord对象,而addRoutes则能够动态添加新的路由配置信息,正与官方文档描述一样。

再看createRouteMap方法:

function createRouteMap (
  routes: Array<RouteConfig>,
  oldPathList?: Array<string>,
  oldPathMap?: Dictionary<RouteRecord>,
  oldNameMap?: Dictionary<RouteRecord>
): {
  ...
  routes.forEach(route => {
    addRouteRecord(pathList, pathMap, nameMap, route)
  })

  // ensure wildcard routes are always at the end
  for (let i = 0, l = pathList.length; i < l; i++) {
    if (pathList[i] === '*') {
      pathList.push(pathList.splice(i, 1)[0])
      l--
      i--
    }
  }
  ...
}

遍历所有的路由配置信息,创建RouteRecord对象并添加到pathMap和nameMap中;最后调整pathList中通配符的位置,所以最后无法准确找匹配的路由,都会返回最后通配符的RouteRecord。

当我们的路由又有子路由的时候,addRouteRecord就会递归调用,把子路由也会加入到相同的pathMap和nameMap中,正如代码以下所示:

function addRouteRecord (
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>,
  route: RouteConfig,
  parent?: RouteRecord,
  matchAs?: string
) {
  ...
  if (route.children) {
    ...
    route.children.forEach(child => {
      const childMatchAs = matchAs
        ? cleanPath(`${matchAs}/${child.path}`)
        : undefined
      addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
    })
  }
  ...
}

那么RouteRecord究竟是个什么样的对象尼:

const record: RouteRecord = {
    path,
    regex,
    components,
    instances, //路由创建的组件实例
    name,
    parent, //父级RouteRecord
    matchAs, //alias
    redirect,
    beforeEnter,
    meta,
    props, //后面会分析
  }

这样一看RouteRcord其实有很多属性跟我们初始配置的路由属性是一致的,最重要的当然就是regex属性了,后面判断路由是否匹配全靠它了,它主要用path-to-regexp这个功能十分强大的库来构建正则表达式的;

而RouteRecord另外一个关键属性就是parent会指向父级的RouteRecord对象,在嵌套的router-view场景下,当我们找到匹配的的RouteRecord就可以顺带把父级的RouteRecord找出来直接匹配到同样depth的router-view上;

instances属性保存了路由创建的组件实例,因为当路由切换的是,需要调起这些实例beforeRouteLeave等钩子;那么这些组件实例是什么时候添加到instances上的尼?主要有两个途径:

  1. 组件的beforeCreate钩子

    Vue.mixin({
        beforeCreate () {
          ...
          registerInstance(this, this)
        },
        destroyed () {
          registerInstance(this)
        }
     })
  2. vnode的钩子函数

    ;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
      matched.instances[name] = vnode.componentInstance
    }
    
    data.hook.init = (vnode) => {
      if (vnode.data.keepAlive &&
        vnode.componentInstance &&
        vnode.componentInstance !== matched.instances[name]
      ) {
        matched.instances[name] = vnode.componentInstance
      }
    }

单纯依赖第一种组件的beforeCreate钩子,在某些场景下是无法正确的把组件的实例放到RouteRecord的instances属性中,那么再讨论一下哪些场景会需要第二种方法。
需要prepatch钩子的场景,当有两个路由:

new VueRouter({
  mode: 'hash',
  routes: [
    { path: '/a', component: Foo },
    { path: '/b', component: Foo }
  ]
})

‘/a', '/b'它们的组件都是Foo,如果初始路由是'/a', 那么虚拟dom树上就已经有一个Foo实例生成,当路由切换到'/b'的时候,因为virtual dom的算法,Foo实例会被重用,并不会重新创建新的实例,也就是Foo的beforeCreate钩子是不会调起,这样的话,Foo的实例也就没办法通过beforeCreate钩子添加到'/b'的RouteRecord上。但是vnode的prepatch钩子这时可以调起,所以可以在这里把Foo的实例放到‘/b’的RouteRecord上。

需要init钩子的场景:

<keep-alive>
    <router-view><router-view>
</keep-alive>

首先在router-view上套一个keep-alive组件,接着路由的定义如下:

new VueRouter({
  mode: 'hash',
  routes: [
    { path: '/a', component: Foo },
    { path: '/b', component: Other}
    { path: '/c', component: Foo }
  ]
})

当初始路由是‘/a’的时候,Foo的实例会被创建并且被keep-alive组件保存了,当切到‘/b’后,Other的实例跟Foo的实例同样一样情况,然后再切换路由到‘/c’的时候,由于keep-alive的组件作用,会直接重用之前‘/a’保存的Foo实例,在virtual dom对比的时候,重用的Foo实例和Other实例的虚拟dom节点完全是不同类型是无法调起prepatch钩子,但是可以调起init钩子。

以上就是相关的一些场景讨论。其实个人感觉有些情况Vue-Router这种处理并不是很好,因为RouteRcord相对整个Router实例是唯一的,对应的instances也是唯一,如果同一深度(不用层级)情况下,有两个多个router-view:

<div>
    <div>
        <router-view></router-view>
    </div>
    <div>
        <router-view></router-view>
    </div>
</div>

很明显现在它们都会指向同一个RouteRecord,但是它们会创建出不同的组件实例,但是只有当中的一个会成功注册到RouteRecord的instances属性上,当切换路由的时候,另外一个组件实例是应该不会接收到相关的路由钩子调用的,虽然这种使用场景可能几乎没有。

另外一个场景可能只是我们使用的时候需要注意一下,因为我们可以改变router-view上的name属性也可以切换router-view展示,而这种切换并不是路由切换引起的,所以组件的实例上是不会有路由钩子调起的;另外当instances上有多个实例的时候,路由一旦切换,就算没有在router-view展示的实例,都会调起路由的钩子。

matchAs是在路由有alias的时候,会创建AliasRouteRecord,它的matchAs就会指向原本的路由路径。

props这个属性有点特别,在官方文档上也没有多少说明,只有在源码有相关的例子;

  1. 它如果是对象可以在router-view创建组件实例时,把props传给实例;
  2. 但是如果是布尔值为true时,它就会把当前的route的params对象作为props传给组件实例;
  3. 如果是一个function,就会把当前route作为参数传入然后调起,并把返回结果当作props传给实例。

所以我们可以很轻松的把props设置为true,就可以把route的params当成props传给组件实例。

现在分析完RouteRecord对象,接着创建的主流程,先需要创建对应的HashHistory;但是如果我们是选择了history模式只是浏览器不支持回退到hash模式的话,url需要额外处理一下,例如如果history模式下,URL路径是这样的:

    http://www.baidu.com/a/b

在hash模式下就会被替换成

    http://www.baidu.com/#/a/b

到此Vue-Router实例构建完成。

监听路由

在Vue-Router提供的全局mixin里,router的init方法会在beforeCreate钩子里面调起,正式开始监听路由。
router的init方法:

init(app) {
    ...
    if (history instanceof HTML5History) {
        history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }

    history.listen(route => {
      this.apps.forEach((app) => {
        app._route = route
      })
    })
}

这里history模式会直接切换路由(history模式在构建HTML5History时就已经挂载了popstate监听器),而hash模式会先设置popstate或者hashchange监听器,才切换到当前的路由(因为hash模式可能是history模式降级而来的,要调整url,所以延迟到这里设置监听器)。
到这里可以发现,可能跟我们想象的不一样,其实hash模式也是优先使用html5的pushstate/popstate的,并不是直接使用hash/hashchange驱动路由切换的(因为pushstate/popstate可以方便记录页面滚动位置信息)

直接到最核心部分transitionTo方法,看Vue-Router如何驱动路由切换:

 transitionTo (
    location: RawLocation,
    onComplete?: Function,
    onAbort?: Function
  ) {
    const route = this.router.match(location, this.current) //1. 找到匹配的路由
    this.confirmTransition( //2. 执行切换路由的钩子函数
      route,
      () => {
        this.updateRoute(route) //3. 更新当前路由
        onComplete && onComplete(route)
        this.ensureURL() //4. 确保当前URL跟当前路由一致

        // fire ready cbs once
        if (!this.ready) {
          this.ready = true
          this.readyCbs.forEach(cb => {
            cb(route)
          })
        }
      },
      err => {
        if (onAbort) {
          onAbort(err)
        }
        if (err && !this.ready) {
          this.ready = true
          this.readyErrorCbs.forEach(cb => {
            cb(err)
          })
        }
      }
    )
  }   

粗略来看总共4步:

  1. 先找到匹配的路由
  2. 执行切换路由的钩子函数(beforeRouteLevave, beforeRouteUpdate等等)
  3. 更新当前路由
  4. 确保当前URL跟当前路由一致

接着分析
第1步到match方法:

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

    if (name) {
      const record = nameMap[name]
      ...
      location.path = fillParams(record.path, location.params, `named route "${name}"`)
      return _createRoute(record, location, redirectedFrom)
    } else 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)
  }

match方法有两个分支,如果跳转路由时提供name,会从nameMap直接查找对应的RouteRecord,否则就遍历pathList找出所有的RouteRecord,逐个尝试匹配当前的路由。
当找到匹配的RouteRecord,接着进入_createRoute方法,创建路由对象:

const route: Route = {
    name: location.name || (record && record.name),
    meta: (record && record.meta) || {},
    path: location.path || '/',
    hash: location.hash || '',
    query,
    params: location.params || {},
    fullPath: getFullPath(location, stringifyQuery),
    matched: record ? formatMatch(record) : []
  }

关键看matched属性,formatMatch会从匹配的RouteRecord一直从父级往上查找,返回一个匹配的RouteRecord数组,这个数组在嵌套router-view场景,会根据嵌套的深度选择对应的RouteRecord。

接着第2步,确认路由切换,调起各个钩子函数,在这一步里面你可以中止路由切换,或者改变切换的路由等
confirmTransition方法如下:

confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
    const current = this.current
    const abort = err => {
     ...
    }
    if (
      isSameRoute(route, current) &&
      // in the case the route map has been dynamically appended to
      route.matched.length === current.matched.length
    ) {
      this.ensureURL()
      return abort(new NavigationDuplicated(route))
    }
    
    //匹配的路由跟当前路由对比,找出哪些RouteRecrod是需要deactivated,哪些是需要activated
    const { updated, deactivated, activated } = resolveQueue(
      this.current.matched,
      route.matched
    )
    //跟官方文档描述一致
    const queue: Array<?NavigationGuard> = [].concat(
      // in-component leave guards
      extractLeaveGuards(deactivated),
      // global before hooks
      this.router.beforeHooks,
      // in-component update hooks
      extractUpdateHooks(updated),
      // in-config enter guards
      activated.map(m => m.beforeEnter),
      // async components
      resolveAsyncComponents(activated)
    )

    this.pending = route
    const iterator = (hook: NavigationGuard, next) => {
      if (this.pending !== route) {
        return abort()
      }
      try {
        hook(route, current, (to: any) => {
          if (to === false || isError(to)) { //如果为false或者出错就中止路由
            // next(false) -> abort navigation, ensure current URL
            this.ensureURL(true)
            abort(to)
          } else if (
            typeof to === 'string' ||
            (typeof to === 'object' &&
              (typeof to.path === 'string' || typeof to.name === 'string'))
          ) {
            // next('/') or next({ path: '/' }) -> redirect
            abort()
            if (typeof to === 'object' && to.replace) {
              this.replace(to)
            } else {
              this.push(to)
            }
          } else {
            // confirm transition and pass on the value
            next(to)
          }
        })
      } catch (e) {
        abort(e)
      }
    }

    runQueue(queue, iterator, () => {
      const postEnterCbs = [] //专门为了处理 next((vm)=> {})情况
      const isValid = () => this.current === route
      // wait until async components are resolved before
      // extracting in-component enter guards
      const enterGuards = extractEnterGuards(activated, postEnterCbs, isValid)
      const queue = enterGuards.concat(this.router.resolveHooks)
      runQueue(queue, iterator, () => {
        if (this.pending !== route) {
          return abort()
        }
        this.pending = null
        onComplete(route)
        if (this.router.app) {
          this.router.app.$nextTick(() => {
            postEnterCbs.forEach(cb => {
              cb()
            })
          })
        }
      })
    })
  }

这里一开始先判断匹配的路由跟当前路由是否一致,如果一致就直接中断了。
然后就是匹配的路由跟当前的路由对比,找出需要updated, deactivated, activated的RouteRecord对象,紧接着就是从RouteRecord的instances(之前收集的)里面抽出组件实例的跟路由相关的钩子函数(beforeRouteLeave等)组成一个钩子函数队列,这里队列的顺序跟官网对路由导航的解析流程完全一致的。
可以看到最后会执行两次runQueue,第一次钩子函数队列会先执行leave,update相关的钩子函数,最后是加载activated的异步组件,当所有异步组件加载成功后,继续抽取beforeRouteEnter的钩子函数,对于enter相关的钩子函数处理是有点不一样的,正如文档说的,beforeRouteEnter方法里面是没办法使用组件实例的,因为第二次runQueue时,明显组件的都还没有被构建出来。所以文档也提供另外一种方法获取组件的实例:

beforeRouterEnter(from, to, next) {
    next((vm) => {
    });
}

那么Vue-Router是怎么把vm传给方法的尼,主要是在抽取enter相关钩子的时候处理了:

function bindEnterGuard (
  guard: NavigationGuard,
  match: RouteRecord,
  key: string,
  cbs: Array<Function>,
  isValid: () => boolean
): NavigationGuard {
  return function routeEnterGuard (to, from, next) {
    return guard(to, from, cb => {
      if (typeof cb === 'function') {
        cbs.push(() => {
          // #750
          // if a router-view is wrapped with an out-in transition,
          // the instance may not have been registered at this time.
          // we will need to poll for registration until current route
          // is no longer valid.
          poll(cb, match.instances, key, isValid)
        })
      }
      next(cb)
    })
  }
}
function poll (
  cb: any, // somehow flow cannot infer this is a function
  instances: Object,
  key: string,
  isValid: () => boolean
) {
  if (
    instances[key] &&
    !instances[key]._isBeingDestroyed // do not reuse being destroyed instance
  ) {
    cb(instances[key])
  } else if (isValid()) {
    setTimeout(() => {
      poll(cb, instances, key, isValid)
    }, 16)
  }
}

当判断next传入的是一个function时,它就把这个function放到postEnterCbs数组上,然后在$nextTick等组件挂载上去的时候调用这个function,就能顺利获取到创建的组件实例了;但是还有一种情况需要处理的,就是存在out-in transtion的时候,组件会延时挂载,所以Vue-Router在poll方法上直接用了一个16毫秒的setTimeout去轮询获取组件的实例(真是简单粗暴)。

最后一步,当所有钩子函数毫无意外都回调完毕,就是更新当前路由,确保当前的url跟更新的路由一致了。
在一般情况下,我们代码触发的路由切换,当我们使用next(false)中断,我们完全可以直接中止对URL操作,避免不一致的情况发生。
另外一种情况我们使用浏览器后退的时候,URL会立即改变,然后我们使用next(false)去中断路由切换,这个时候URL就会跟当前路由不一致了,这个时候ensureURL是怎么保证路由一致的尼,其实也很简单:

  ensureURL (push?: boolean) {
    const current = this.current.fullPath
    if (getHash() !== current) {
      push ? pushHash(current) : replaceHash(current)
    }
  }

先判断路由路径是否一致,不一致的话,按照刚刚说的场景就会把当前路由重新push进去(解开多年的疑惑,哈哈)。

其他情况的一些讨论:
如果我们在next的时候改变跳转的路径会怎样尼,这个时候可以在上面的iterator方法看到会abort当前的钩子函数的执行队列,转而push或者replace新的路径,重新执行一遍上面的切换路由流程,那么就可能会出现leave,update等钩子函数可能会重复执行两次了,这是写代码时要注意的(一开始以为这些钩子函数只会调起一次)。

总结

虽然最后发现我遇到的坑,跟Vue-Router好像没多大关系,但是阅读源码也是收获良多,了解到文档上很多没有介绍到的细节;噢,对了,Vue3.0源码也开放了,又得加班加点啃源码了,今天先到这里,如有错漏,还望指正。


tain335
576 声望196 粉丝

Keep it simple.


« 上一篇
Immer.js简析