vue-router源码解析

vue-router源码解析

前端路由简介及实现原理

1. hash 模式

随着 ajax 的流行,异步数据请求交互可以在不刷新浏览器的情况下进行,而异步交互体验的更高级版本就是 SPA —— 单页应用。单页应用不仅仅是在页面交互是无刷新的,连页面跳转都是无刷新的,为了实现单页应用,所以就有了前端路由。

前端路由实现起来比较简单,就是匹配不同的 url 路径,进行解析,然后动态的渲染出区域 html 内容。但是这样存在一个问题,就是 url 每次变化的时候,都会造成页面的刷新。那解决问题的思路便是在改变 url 的情况下,保证页面的不刷新。在 html5 出现之前,大家是通过 hash 来实现路由,url hash 就是类似于:

http://www.xxx.com/#/login

像这种带#号,后面hash值的变化,并不会导致浏览器向服务器发出请求,浏览器不发出请求,也就不会刷新页面。另外每次 hash 值的变化,还会触发 hashchange 这个事件,通过这个事件我们就可以知道 hash 值发生了哪些变化。然后我们便可以通过监听 hashchange 来实现更新页面部分内容的操作。

2. history 模式

HTML5 标准发布之后,多了两个 API,pushStatereplaceState,通过这两个API可以改变url地址且不会发送请求。同时还有 popstate 事件。通过这些就能用另一种方式来实现前端路由了,但原理都是跟hash实现相同的。用了HTML5的实现,单页路由的url就不会多出一个#,变得更加美观。但因为没有 # 号,所以当用户刷新页面之类的操作时,浏览器还是会给服务器发送请求, 如果在服务端找不到匹配的路由,就会出现404。为了避免出现这种情况,你要在服务端增加一个覆盖所有情况的候选资源:如果URL匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你app依赖的页面。

vue-router功能和用法

学习使用一门技术,最快的方式就是先看官方技术文档,然后跟着写demo或者运用到实际项目中,一般技术文档里面功能点都是比较全,而且比较实用的,学习和阅读源码也不例外,如果阅读源码之前,足够熟悉对应的api,那对学习源码也能起到事半功倍的效果。

我们可以结合某个api有什么功能,它是用来解决什么问题的以及该功能是如何实现的,为什么这样实现,如果是我,我会如何实现,带着这样的思考,来学习源码可能会更好。

我们平时在使用vue-router的时候通常需要在 main.js 中初始化Vue实例时将vue-router实例对象当做参数传入

先来看一下vue-router使用的基本实现,具体代码如下:

import VueRouter from 'vue-router'
Vue.use(VueRouter)

const router = new VueRouter({
  mode: 'history',
  routes: [...]
})

new Vue({
  router
  ...
})

详见vue-router官方文档

Vue.use

从上面vue-router的基本用法,我们可以看到使用了Vue.use(VueRouter)来安装VueRouter,那么Vue.use(VueRouter)做了什么事情呢?

问题定位到Vue源码中的src/core/global-api/use.js源码地址 (https://github.com/vuejs/vue/blob/dev/src/core/global-api/use.js)

export function initUse (Vue: GlobalAPI) {
  Vue.use = function (plugin: Function | Object) {
    // 拿到 installPlugins 
    const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))
    // 保证不会重复注册
    if (installedPlugins.indexOf(plugin) > -1) {
      return this
    }
    // 获取第一个参数 plugins 以外的参数
    const args = toArray(arguments, 1)
    // 将 Vue 实例添加到参数
    args.unshift(this)
    // 执行 plugin 的 install 方法 每个 insatll 方法的第一个参数都会变成 Vue,不需要额外引入
    if (typeof plugin.install === 'function') {
      plugin.install.apply(plugin, args)
    } else if (typeof plugin === 'function') {
      plugin.apply(null, args)
    }
    // 最后用 installPlugins 保存 
    installedPlugins.push(plugin)
    return this
  }
}

可以看到Vueuse方法会接受一个plugin参数,然后使用installPlugins数组 保存已经注册过的plugin。首先保证plugin不被重复注册,然后将Vue从函数参数中取出,将整个Vue作为plugin的install 方法的第一个参数,这样做的好处就是不需要麻烦的另外引入 Vue,便于操作。接着就去判断plugin上是否存在install方法。存在则将赋值后的参数传入执行 ,最后将所有的存在install方法的plugin交给installPlugins维护。

vue-router源码目录

首先我们来看一下vue-router源码目录结构,具体如下:

|-- src  ----------------------------------- 包含主要源码
    |   |-- create-matcher.js  ------------------------- 路由映射表
    |   |-- create-route-map.js  ------------------------------- 创建路由映射状态树
    |   |-- index.js  ----------------------------  主入口文件
    |   |-- install.js  ---------------------------- 路由装载文件
    |   |-- components  --------------------------- 路由组件
    |   |   |-- link.js  ---------------- router-link组件
    |   |   |-- view.js  ---------------- router-view组件
    |   |-- history  -------------------------- 路由模式
    |   |   |-- abstract.js  ------------------------ abstract路由模式
    |   |   |-- base.js ---------------------------- history类
    |   |   |-- hash.js  ------------------------ hash路由模式
    |   |   |-- html5.js  ------------------------ HTML5History模式
    |   |-- util  ---------------------------- 工具类功能封装
    |       |-- async.js ---------------------------- 异步任务调度
    |       |-- dom.js  ---------------------------- 判断是否为浏览器环境
    |       |-- errors.js  ---------------------------- 错误处理
    |       |-- location.js  ---------------------------- 解析location
    |       |-- misc.js ---------------------------- 对象浅拷贝方法extend
    |       |-- params.js ---------------------------- 缓存params
    |       |-- path.js  ---------------------------- 解析path
    |       |-- push-state.js  ---------------------------- 判断是否支持pushState
    |       |-- query.js  ---------------------------- 解析和序列化查询参数
    |       |-- resolve-components.js  ---------------------------- 异步组件
    |       |-- route.js  ---------------------------- 创建route对象
    |       |-- scroll.js  ---------------------------- 滚动处理
    |       |-- state-key.js ---------------------------- 获取和设置state key
    |       |-- warn.js  ---------------------------- 断言错误和警告

从入口开始分析

通过目录结构找到入口文件index.js,从这个文件里面可以看到,该文件提供了一个VueRouter类, 这个就是我们在vue项目中引入vue-router的时候所用到的new Router(),其中具体内部代码如下(为了方便阅读,省略部分代码)

// index.js
export default class VueRouter {
  constructor(options: RouterOptions = {}) {
    this.app = null
    this.apps = []
    this.options = options
    this.beforeHooks = []
    this.resolveHooks = []
    this.afterHooks = []
    // 根据传入的routes参数生成路由状态表
    this.matcher = createMatcher(options.routes || [], this)
    // 默认使用hash路由模式
    let mode = options.mode || 'hash'
    // 如果不支持history模式,则回退使用hash模式
    this.fallback = mode === 'history' && !supportsPushState && options.fallback !== false
    if (this.fallback) {
      mode = 'hash'
    }
    // 非浏览器环境(比如node环境)使用abstract路由模式
    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}`)
        }
    }
  }
  // 初始化
  init() {}
}

// ...省略部分方法

// 注册hook
function registerHook(list: Array < any > , fn: Function): Function {
  list.push(fn)
  return () => {
    const i = list.indexOf(fn)
    if (i > -1) list.splice(i, 1)
  }
}
// 创建href
function createHref(base: string, fullPath: string, mode) {
  var path = mode === 'hash' ? '#' + fullPath : fullPath
  return base ? cleanPath(base + '/' + path) : path
}
// 挂载install方法
VueRouter.install = install
VueRouter.version = '__VERSION__'

// ...

// 浏览器环境安装vue-router
if (inBrowser && window.Vue) {
  window.Vue.use(VueRouter)
}

先来看一下constructor实例化的时候将会做的处理:通过new VueRouter({...})我们创建了一个 VueRouter 的实例。VueRouter中通过参数mode来指定路由模式,前面已经简单的了解了一下前端路由的2种模式。通过上面的代码,我们可以看出来 VueRouter对不同模式的实现大致是这样的:

入口文件代码主要做了以下几件事:

  1. 初始化路由模式

  2. 根据传入的routes参数生成路由状态表

  3. 获取当前路由对象

  4. 初始化路由函数

  5. 注册Hooks等事件

  6. 添加install装载函数

install装载函数

从入口文件index.js代码中,我们可以看到VueRouter类中挂载了一个install方法,在我们引入VueRouter并且实例化它的时候,VueRouter内部会帮助我们将router实例装载入vue的实例中,这样我们才可以在组件中可以直接使用router-linkrouter-view等组件。以及直接访问this.$routerthis.$route等全局变量,这些事情主要都是通过install.js来实现的,具体代码如下:

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

export let _Vue

export function install(Vue) {
  // 判断是否装载,如果已经安装过,则不再执行后续操作了
  if (install.installed && _Vue === Vue) return
  install.installed = true
  // export 一个 Vue 引用
  _Vue = Vue
  // 判断一个变量是否定义
  const isDef = v => v !== undefined
  // 实现对router-view的挂载操作
  const registerInstance = (vm, callVal) => {
    let i = vm.$options._parentVnode
    if (isDef(i) && isDef(i = i.data) && isDef(i = i.registerRouteInstance)) {
      i(vm, callVal)
    }
  }

  // 混入 beforeCreate 钩子
  Vue.mixin({
    beforeCreate() {
      // 在option上面存在router则代表是根组件 
      if (isDef(this.$options.router)) {
        this._routerRoot = this
        this._router = this.$options.router
        // 执行_router实例的 init 方法
        this._router.init(this)
        // 利用vue工具库对当前路由进行数据劫持
        Vue.util.defineReactive(this, '_route', this._router.history.current)
      } else {
        // 非根组件则直接从父组件中获取
        this._routerRoot = (this.$parent && this.$parent._routerRoot) || this
      }
      // 实现对router-view的挂载操作
      registerInstance(this, this)
    },
    destroyed() {
      registerInstance(this)
    }
  })
  // 设置代理,当访问 this.$router 的时候,代理到 this._routerRoot._router
  Object.defineProperty(Vue.prototype, '$router', {
    get() {
      return this._routerRoot._router
    }
  })
  // 设置代理,当访问 this.$route 的时候,代理到 this._routerRoot._route
  Object.defineProperty(Vue.prototype, '$route', {
    get() {
      return this._routerRoot._route
    }
  })
  // 全局注册 router-view 和 router-link 组件
  Vue.component('RouterView', View)
  Vue.component('RouterLink', Link)
  // Vue钩子合并策略
  const strats = Vue.config.optionMergeStrategies
  // use the same hook merging strategy for route hooks
  strats.beforeRouteEnter = strats.beforeRouteLeave = strats.beforeRouteUpdate = strats.created
}

再具体总结一下install.js主要做了哪些事情:

  1. 使用mixin在组件中混入beforeCreate,destory这俩个生命周期钩子
  2. 在构造Vue实例的时候,会传入router对象,此时的router会被挂载到Vue的根组件this.$options选项中。在option上面存在router则代表是根组件。如果存在this.$options.router,则对_routerRoot_router进行赋值操作,之后执行 _router.init() 方法
  3. 为了让 _route 的变化能及时响应页面的更新,所以接着又调用了 Vue.util.defineReactive方法来进行get和set的响应式数据定义
  4. 然后通过registerInstance(this, this)这个方法来实现对router-view的挂载操作
  5. 同时设置全局访问变量$router$route
  6. 全局注册router-linkrouter-view 组件

createMatcher方法

之前在VueRouter的构造函数中初始化了createMatcher方法,下面我们分析下这句代码到底做了什么事,以及match方法在做什么,部分代码如下:

export function createMatcher (
  routes: Array<RouteConfig>,
  router: VueRouter
): Matcher {
  // 创建映射表
  const { pathList, pathMap, nameMap } = createRouteMap(routes)
  // 动态添加路由配置(已废弃)
  function addRoutes (routes) {
    createRouteMap(routes, pathList, pathMap, nameMap)
  }
  // 动态添加路由配置
  function addRoute (parentOrRoute, route) {
    const parent = (typeof parentOrRoute !== 'object') ? nameMap[parentOrRoute] : undefined
    // $flow-disable-line
    createRouteMap([route || parentOrRoute], pathList, pathMap, nameMap, parent)

    // add aliases of parent
    if (parent) {
      createRouteMap(
        // $flow-disable-line route is defined if parent is
        parent.alias.map(alias => ({ path: alias, children: [route] })),
        pathList,
        pathMap,
        nameMap,
        parent
      )
    }
  }
  // 计算新路径
  function match (
    raw: RawLocation,
    currentRoute?: Route,
    redirectedFrom?: Location
  ): Route {...}
  // ... 后面的一些方法暂不展开
  
  return {
    match,
    addRoute,
    getRoutes,
    addRoutes
  }
}

createMatcher方法接受俩参数,分别是routes,这个就是我们平时在router.js定义的路由表配置,然后还有一个参数是router,就是new VueRouter 返回的实例。这个函数返回一个包含match,addRoutes,addRoute,getRoutes这四个方法的对象。

createRouteMap方法

export function createRouteMap (
  routes: Array<RouteConfig>,
  oldPathList?: Array<string>,
  oldPathMap?: Dictionary<RouteRecord>,
  oldNameMap?: Dictionary<RouteRecord>
): {
  pathList: Array<string>,
  pathMap: Dictionary<RouteRecord>,
  nameMap: Dictionary<RouteRecord>
} {
  // 记录所有的 path
  const pathList: Array<string> = oldPathList || []
  // 记录 path-RouteRecord 的 Map
  const pathMap: Dictionary<RouteRecord> = oldPathMap || Object.create(null)
   // 记录 name-RouteRecord 的 Map
  const nameMap: Dictionary<RouteRecord> = oldNameMap || Object.create(null)
  // 遍历所有的 route 生成对应映射表
  routes.forEach(route => {
    addRouteRecord(pathList, pathMap, nameMap, route)
  })
  // 调整优先级
  for (let i = 0, l = pathList.length; i < l; i++) {
    if (pathList[i] === '*') {
      pathList.push(pathList.splice(i, 1)[0])
      l--
      i--
    }
  }
  return {
    pathList,
    pathMap,
    nameMap
  }
}

createRouteMap需要传入路由配置,支持传入旧路径数组和旧的Map这一步是为后面递归和添加路由做好准备。首先用三个变量记录pathList,pathMap,nameMap, 接着我们来看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)

  if (typeof route.caseSensitive === 'boolean') {
    pathToRegexpOptions.sensitive = route.caseSensitive
  }
  // 路由记录 对象
  const record: RouteRecord = {
    path: normalizedPath,
    regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
    components: route.components || { default: route.component },
    alias: route.alias
      ? typeof route.alias === 'string'
        ? [route.alias]
        : route.alias
      : [],
    instances: {},
    enteredCbs: {},
    name,
    parent,
    matchAs,
    redirect: route.redirect,
    beforeEnter: route.beforeEnter,
    meta: route.meta || {},
    props:
      route.props == null
        ? {}
        : route.components
          ? route.props
          : { default: route.props }
  }
  // 嵌套子路由 则递归增加 记录
  if (route.children) {
    // Warn if route is named, does not redirect and has a default child route.
    // If users navigate to this route by name, the default child will
    // not be rendered (GH Issue #629)
    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
  }
  // 处理别名 alias 逻辑 增加对应的 记录
  if (route.alias !== undefined) {
    const aliases = Array.isArray(route.alias) ? route.alias : [route.alias]
    for (let i = 0; i < aliases.length; ++i) {
      const alias = aliases[i]
      const aliasRoute = {
        path: alias,
        children: route.children
      }
      addRouteRecord(
        pathList,
        pathMap,
        nameMap,
        aliasRoute,
        parent,
        record.path || '/' // matchAs
      )
    }
  }

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

addRouteRecord方法主要做了以下几件事:

  1. 记录路由信息的关键对象,后续会依此建立映射表
  2. 如果有 children 递归调用addRouteRecord
  3. 最后映射两张表(nameMappathMap),并将record.path保存进 pathList,把record通过name属性映射到nameMap表,通过path属性映射到pathMap

分析HashHistory和HTML5History类

HashHistory

部分代码如下:

// 继承 History 基类
export class HashHistory extends History {
  constructor (router: VueRouter, base: ?string, fallback: boolean) {
    // 调用基类构造器
    super(router, base)

    // 如果说是从 history 模式降级来的
    // 需要做降级检查
    if (fallback && this.checkFallback()) {
      // 如果降级 且 做了降级处理 则什么也不需要做
      return
    }
    // 保证 hash 是以 / 开头
    ensureSlash()
  }
// ...
}

function checkFallback (base) {
    // 得到除去 base 的真正的 location 值
    const location = getLocation(this.base)
    if (!/^\/#/.test(location)) {
      // 如果说此时的地址不是以 /# 开头的
      // 需要做一次降级处理 降级为 hash 模式下应有的 /# 开头
      window.location.replace(
        cleanPath(this.base + '/#' + location)
      )
      return true
    }
}

// 保证 hash 以 / 开头
function ensureSlash (): boolean {
  // 得到 hash 值
  const path = getHash()
  // 如果说是以 / 开头的 直接返回即可
  if (path.charAt(0) === '/') {
    return true
  }
  // 不是的话 需要手工保证一次 替换 hash 值
  replaceHash('/' + path)
  return false
}

export function getHash (): string {
  // 因为兼容性问题 这里没有直接使用 window.location.hash
  // 因为 Firefox decode hash 值
  const href = window.location.href
  const index = href.indexOf('#')
  // 如果此时没有 # 则返回 ''
  // 否则 取得 # 后的所有内容
  return index === -1 ? '' : href.slice(index + 1)
}

以上代码主要做了两件事情:针对于不支持 history api 的降级处理,以及保证默认进入的时候对应的 hash 值是以 / 开头的,如果不是则替换。

HTML5History

部分代码如下:

export class HTML5History extends History {
  _startLocation: string

  constructor (router: Router, base: ?string) {
    super(router, base)

    this._startLocation = getLocation(this.base)
  }

  setupListeners () {
    if (this.listeners.length > 0) {
      return
    }

    const router = this.router
    const expectScroll = router.options.scrollBehavior
    const supportsScroll = supportsPushState && expectScroll

    if (supportsScroll) {
      this.listeners.push(setupScroll())
    }

    const handleRoutingEvent = () => {
      // 当前路由对象
      const current = this.current
      console.log('current', current)

      // 避免在有的浏览器中第一次加载路由就会触发 `popstate` 事件
      // Avoiding first `popstate` event dispatched in some browsers but first
      // history route not updated since async guard at the same time.
      const location = getLocation(this.base)
      if (this.current === START && location === this._startLocation) {
        return
      }
      // 路由切换动作
      this.transitionTo(location, route => {
        if (supportsScroll) {
          handleScroll(router, route, current, true)
        }
      })
    }
    // 监听 popstate 事件
    window.addEventListener('popstate', handleRoutingEvent)
    this.listeners.push(() => {
      window.removeEventListener('popstate', handleRoutingEvent)
    })
  }

  ensureURL (push?: boolean) {
    if (getLocation(this.base) !== this.current.fullPath) {
      const current = cleanPath(this.base + this.current.fullPath)
      push ? pushState(current) : replaceState(current)
    }
  }

  getCurrentLocation (): string {
    return getLocation(this.base)
  }
}

可以看到在这种模式下,初始化作的工作相比hash模式少了很多,只是调用基类构造函数以及初始化监听事件,不需要再做额外的工作

addRoutes和addRoute的区别

详见router.addRoutes

学习源码总结

通过学习源码,我们可以收获:

  1. 学习到牛人优秀的编码风格、编码技巧以及编程思想
  2. get到更多实用的新奇的api
  3. 获取到很多干货,包括一些常见的面试题等等
  4. 对所使用的技术能做到,知其然,知其所以然
赞 赏