vue-router源码全解析

5,359 阅读3分钟

最近立了一个flag,要通读 vue-router 源码。压力有点大,希望我能写完。。。

参考版本: vue-router@v3.0.1

github.com/vuejs/vue-r…

源码在 ./src 下,使用 tree 命令查看下目录结构

.
|-- components           // 组件(view/link)
|   |-- link.js
|   `-- view.js
|-- create-matcher.js    // Route 匹配
|-- create-route-map.js  // Route 映射
|-- history              // Router 处理 (hash模式、html5模式)
|   |-- abstract.js
|   |-- base.js
|   |-- hash.js
|   `-- html5.js
|-- index.js             // Router 入口
|-- install.js           // Router 插件安装
`-- util                 // 工具库
    |-- async.js
    |-- dom.js
    |-- location.js
    |-- params.js
    |-- path.js
    |-- push-state.js
    |-- query.js
    |-- resolve-components.js
    |-- route.js
    |-- scroll.js
    `-- warn.js

根据官网提供的示例: router.vuejs.org/guide/#html

<div id="app">
  <h1>Hello App!</h1>
  <p>
    <!-- 使用 router-link 组件来导航. -->
    <!-- 通过传入 `to` 属性指定链接. -->
    <!-- <router-link> 默认会被渲染成一个 `<a>` 标签 -->
    <router-link to="/foo">Go to Foo</router-link>
    <router-link to="/bar">Go to Bar</router-link>
  </p>
  <!-- 路由出口 -->
  <!-- 路由匹配到的组件将渲染在这里 -->
  <router-view></router-view>
</div>

<script>
  // 0. 如果使用模块化机制编程,导入Vue和VueRouter,要调用 Vue.use(VueRouter)

  // 1. 定义 (路由) 组件。
  // 可以从其他文件 import 进来
  const Foo = { template: '<div>foo</div>' }
  const Bar = { template: '<div>bar</div>' }

  // 2. 定义路由
  // 每个路由应该映射一个组件。 其中"component" 可以是
  // 通过 Vue.extend() 创建的组件构造器,
  // 或者,只是一个组件配置对象。
  // 我们晚点再讨论嵌套路由。
  const routes = [
    { path: '/foo', component: Foo },
    { path: '/bar', component: Bar }
  ]

  // 3. 创建 router 实例,然后传 `routes` 配置
  // 你还可以传别的配置参数, 不过先这么简单着吧。
  const router = new VueRouter({
    routes // (缩写) 相当于 routes: routes
  })

  // 4. 创建和挂载根实例。
  // 记得要通过 router 配置参数注入路由,
  // 从而让整个应用都有路由功能
  const app = new Vue({
    router
  }).$mount('#app')

  // 现在,应用已经启动了!

</script>

简单打印一下:

可以发现 app.options.router 等于 router

根据官网示例,可以看出实现 vue-router 大概需要经过一下几步:

1、将 VueRouter 插件注入 Vue

2、定义 router-linkrouter-view 两个全局组件供页面跳转使用

3、传入用户自定义的 routes 路由映射关系

插件 vue-router 安装

vue-router 只是 vue 中的一个插件。在 vue 中插件的开发有这几种方式:

// https://cn.vuejs.org/v2/guide/plugins.html#开发插件

MyPlugin.install = function (Vue, options) {
  // 1. 添加全局方法或属性
  Vue.myGlobalMethod = function () {
    // 逻辑...
  }

  // 2. 添加全局资源
  Vue.directive('my-directive', {
    bind (el, binding, vnode, oldVnode) {
      // 逻辑...
    }
    ...
  })

  // 3. 注入组件选项
  Vue.mixin({
    created: function () {
      // 逻辑...
    }
    ...
  })

  // 4. 添加实例方法
  Vue.prototype.$myMethod = function (methodOptions) {
    // 逻辑...
  }
}

index.js

先看下 vue-router 的入口文件 ./src/index.js

VueRouter.install = install
VueRouter.version = '__VERSION__'

if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter)
}

VueRouter 的插入方式在 ./src/install.js 下。

先确保只安装一次

// 插件安装方法
export function install (Vue) {
  if (install.installed && _Vue === Vue) return
  install.installed = true
  // ...
}

然后通过 mixinvue 的生命周期 beforeCreate 内注册实例,在 destroyed 内销毁实例。

export function install (Vue) {
  // ...
  Vue.mixin({
    beforeCreate () {
      // this.$options.router 来自于 VueRouter 的实例化,参考上面的 app = new Vue() 的打印: app.png
      // 判断实例是否已经挂载
      if (isDef(this.$options.router)) {
        this._routerRoot = this
        this._router = this.$options.router
        // 调用 VueRouter 的 init 方法
        this._router.init(this)
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        // 将每一个组件的 _routerRoot 都指向根 Vue 实例
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      // 注册 VueComponent
      registerInstance(this, this)
    },
    destroyed () {
      // 销毁 VueComponent
      registerInstance(this)
    }
  })
  // ...
}

使用 mixin 会在每个 .vue 文件处进行 beforeCreatedestroyed

如在使用 vue-cli 创建的vue项目中,在 HelloWorld.vue 调用 mixin ,则会调用两次 mixin ,分别是父级 App.vue 和自己。

所以通过 this._routerRoot = this 可以将 _routerRoot 绑定至当前组件,即 _routerRoot 指向根节点。

接下来,将 $router$route 挂载到 vue

Object.defineProperty(Vue.prototype, '$router', {
  get () { return this._routerRoot._router }
})

Object.defineProperty(Vue.prototype, '$route', {
  get () { return this._routerRoot._route }
})

我们使用 vue 时,访问 this.$routerthis.$route 就来源于这里。

提示

通过 Object.defineProperty 定义 get 来实现 , 而不使用 Vue.prototype.$router = this._routerRoot._router 是为了让其只读,不可修改

最后,注册全局组件 router-viewrouter-link

import View from './components/view'
import Link from './components/link'

export function install (Vue) {
  // ...
  Vue.component('router-view', View)
  Vue.component('router-link', Link)
  // ...
}

就可以使用

<router-link to="/foo">Go to Foo</router-link>
<router-link to="/bar">Go to Bar</router-link>

<router-view></router-view>

回到 ./src/index.jsVueRouter

import { HashHistory } from './history/hash'
import { HTML5History } from './history/html5'
import { AbstractHistory } from './history/abstract'

export default class VueRouter {
  // ...
  constructor (options: RouterOptions = {}) {
    // 默认是 hash 模式
    let mode = options.mode || 'hash'
    // 模式兼容处理
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    if (!inBrowser) {
      mode = 'abstract'
    }
    this.mode = mode

    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}`)
        }
    }
  }
  // ...
}

可以看到有三种模式: HTML5History、HashHistory、AbstractHistory ,都来自于 history 目录。

create-matcher.js

示例中的 routes

const routes = [
  { path: '/foo', component: Foo },
  { path: '/bar', component: Bar }
]

const router = new VueRouter({
  routes // (缩写) 相当于 routes: routes
})

来自

import { createMatcher } from './create-matcher'

export default class VueRouter {
  constructor (options: RouterOptions = {}) {
    this.matcher = createMatcher(options.routes || [], this)
  }
}

matcher 来自于 createMatcher 的返回,初步猜测一下, createMatcher 是做了一个路由映射。

./src/create-matcher.js

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

  // ...

  return {
    match,
    addRoutes
  }
}

createMatcher 接收2个参数, routes 来自于用户定义的配置、 routernew VueRouter 返回的实例,同时返回两个方法 matchaddRoutes

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]
    if (process.env.NODE_ENV !== 'production') {
      warn(record, `Route with name '${name}' does not exist`)
    }
    if (!record) return _createRoute(null, location)
    const paramNames = record.regex.keys
      .filter(key => !key.optional)
      .map(key => key.name)

    if (typeof location.params !== 'object') {
      location.params = {}
    }

    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 = {}
    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 函数逻辑如下:

1、调用 normalizeLocation 获取 location 的信息。

2、判断是否有 name ,有的话通过 nameMap 来创建 route

3、判断是否有 path ,有的话通过 pathMap 来创建 route

4、如果 namepath 都没有,则直接用 null 创建 route

map 是什么?

这里以 pathMap 举例:

const routes = [
  // 有 path 没 name
  { path: '/foo', component: Foo },
  { path: '/bar', component: Bar }
]

match 之后会转换成Object形式

function addRoutes (routes) {
  createRouteMap(routes, pathList, pathMap, nameMap)
}

addRoutes 动态添加路由的方法,如: router.addRoutes()

小结一下, createMatcher 就是暴露出两个方法给 VueRouter ,需要做路由的映射以及动态添加路由的方法。

./src/create-route-map.js

create-route-map.js 中最重要的是 addRouteRecord 函数

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

  // ...

  if (route.children) {
    // ...
  }

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

  if (name) {
    if (!nameMap[name]) {
      nameMap[name] = record
    }
  }

  // ...

}

简化一下后,就是有 path 的时候存 pathMap ,有 name 的时候存 nameMap

record: RouteRecord 是什么?

const record: RouteRecord = {
  path: normalizedPath, // 路径
  regex: compileRouteRegex(normalizedPath, pathToRegexpOptions), // 正则匹配路径,如 { path: '/params-with-regex/:id(\\d+)' }
  components: route.components || { default: route.component }, // 对应组件
  instances: {},
  name, // 别名
  parent, // 父亲路由
  matchAs, // alias 会用到
  redirect: route.redirect, // 重定向
  beforeEnter: route.beforeEnter, // 钩子
  meta: route.meta || {},
  props: route.props == null
    ? {}
    : route.components
      ? route.props
      : { default: route.props }
}

record 保存着一个路由里所需的信息,比如我们可以通过路径来找到对应要渲染的组件。

如果有 嵌套路由 的话,即当前 route 有 children 属性的话,那就需要通过递归的方式将它们也加入映射之中。

addRouteRecord 的第四个参数 parent ,用于保存当前路由的父路由。

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

还有就是如果有别名,如

const routes = [
  {path: '/a', component: A, alias: '/ali'}
]

有别名的情况下, alias 并不会复制一份跟 path 一样的 record ,而是使用第五个参数 matchAs 来保存 path

if (route.alias !== undefined) {
  const aliases = Array.isArray(route.alias)
    ? route.alias
    : [route.alias]

  aliases.forEach(alias => {
    const aliasRoute = {
      path: alias,
      children: route.children
    }
    addRouteRecord(
      pathList,
      pathMap,
      nameMap,
      aliasRoute,
      parent,
      record.path || '/' // matchAs
    )
  })
}

这样,通过 alias 别名就能找到 path 路径,再通过 path 就能找到对应的 record ,从而找到对应的路由信息。

总结讲了这么老半天, ./src/create-matcher.js 中的 createMatcher 函数与 ./src/create-route-map.js 中的 createRouteMap 函数就是为了将数组 routes

const routes = [
  { path: '/foo', component: Foo },
  { path: '/bar', component: Bar }
]

转换成树形结构 map

map = {
  '/foo': {
    component: Foo
  },
  '/bar': {
    component: Bar
  }
}

再加上一个可以动态添加的函数 addRoutes

绕这么一大圈,不是很清楚作者为何要改成这样。一般来说本来数组的形式也是可以操作的。

history

路由的两种实现方式

1、 hash 路由

<a href="#/home">首页</a>
<a href="#/about">关于</a>
<div id="html"></div>

<script>
  window.addEventListener('load', function () {
    document.getElementById('html').innerHTML = location.hash.slice(1);
  });
  window.addEventListener('hashchange', function () {
    document.getElementById('html').innerHTML = location.hash.slice(1);
  });
</script>

2、 history 路由

<div onclick="go('/home')">首页</div>
<div onclick="go('/about')">关于</div>
<div id="html"></div>

<script>
  function go(pathname) {
    document.getElementById('html').innerHTML = pathname;
    history.pushState({}, null, pathname);
  }
  window.addEventListener('popstate', function () {
    go(location.pathname);
  });
</script>

注意

创建的 html 文件直接打开,有的浏览器执行 history.pushState 会报错:

"DOMException: Failed to execute 'pushState' on 'History': A history state object with URL"

需要创建本地环境 localhost

这是两种的简易实现方式,在源码 history 下有四个文件,基类 base.js 、hash路由 hash.js 、html5路由 html5.js 、abstract模式 abstract.js

export class History {
  // url 跳转
  transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
    // ...
  }
  // 确认跳转
  confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
    // ...
  }
}
// 得到 base 值
function normalizeBase (base: ?string): string {
  // ...
}
// 交叉比对当前路由的路由记录和现在的这个路由的路由记录来决定调用哪些路由记录的钩子函数
function resolveQueue () {
  // ...
}

先介绍下基类,基类 History 中有个很重要的函数 transitionTotransitionTo 会根据目标 location 与当前路径 this.current 进行匹配。如,从首页跳转到 /bar 页,执行

const route = this.router.match(location, this.current)

this.current 来自

import { START, isSameRoute } from '../util/route'

export class History {
  constructor (router: Router, base: ?string) {
    this.current = START
  }
}
// ./src/util/route.js
export function createRoute (
  record: ?RouteRecord,
  location: Location,
  redirectedFrom?: ?Location,
  router?: VueRouter
): Route {
  const stringifyQuery = router && router.options.stringifyQuery

  let query: any = location.query || {}
  try {
    query = clone(query)
  } catch (e) {}

  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) : []
  }
  if (redirectedFrom) {
    route.redirectedFrom = getFullPath(redirectedFrom, stringifyQuery)
  }
  return Object.freeze(route)
}

export const START = createRoute(null, {
  path: '/'
})

之后调用 confirmTransition 函数

confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
  // 路由相同则返回
  if (
    isSameRoute(route, current) &&
    route.matched.length === current.matched.length
  ) {
    this.ensureURL()
    return abort()
  }
}
const {
  updated,
  deactivated,
  activated
} = resolveQueue(this.current.matched, route.matched)

matched 来自 ./src/util/route.js

const route: Route = {
  // ...
  matched: record ? formatMatch(record) : []
}

recordRoute 的基本单位, formatMatch 函数是拿 record 及它的所有父级组成数组。

function resolveQueue (
  current: Array<RouteRecord>, // 当前页
  next: Array<RouteRecord> // 即将跳转页
): {
  updated: Array<RouteRecord>, // 相同的父级组件
  activated: Array<RouteRecord>, // 跳转页需要更新的组件
  deactivated: Array<RouteRecord> // 当前页需要更新的组件
}

可以理解为,比如:用上面的例子从 /foo 跳转到 /bar ,那么他的父组件是相同的不需要更新,但 /foo 组件需要更新(去掉)、 /bar 组件也需要更新(添上)。

再往下,就是官方说的“导航守卫”

// 导航守卫数组
const queue: Array<?NavigationGuard> = [].concat(
  // 失活的组件钩子
  extractLeaveGuards(deactivated),
  // 全局 beforeEach 钩子
  this.router.beforeHooks,
  // 在当前路由改变,但是该组件被复用时调用
  extractUpdateHooks(updated),
  // 需要渲染组件 enter 守卫钩子
  activated.map(m => m.beforeEnter),
  // 解析异步路由组件
  resolveAsyncComponents(activated)
)

1、给 deactivated 组件加上 beforeRouteLeave 钩子。

extractGuardsRouteRecord 数组中提取各个阶段的守卫。 flatMapComponents 方法去从 records 中获取所有的导航。

function extractGuards (
  records: Array<RouteRecord>,
  name: string,
  bind: Function,
  reverse?: boolean
): Array<?Function> {
  const guards = flatMapComponents(records, (def, instance, match, key) => {
    const guard = extractGuard(def, name)
    if (guard) {
      return Array.isArray(guard)
        ? guard.map(guard => bind(guard, instance, match, key))
        : bind(guard, instance, match, key)
    }
  })
  return flatten(reverse ? guards.reverse() : guards)
}

获取到 guard 后,还会调用 bind 方法把组件的实例 instance 作为函数执行的上下文绑定到 guard 上。

function bindGuard (guard: NavigationGuard, instance: ?_Vue): ?NavigationGuard {
  if (instance) {
    return function boundRouteGuard () {
      return guard.apply(instance, arguments)
    }
  }
}

2、添加全局 beforeEach 钩子。

./src/index.js 中定义了 beforeEach 。当用户使用 router.beforeEach ,就会往 router.beforeHooks 添加一个钩子函数,这样 this.router.beforeHooks 获取的就是用户注册的全局 beforeEach

beforeEach (fn: Function): Function {
  return registerHook(this.beforeHooks, fn)
}

function registerHook (list: Array<any>, fn: Function): Function {
  list.push(fn)
  return () => {
    const i = list.indexOf(fn)
    if (i > -1) list.splice(i, 1)
  }
}

3、给 updated 组件加上 beforeRouteUpdate 钩子。

extractLeaveGuards(deactivated) 相差不大。

4、添加全局 beforeEnter 钩子。

激活的路由配置中定义的 beforeEnter 函数。

5、加载要被激活的异步组件。

resolveAsyncComponents 返回的是一个导航守卫函数,有标准的 tofromnext 参数。它的内部实现很简单,利用了 flatMapComponents 方法从 matched 中获取到每个组件的定义,判断如果是异步组件,则执行异步组件加载逻辑。

这样在 resolveAsyncComponents(activated) 解析完所有激活的异步组件后,我们就可以拿到这一次所有激活的组件。

6、在被激活的组件里调用 beforeRouteEnter

7、调用全局的 beforeResolve

beforeResolve (fn: Function): Function {
  return registerHook(this.resolveHooks, fn)
}

8、导航被确认。

9、调用全局的 afterEach

执行了 onComplete(route) 后,会执行 this.updateRoute(route) 方法。

10、触发DOM更新。

11、用创建好的实例调用 beforeRouteEnter 守卫传给 next 的回调函数。

const iterator = (hook: NavigationGuard, next) => {
  // ...
}

iterator 函数逻辑很简单,它就是去执行每一个 导航守卫 hook ,并传入 routecurrent 和匿名函数,这些参数对应文档中的 tofromnext ,当执行了匿名函数,会根据一些条件执行 abortnext ,只有执行 next 的时候,才会前进到下一个导航守卫钩子函数中,这也就是为什么官方文档会说只有执行 next 方法来 resolve 这个钩子函数。

那么,怎么执行这些 queue 呢?

创建一个 迭代器模式 的函数

function runQueue (queue, fn, cb) {
  var step = function (index) {
    if (index >= queue.length) {
      cb();
    } else {
      if (queue[index]) {
        fn(queue[index], function () {
          step(index + 1);
        });
      } else {
        step(index + 1);
      }
    }
  };
  step(0);
}
// 示例
// 可以通过是否调用 `next` 让执行随时停止。
var arr = [1,2,3];
runQueue(arr, function (a, next) {
  console.log(a, next);
  // next();
}, function () {
  console.log('callback');
});

组件

<router-view> 组件通过 render 渲染。

const route = parent.$route // 获取当前的路径

<router-view> 支持嵌套

while (parent && parent._routerRoot !== parent) {
  if (parent.$vnode && parent.$vnode.data.routerView) {
    depth++
  }
  if (parent._inactive) {
    inactive = true
  }
  parent = parent.$parent
}

还记得一开始说的吗 _routerRoot 表示的是根 Vue 实例。

同时定义了一个注册路由实例的方法。

data.registerRouteInstance = (vm, val) => {
  // val could be undefined for unregistration
  const current = matched.instances[name]
  if (
    (val && current !== vm) ||
    (!val && current === vm)
  ) {
    matched.instances[name] = val
  }
}

./src/install.js 中,我们会调用该方法去注册路由的实例。

const registerInstance = (vm, callVal) => {
  let i = vm.$options._parentVnode
  if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
    i(vm, callVal)
  }
}

Vue.mixin({
  beforeCreate () {
    registerInstance(this, this)
  },
  destroyed () {
    registerInstance(this)
  }
})

<router-link> 组件也是通过 render 渲染。

当我们点击 <router-link> 的时候,实际上最终会执行 router.push

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

我们看下 hash 模式下 push 的实现。

push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
  const { current: fromRoute } = this
  this.transitionTo(location, route => {
    pushHash(route.fullPath)
    handleScroll(this.router, route, fromRoute, false)
    onComplete && onComplete(route)
  }, onAbort)
}

执行 transitionTo 跳转后,调用 pushHash 更新浏览器的 url 地址,并把当前 url 压入历史栈中。

history 的初始化中,会设置一个监听器,监听历史栈的变化。

setupListeners () {
  const router = this.router
  const expectScroll = router.options.scrollBehavior
  const supportsScroll = supportsPushState && expectScroll

  if (supportsScroll) {
    setupScroll()
  }

  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)
      }
    })
  })
}

传送门: Vuex源码全解析


终于写完了!!!最后点个赞或者关注公众号“大前端FE”