Vue源码阅读笔记之路由管理(六)

我们可以通过VueVue-router来构建单页面应用。在传统的web项目里面,往往存在多个页面映射不同的功能,而在单页面应用中,不同的页面对应的是不同的组件。

在之前的博客同构渲染实践history与单页面应用路由中,我尝试实现了单页面的路由管理,不过十分简陋,现在让我们来看看Vue-router的实现原理。

<!--more-->

参考:

1. 前言

vue-router一个单独的项目,然后作为插件与vue进行绑定的,这赋予了开发者更大的选择自由。

跟分析Vue一样,克隆项目,安装依赖,然后从package.jsonnpm run dev脚本入手,该命令会通过express开启一个静态服务器,返回examples下相关的静态页面。examples目录下包含了相关的API使用方法,这对于源码分析很有帮助,基本上就不用我们自己写Demo了

在传统的后台MVC框架中,通过URL路由映射到对应的控制器方法,然后执行相关逻辑,最后展示视图。路由的声明又可以分为:

  • 显式声明,手动指定URL和对应的控制器方法,如Laravel、Express等
  • 隐式声明,利用语言的自动加载机制,将URL映射到对应目录路径下的文件控制器方法,如ThinkPHP等

那么,单页面应用中的路由该是什么样子的呢?首先,我们肯定需要关联路由和需要展示的视图;其次,我们需要模拟浏览器前进、后退等操作。

回想一下,Vue-router的使用方式十分简单,

  • 声明对应的页面组件,
  • 通过组件构造routes数组,通过routes和其他配置参数实例化router对象
  • 为Vue实例传入对应的router配置参数,在模板中通过router-viewrouter-link实现组件的跳转

下面使用了examples中的一个例子,展示了一个基础的router对象实例化过程

// /examples/basic/app.js
// 注册插件
Vue.use(VueRouter)
// 定义组件
const Home = { template: '<div>home</div>' }
const Foo = { template: '<div>foo</div>' }
const Bar = { template: '<div>bar</div>' }
// 关联url和对应组件,组成构造参数,实例化router对象
const router = new VueRouter({
  mode: 'history',
  base: __dirname,
  routes: [
    { path: '/', component: Home },
    { path: '/foo', component: Foo },
    { path: '/bar', component: Bar }
  ]
})
// 将router作为配置参数,获得vm实例
new Vue({
  router,
  template: `
    <div id="app">
      <h1>Basic</h1>
      <ul>
        <li><router-link to="/">/</router-link></li>
        <li><router-link to="/foo">/foo</router-link></li>
        <li><router-link to="/bar">/bar</router-link></li>
        <router-link tag="li" to="/bar" :event="['mousedown', 'touchstart']">
          <a>/bar</a>
        </router-link>
      </ul>
      <router-view class="view"></router-view>
    </div>
  `
}).$mount('#app')

通过上面这个流程,需要弄明白的一些问题有:

  • VueRouter插件注册及router-viewrouter-link组件
  • 配置参数moderoutes的处理
  • router对象的属性和方法

按照上面demo程序执行的流程,我们一步一步进行分析。

2. 插件安装

插件注册是在install方法中进行的,

// /src/install.js
export function install (Vue) {
   // 全局混合,注册_router属性
   Vue.mixin({
    beforeCreate () {
      if (isDef(this.$options.router)) {
        this._routerRoot = this
        // 设置this._router
        this._router = this.$options.router
        // 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)
    },
    destroyed () {
      registerInstance(this)
    }
  })
  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })

  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })
  // 注册router-view和router-link全局组件
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)
}

可见在install主要就是为vm实例注入了_router对象,然后执行对应router的init方法。我们先来看看router对象的构造函数

3. VueRouter构造函数

在前面的例子中,首先需要初始化路由构造参数,包括url和与之对应的组件,然后传入VueRouter构造函数实例一个router对象。我们来看看构造函数中是如何处理这些参数的

// /src/index.js
export default class VueRouter {
  constructor (options: RouterOptions = {}) {
    // ...初始化相关属性
    this.options = options
    // 关联声明的路由
    this.matcher = createMatcher(options.routes || [], this)

    let mode = options.mode || 'hash'
    // ...根据配置参数运行环境确定mode类型
    this.mode = mode

    // 根据mode实例化对应的history对象
    // 我们上面选择的是history,因此暂时只需要了解HTML5History即可
    switch (mode) {
      case 'history':
        this.history = new HTML5History(this, options.base)
        break
      case 'hash':
        this.history = new HashHistory(this, options.base, this.fallback)
        break
      case 'abstract':
        this.history = new AbstractHistory(this, options.base)
        break
      default:
        if (process.env.NODE_ENV !== 'production') {
          assert(false, `invalid mode: ${mode}`)
        }
    }
  }
}
// 实现插件的注册函数
VueRouter.install = install
// 在浏览器中自动注册,这是Vue插件的基本实现形式
if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter)
}

VueRouter构造函数内只做了两件事:初始化this.matcher和初始化this.history,我们暂时不要去深入这两个属性的作用,只需要知道他们在构造函数中被初始化即可。

4. 路由匹配matcher

所谓路由,最基本的作用就是通过给定的url获得对应的视图内容,这就是this.matcher需要完成的工作。我们来看看matcher匹配器的初始化过程

// /src/create-matcher.js
export function createMatcher (
  routes: Array<RouteConfig>,
  router: VueRouter
): Matcher {
  // pathList是路由路径的数组,["", "/foo", "/bar"]
  // pathMap是一个以路径为键值的对象,对应每个路径相关的配置,这种形式被称为RouteRecord
  // nameMap是一个以路由名称为键值的对象,与pathMap对应路径指向的路由配置相同
  const { pathList, pathMap, nameMap } = createRouteMap(routes)

  // ...手动添加路由的接口,会更新pathList、pathMap和nameMap
  function addRoutes (routes){}
  // 传入url匹配对应的路由对象,路由对象是根据路由记录初始化的
  function match (
    raw: RawLocation,
    currentRoute?: Route,
    redirectedFrom?: Location
  ): Route {}
  // ...一些其他辅助函数
  // mather对象上包含这两个方法
  return {
    match,
    addRoutes
  }
}

可以看见,createMatcher主要的作用就是通过options.routes,将对应的路由解析成字典,然后返回matchaddRoutes两个闭包函数

4.1. RouteRecord

在match函数的返回值中提到了路由对象路由记录的概念,路由对象我们都比较熟悉了,即vm.$route,那么路由记录是什么呢?我们先看看pathMap的形式

// 转换前的route形式
{ 
  path: '/foo', 
  component: Foo 
}

// pathMap的形式,这里只展示了部分路由
// 路径作为属性值,用于后面的路由匹配
{
  "/foo": {
    path: "/foo",
    regex: { keys: [] },
    components: { default: { template: "<div>foo</div>" } },
    instances: {},
    meta: {},
    props: {}
  },
}

可见pathMap即以url为键名,一个保存路由相关信息的对象为键值的对象。将配置参数的route转换成pathMap形式的路由配置是在addRouteRecord中进行的

// /src/create-route-map.js
function addRouteRecord (
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>,
  route: RouteCaddRouteRecordonfig, // 这里就是配置参数中的route选项
  parent?: RouteRecord,
  matchAs?: string
) {
  // ...
  // 路由记录就是一个用来保存每个url的相关信息,包括正则、组件、命名等的对象
  const record: RouteRecord = {
    path: normalizedPath,
    regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
    components: route.components || { default: route.component },
    instances: {},
    name,
    parent,
    matchAs,
    redirect: route.redirect,
    beforeEnter: route.beforeEnter,
    meta: route.meta || {},
    props: route.props == null
      ? {}
      : route.components
        ? route.props
        : { default: route.props }
  }

  // 递归为子路由生成RouteRecord 
  if (route.children) {
    route.children.forEach(child => {}
  }
  // 生成pathMap
  if (!pathMap[record.path]) {
    pathList.push(record.path)
    pathMap[record.path] = record
  }
}

route相关配置参数可以参考官方文档,上面展示了从配置参数到RouteRecord的转换过程。其中,regex值的生成使用了path-to-regexp这个库(对于反向生成正则我一直十分好奇~)。

上面的过程也比较清晰,解析options.routes,然后生成对应URL的RouteRecord并保存在pathMap

const { pathList, pathMap, nameMap } = createRouteMap(routes)

name选项并不是必须的,如果设置了该属性,则对应的路由会变成命名路由,所有的命名路由会解析到nameMap中,通常地,使用命名路由的查找效率要快一些,因为在match中对pathMapnameMap有些差异。

4.2. match

// /src/create-matcher.js
function match(
  raw: RawLocation, // 目标url
  currentRoute?: Route, // 当前url对应的route对象
  redirectedFrom?: Location // 重定向
): Route {
  // 从url中提取hash、path、query和name等信息
  const location = normalizeLocation(raw, currentRoute, false, router);
  const { name } = location;

  if (name) {
    // 处理命名路由
    const record = nameMap[name];
    const paramNames = record.regex.keys
      .filter(key => !key.optional)
      .map(key => key.name);
    if (currentRoute && typeof currentRoute.params === "object") {
      for (const key in currentRoute.params) {
        if (!(key in location.params) && paramNames.indexOf(key) > -1) {
          location.params[key] = currentRoute.params[key];
        }
      }
    }

    if (record) {
      location.path = fillParams(
        record.path,
        location.params,
        `named route "${name}"`
      );
      return _createRoute(record, location, redirectedFrom);
    }
  } else if (location.path) {
    // 处理非命名路由
    location.params = {};
    // 这里会遍历pathList,找到合适的record,因此命名路由的record查找效率更高
    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中,主要通过当前URL确定对应的RouteRecord路由记录,然后调用_createRoute,并返回当前url对应的route对象。

4.3. _createRoute

_createRoute中会根据RouteRecord执行相关的路由操作,最后返回Route对象

function _createRoute (
  record: ?RouteRecord,
  location: Location,
  redirectedFrom?: Location
): Route {
  // 重定向
  if (record && record.redirect) {
    return redirect(record, redirectedFrom || location)
  }
  // 别名
  if (record && record.matchAs) {
    return alias(record, location, record.matchAs)
  }
  // 普通路由
  return createRoute(record, location, redirectedFrom, router)
}

这里是重定向和别名路由的文档传送门

现在我们知道了this.mather.match最终返回的就是Route对象,下面是一个Route对象包含的相关属性

// /src/util/route.js
export function createRoute (
  record: ?RouteRecord,
  location: Location,
  redirectedFrom?: ?Location,
  router?: VueRouter
): Route {
  // ...
  // 终于看到了路由记录对象的真面目,每个url都对应一个路由记录,
  // 方便后续的根据url匹配对应的组件
  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) : []
  }
  // 禁止route对象改变
  return Object.freeze(route)
}

至此,我们理清了从url生成对应route的过程。那么,match方法在何处调用呢?

我现在的猜想是:如果路由发生了改变,比如点击了一个连接,就需要根据目标url,匹配到对应的路由记录,重新生成新的route对象,然后加载对应的组件。

5. 路由初始化

在上面的install函数中,我们了解到VueRouter内部注册了一个全局混合,在beforeCreate时会调用router.init方法

init (app: any /* Vue component instance */) {
    this.apps.push(app)

    if (this.app) {
      return
    }

    this.app = app
    // 在构造函数中初始化history属性
    const history = this.history
    // 在下面这两种模式存在进入的不是默认页的情形,因此需要调用transitionTo
    if (history instanceof HTML5History) {
      history.transitionTo(history.getCurrentLocation())
    } else if (history instanceof HashHistory) {
      // 绑定hashChange事件,在下面的路由变化章节会解释
      const setupHashListener = () => {
        history.setupListeners()
      }
      history.transitionTo(
        history.getCurrentLocation(),
        setupHashListener,
        setupHashListener
      )
    }
      // listen中初始化history.cb = cb
    history.listen(route => {
      this.apps.forEach((app) => {
        // 为vm实例注册_route属性
        app._route = route
      })
    })
  }

可以看见,init函数内部主要调用了history.transitionTohistory.listen这两个方法,在上面的构造函数中我们知道了

 this.history = new HTML5History(this, options.base)

接下来就去了解history对象。

6. history对象

在VueRouter的构造函数中,我们发现,router.history实际上是根据mode实例化不同的History对象,三种history对象都继承自History基类。

我们知道init方法在historyhash模式下,会手动调用一次transitionTo,这是因为这两种模式(浏览器模式)存在进入的不是默认页的情形。

6.1. transitionTo

在transitionTo中匹配目标rul的route对象,然后调用confirmTransition

// src/history/base.js
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
      // 匹配目标url的route对象
    const route = this.router.match(location, this.current)
    // 调用this.confirmTransition,执行路由转换
    this.confirmTransition(route, () => {
      // ...跳转完成
      this.updateRoute(route)
      onComplete && onComplete(route)
      this.ensureURL()
      // fire ready cbs once
      if (!this.ready) {
        this.ready = true
        this.readyCbs.forEach(cb => { cb(route) })
      }
    }, err => {    
      // ...处理异常
    })
  }
}

6.2. confirmTransition

confirmTransition主要作用是处理链接跳转过程中相关逻辑

confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
    const current = this.current
    const abort = err => {
      // ... 实现abort函数
    }

    const queue: Array<?NavigationGuard> = [].concat(
      // ...保存需要处理的逻辑队列,
    )

    this.pending = route
    // 迭代函数
    const iterator = (hook: NavigationGuard, next) => {
      if (this.pending !== route) {
        return abort()
      }
      try {
        hook(route, current, (to: any) => {
              // ...
            next(to)
        })
      } catch (e) {
        abort(e)
      }
    }
    // 依次执行队列
    runQueue(queue, iterator, () => {
      const postEnterCbs = []
      const isValid = () => this.current === route
      // 处理异步组件
      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() })
          })
        }
      })
    })

其中需要注意的是这个runQueue函数,他会依次将队列中的元素传入迭代器中执行,最后执行回调函数。

// /src/util/async.js
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)
}

上面整理了transitionTo的工作原理,其主要任务就是跳转到对应的url上,然后加载对应的视图,并触发相应的钩子函数。

7. 路由变化

除了在init方法中会手动调用transitionTo之外,其他的调用应当是是通过注册url发生变化时的事件处理函数进行的,其中,

  • 点击链接会触发url的变化
  • 浏览器后退也会触发url的变化
  • 手动调用路由接口,也会触发url的变化

现在让我们来看看router-link组件的实现,组件的相关属性可参考官方文档

export default {
  name: 'RouterLink',
  props: {}, // ...相关属性
  render (h: Function) {
    const router = this.$router
    const current = this.$route
    const { location, route, href } = router.resolve(this.to, current, this.append)
    // ...导航状态类的处理

    // 事件处理函数
    const handler = e => {
      // guardEvent函数用来判断当前路由跳转是否是有效的
      if (guardEvent(e)) {
        // 替换路由或新增路由
        if (this.replace) {
          router.replace(location)
        } else {
          router.push(location)
        }
      }
    }

    const on = { click: guardEvent }
    // 相关的事件都会触发handler
    if (Array.isArray(this.event)) {
      this.event.forEach(e => { on[e] = handler })
    } else {
      on[this.event] = handler
    }
    const data: any = {
      class: classes
    }
    // ... 对渲染的标签进行处理
    data.on = on
    // 执行渲染函数
    return h(this.tag, data, this.$slots.default)
  }
}

可见在点击(或者是其他指定的事件)router-link组件时,会调用router.pushrouter.replace方法

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  this.history.push(location, onComplete, onAbort)
}

replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  this.history.replace(location, onComplete, onAbort)
}

在不同的模式下,浏览器对应的事件是不一样的,这也是为什么会实例化不同的history的原因。接下来看看浏览器环境下相关事件与transitionTo是如何关联起来的

7.2. HTML5History

在HTML5History的构造函数中,会监听popstate事件,关于HTML5提供的history API,可以参看MDN文档,其中

  • push是由history.pushState实现的
  • replace是由history.replaceState实现的
  • 浏览器后退由监听popstate事件实现的
export class HTML5History extends History {
  constructor (router: Router, base: ?string) {
    // 父类构造函数
    super(router, base)

    // 滚动处理
    const expectScroll = router.options.scrollBehavior
    const supportsScroll = supportsPushState && expectScroll

    if (supportsScroll) {
      setupScroll()
    }

    const initLocation = getLocation(this.base)
    // 监听浏览器的后退事件变化,执行transitionTo
    window.addEventListener('popstate', e => {
      const current = this.current
      const location = getLocation(this.base)
      if (this.current === START && location === initLocation) {
        return
      }

      this.transitionTo(location, route => {
        if (supportsScroll) {
          handleScroll(router, route, current, true)
        }
      })
    })
  }
  // 实现
  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      pushState(cleanPath(this.base + route.fullPath))
      // ...
    }, onAbort)
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      replaceState(cleanPath(this.base + route.fullPath))
      // ...
    }, onAbort)
  }
}

7.3. HashHistory

hashChange事件可查看MDN文档

export class HashHistory extends History {
  constructor (router: Router, base: ?string, fallback: boolean) {
    super(router, base)
    // check history fallback deeplinking
    if (fallback && checkFallback(this.base)) {
      return
    }
    ensureSlash()
  }
  // setupListeners在router.init中才会被调用,防止过早触发
  setupListeners () {
    const router = this.router
    // ... 处理滚动
    // 监听url变化,如果不支持popstate则会监听hashchange事件
    window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', () => {
      const current = this.current
      if (!ensureSlash()) {
        return
      }
      this.transitionTo(getHash(), route => {
        if (supportsScroll) {
          handleScroll(this.router, route, current, true)
        }
        if (!supportsPushState) {
          replaceHash(route.fullPath)
        }
      })
    })
  }
  push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      pushHash(route.fullPath)
      // ...
    }, onAbort)
  }

  replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const { current: fromRoute } = this
    this.transitionTo(location, route => {
      replaceHash(route.fullPath)
      // ...
    }, onAbort)
  }
}

OK,现在我们知道了路由变化相关处理函数的注册过程。大体来说,就是根据选择的模式,底层调用浏览器不同的接口来实现的。

8. 视图变化

当路由改变时,对应的router-view会加载相关的视图,我们来看看这部分是如何处理的

8.1. router-view

router-view相关的属性和使用方法可以参考官方文档

export default {
  name: 'RouterView',
  functional: true,
  props: {
    name: {
      type: String,
      default: 'default'
    }
  },
  render (_, { props, children, parent, data }) {
    data.routerView = true
    const h = parent.$createElement
    const name = props.name
    // 目标url的route对象
    const route = parent.$route
    const cache = parent._routerViewCache || (parent._routerViewCache = {})

    // ...处理data.routerViewDepth
    // render previous view if the tree is inactive and kept-alive
    if (inactive) {
      return h(cache[name], data, children)
    }
    const matched = route.matched[depth]
    // 找到name对应的组件
    const component = cache[name] = matched.components[name]
    // ...关联registration钩子
    // 注册data.hook.prepatch钩子函数
    ;(data.hook || (data.hook = {})).prepatch = (_, vnode) => {
      matched.instances[name] = vnode.componentInstance
    }
    // ...处理props
    // 渲染组件
    return h(component, data, children)
  }
}

可以看见,当route发生变化时,其内部会渲染目标url所匹配的路由记录保存的组件,这就是上面在router的配置函数传入,然后通过addRouteRecord生成的。

8.2. link与view的关联

上面提到了router-view的更新是根据route进行的,那么vm.$route的变化与路由的变化时如何关联起来的呢?回到前面,在transitionTo中向 confirmTransition传入了onComplete函数

// /src/history/base.js
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    const route = this.router.match(location, this.current)
    this.confirmTransition(route, () => {
       // ...
       this.updateRoute(route)
    })
}
updateRoute (route: Route) {
  const prev = this.current
  this.current = route
  // cb是在router.init中调用listen注册
  this.cb && this.cb(route)
  this.router.afterHooks.forEach(hook => {
    hook && hook(route, prev)
  })
}
listen (cb: Function) {
  this.cb = cb
}
history.listen(route => {
  this.apps.forEach((app) => {
      app._route = route
  })
})

可见,在执行confirmTransition完成路由的切换后,会调用updateRoute,然后修改vm._route,此时会重新渲染router-view组件。至此整个流程已整理完毕

9. 小结

上面整理了Vue-Router大致的运行流程和工作原理,了解了matcher的工作机制和不同模式下模拟的history实例,以及router-link和router-view的构造和关联。其中也有一些细节没有处理,比如滚动行为、avtiveClass、异步组件和keep-alive等特性。

现在回头看看,这一个多月基本都在学习Vue相关源码,还是有一些收获的,包括从Vue的使用到内部实现的一些理解,以及阅读源码的经验积累,还有对于Vue之外的关于编码的思考。其中我觉得,阅读源码最难的不是思考某个机制的实现,而是思考为什么作者需要这么处理。

也许某一天Vue就不再流行了,也许Vue-Router会被更好的框架替代,但我相信这一段时间的学习经历,对于自己而言还是很有帮助的,还有很长的路要走,自勉之。