看完拉勾前端训练营关于Vue-Router的实现,干货满满,但Vue-Router的实现实在是绕,所以做一下笔记,确认以及加深自己的了解。进了拉勾前端训练营两个多月,收获还是挺多的,群里不少大牛,还有美女班主任,导师及时回答学员的疑问,幽默风趣,真是群里一席谈,胜读四年本科(literally true,四年本科的课程真的水=_=)。

实现的功能

实现前,看一下实现的功能:

  1. 基本路由功能
  2. 子路由功能
  3. History及Hash功能

创建一个项目。首先肯定是要创建Vue Router的类,在根目录下创建index.js文件:

export default class VueRouter {constructor (option) {this._routes = options.routes || []
    }

    init () {}
}复制代码

我们平时创建路由实例时,会传入一个对象,像这样:

const router = new VueRouter({
  routes
})复制代码

所以构造函数应该要有一个对象,如果里面有路由routes,赋值给this._routes,否则给它一个空数组。options里当然有其他属性,但先不管,之后再实现。 还有一个init方法,用来初始化设定。

install

由于Vue Router是插件,要想使用它,必须通过Vue.use方法。该方法会判定传入的参数是对象还函数,如果是对象,则调用里面的install方法,函数的话则直接调用。 Vue Router是一个对象,所以要有install方法。实现install之前,看一下Vue.use的源码,这样可以更好理解怎样实现install:

export function initUse (Vue: GlobalAPI) {

  Vue.use = function (plugin: Function | Object) {const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))if (installedPlugins.indexOf(plugin) > -1) {      return this}const args = toArray(arguments, 1)
    args.unshift(this)if (typeof plugin.install === 'function') {
      plugin.install.apply(plugin, args)  
    } else if (typeof plugin === 'function') {
      plugin.apply(null, args)
    }
    installedPlugins.push(plugin)return this
  }
}复制代码

首先Vue.use会先判定Vue有没有一个属性叫_installedPlugins,有则引用,没有就为Vue添加属性_installedPlugins,它是一个空数组,再去引用它。_installedPlugins是记录安装过的插件。接下来判定_installedPlugins里有没有传入的插件,有则不用安装。 把传入的参数从第二个开始,变成数组,把Vue放入数组首位。如果插件是对象,则调用它的install方法,插件方法里的上下文this依然是它自身,传入刚才变成数组的参数。函数的话,不用考虑上下文,直接调用。最后记录该插件是安装过的。

现在简单把install方法实现,在根目录下新建install.js:

export let _Vue = nullexport default function install (Vue) {
  _Vue = Vue
  _Vue.mixin({
    beforeCreate () {      if (this.$options.router) {this._router = this.$options.routerthis._routerRoot = this// 初始化 router 对象this._router.init(this)
      } else {this._routerRoot = this.$parent && this.$parent._routerRoot
      }
    }
  })复制代码

全局变量_Vue是为了方便其他Vue Router模块的引用,不然的话其他模式需要引入Vue,比较麻烦。mixin是把Vue中某些功能抽取出来,方便在不同地方複用,这里的用法是全局挂载鈎子函数。

先判断是否为根实例,如果是根实例,会有路由传入,所以会$options.router存在。根实例的话则添加两个私有属性,其中_routerRoot是为了方便根实例以下的组件引用,然后初始化router。如果是根实例下的组件,去找一下有没有父组件,有就引用它的_routerRoot,这样可以通过_routerRoot.router来引用路由。

挂载函数基本完成。当我们使用Vue Router,还有两个组件挂载:Router Link和Router View。在根目录下创建文件夹components,创建文件link.js和view.js。先把Router Link实现:

export default {  name: 'RouterLink',  props: {to: {      type: String,      required: true}
  },
  render (h) {return h('a', { attrs: { href: '#' + this.to } }, [this.$slots.default])
  }
}复制代码

RouterLink接收一个参数to,类型是字符串。这里不使用template,是因为运行版本的vue没有编译器,把模板转为渲染函数,要直接用渲染函数。 简单讲一下渲染函数的用法,第一个参数是标签类型,第二个是标签的属性,第三是内容。详细可以看vue文档。 我们要实现的其实是<a :href="{{ '#' + this.to }}"><slot name="default"></slot></a>。所以第一个参数是a,第二个它的连接,第三个之所以要用数组,是因为标签的内容是一个slot标签节点,子节点要用数组包起来。 至于RouterView,现在不知道它的实现,大概写一下:

export default {  name: 'RouterView',
  render (h) {return h () 
  }
}复制代码

在install里把两个组件注册:

import Link from './components/link'import View from './components/view'export default function install (Vue) {
   ...
  _Vue.component(Link.name, Link)
  _Vue.component(View.name, View)
}复制代码

createMatcher

接下来要创建create-matcher,它是用来生成匹配器,主要返回两个方法:match和addRoutes。前者是匹配输入路径,获取路由表相关资料,后者是手动添加路由规则到路由表。这两个方法都是要依赖路由表,所以我们还要实现路由表生成器:create-router-map,它接收路由规则,返回一个路由表,它是对象,里面有两个属性,一个是pathList,它是一个数组,存有所有路由表的路径,另一个是pathMap,是一个字典,键是路径,而值的路径相应的资料。 在项目根目录下创建create-router-map.js:

export default function createRouteMap (routes) {  // 存储所有的路由地址
  const pathList = []  // 路由表,路径和组件的相关信息
  const pathMap = {}  return {
    pathList,
    pathMap
  }
}复制代码

我们需要遍历路由规则,在这过程中做两件事:

  1. 把所有路径存入pathList
  2. 把路由和资料对应关係放入pathMap

这里的难点是有子路由,所以要用递归,但现在先不要考虑这问题,简单把功能实现:

function addRouteRecord (route, pathList, pathMap, parentRecord) {  const path = route.path  const record = {path: path,component: route.component,parentRecord: parentRecord// ...
  }  // 判断当前路径,是否已经存储在路由表中了
  if (!pathMap[path]) {
    pathList.push(path)
    pathMap[path] = record
  }
}复制代码

现在考虑一下子路由的问题。首先要先有判定路由是否有子路由,有的话遍历子路由,递归处理,还要考虑路径名称问题,如果是子路由,path应该是父子路径合并,所以这里要判定是否存有父路由。

function addRouteRecord (route, pathList, pathMap, parentRecord) {  const path = parentRecord ? `${parentRecord.path}/${route.path}` : route.path  const record = {path: path,component: route.component,parentRecord: parentRecord// ...
  }  // 判断当前路径,是否已经存储在路由表中了
  if (!pathMap[path]) {
    pathList.push(path)
    pathMap[path] = record
  }  // 判断当前的route是否有子路由
  if (route.children) {
    route.children.forEach(childRoute => {
      addRouteRecord(childRoute, pathList, pathMap, route)
    })
  }
}复制代码

如果有传入父路由资料,path是父子路径合并。

最后把addRouteRecord添加到createRouteMap:

export default function createRouteMap (routes) {  // 存储所有的路由地址
  const pathList = []  // 路由表,路径和组件的相关信息
  const pathMap = {}  // 遍历所有的路由规则 routes
  routes.forEach(route => {
    addRouteRecord(route, pathList, pathMap)
  })  return {
    pathList,
    pathMap
  }
}复制代码

createRouteMap实现了,可以把create-matcher的路由表创建和addRoute实现:

import createRouteMap from './create-route-map'export default function createMatcher (routes) {  const { pathList, pathMap } = createRouteMap(routes)  function addRoutes (routes) {
    createRouteMap(routes, pathList, pathMap)
  }  return {
    match,
    addRoutes
  }
}复制代码

最后要实现match了,它接收一个路径,然后返回路径相关资料,相关资料不仅仅是它自身的,还有它的父路径的资料。这里先实现一个工具类函数,它是专门创建路由的,就是返回路径以及它的相关资料。创建util/route.js:

export default function createRoute (record, path) {  // 创建路由数据对象
  // route ==> { matched, path }  matched ==> [record1, record2]
  const matched = []  while (record) {
    matched.unshift(record)

    record = record.parentRecord
  }  return {
    matched,
    path
  }复制代码

其实功能很简单,就是不断获取上一级的资料,放进数组首位。配上createRoute,match基本就实现了:

import createRoute from './util/route'

  function match (path) {const record = pathMap[path]if (record) {      // 创建路由数据对象  // route ==> { matched, path }  matched ==> [record1, record2]  return createRoute(record, path)
    }return createRoute(null, path)
  }复制代码

在VueRouter的构造函数里把matcher加上:

import createMatcher from './create-matcher'export default class VueRouter {  constructor (options) {this._routes = options.routes || []this.matcher = createMatcher(this._routes)
...复制代码

History历史管理

matcher做好后,开始实现History类吧,它的目的是根据用户设定的模式,管理路径,通知 RouterView把路径对应的组件渲染出来。

在项目根目录新建history/base.js:

import createRoute from '../util/route'export default class History {  constructor (router) {this.router = router// 记录当前路径对应的 route 对象 { matched, path }this.current = createRoute(null, '/')
  }

  transitionTo (path, onComplete) {this.current = this.router.matcher.match(path)
    onComplete && onComplete()
  }
}复制代码

创建时当时路径先默认为根路径,current是路由对象,属性有路径名和相关资料,transitionTo是路径跳转时调用的方法,它更改current和调用回调函数。 之后不同模式(如hash或history)的类都是继承History。这里只实现HashHistory:

import History from './base'export default class HashHistory extends History {  constructor (router) {super(router)// 保证首次访问的时候 #/ensureSlash()
  }

  getCurrentLocation () {return window.location.hash.slice(1)
  }

  setUpListener () {window.addEventListener('hashchange', () => {      this.transitionTo(this.getCurrentLocation())
    })
  }
}function ensureSlash () {  if (window.location.hash) {return
  }  window.location.hash = '/'}复制代码

HashHistory基本是围绕window.location.hash,所以先讲一下它。简单来说,它会返回#后面的路径名。如果对它赋值,它会在最前面加上#。明白window.location.hash后,其他方法都不难理解。setUpListener注册一个hashchange事件,表示当哈希路径(#后的路径)发生变化,调用注册的函数。

html5模式不实现了,继承HashHistory算了:

import History from './base'export default class HTML5History extends History {
}复制代码

History的类基本实现了,但是现在还不是响应式的,意味着即使实例发生变化,视图不会变化。这问题后解决。

回到VueRouter的构造函数:

constructor(options)
...const mode = this.mode = options.mode || 'hash'switch (mode) {      case 'hash':this.history = new HashHistory(this)break  case 'history':this.history = new HTML5History(this)break  default:throw new Error('mode error')
    }
 }复制代码

这里使用了简单工厂模式 (Simple Factory Pattern),就是设计模式中工厂模式的简易版。它存有不同的类,这些类都是继承同一类的,它通过传入的参数进行判断,创建相应的实例返回。简单工厂模式的好处是用户不用考虑创建实例的细节,他要做的是导入工厂,往工厂传入参数,就可获得实例。

init

之前的History有一个问题,就是它不是响应式的,也就是说,路径发生变化,浏覧器不会有任何反应,要想为响应式,可以给它一个回调函数:

import createRoute from '../util/route'export default class History {  constructor (router) {
  ...this.cb = null
  }
  ...
  listen (cb) {this.cb = cb
  }
  
  transitionTo (path, onComplete) {this.current = this.router.matcher.match(path)this.cb && this.cb(this.current)
    onComplete && onComplete()
  }
}复制代码

加上listen方法,为History添加回调函数,当路径发生转变时调用。

把之前的初始化方法init补上:

init (app) {  // app 是 Vue 的实例
  const history = this.history

  history.listen(current => {
    app._route = current
  })

  history.transitionTo(
    history.getCurrentLocation(),
    history.setUpListener
  )
}复制代码

给history的回调函数是路径发生变化,把路由传给vue实例,然后是转换至当前路径,完成时调用history.setUpListener。不过直接把history.setUpListener放进去有一个问题,因为这等于是仅仅把setUpListener放进去,里面的this指向window,所以要用箭头函数封装,这样的话,就会调用history.setUpListener,this指向history。

  init (app) {// app 是 Vue 的实例const history = this.historyconst setUpListener = () => {
      history.setUpListener()
    }

    history.listen(current => {
      app._route = current
    })

    history.transitionTo(
      history.getCurrentLocation(),
      setUpListener
    )
  }复制代码

用箭头函数把history.setUpListener封装一下,this就指向history。

install补完

init完成实现,回来把install的剩馀地方实现了。当初始化完成后,把vue实例的路由(不是路由表)变成响应式,可以使用 Vue.util.defineReactive(this, '_route', this._router.history.current),就是为vue实例添加一个属性_route,它的值是this._router.history.current,最后添加router和route。 完整代码如下:

import Link from './components/link'import View from './components/view'export let _Vue = nullexport default function install (Vue) {  // 判断该插件是否注册略过,可以参考源码
  _Vue = Vue  // Vue.prototype.xx
  _Vue.mixin({
    beforeCreate () {      // 给所有 Vue 实例,增加 router 的属性  // 根实例  // 以及所有的组件增加 router 属性  if (this.$options.router) {this._router = this.$options.routerthis._routerRoot = this// 初始化 router 对象this._router.init(this)
        Vue.util.defineReactive(this, '_route', this._router.history.current)// this.$parent// this.$children  } else {this._routerRoot = this.$parent && this.$parent._routerRoot
      }
    }
  })

  _Vue.component(Link.name, Link)
  _Vue.component(View.name, View)  Object.defineProperty(Vue.prototype, '$router', {
    get () { return this._routerRoot._router }
  })  Object.defineProperty(Vue.prototype, '$route', {
    get () { return this._routerRoot._route }
  })
}复制代码

现在就可以如平时开发一样,使用router和route。

RouterView

最后把RouterView实现。其实它也没什么,就是获取当取路径,从路径中得到组件,然后渲染出来。问题是要考虑父子组件的问题。把思想整理一下,当有父组件时,肯定是父组件已经渲染出来,子组件是从父组件的RouterView组件渲染,还有是$route有的是当前路径和匹配的资料的数组,即包括父组件的数组,所以可遍历获得要渲染的组件:

export default {  name: 'RouterView',
  render (h) {const route = this.$routelet depth = 0//routerView表示已经完成渲染了this.routerView = truelet parent = this.$parentwhile (parent) {      if (parent.routerView) {
        depth++
      }
      parent = parent.$parent
    }const record = route.matched[depth]if (record) {      return h(record.component)
    }return h()
  }
}复制代码

if (parent.routerView) 是因为是确认父组件是否已经渲染,如果渲染,它的routerView为true,用depth来记录有多少父路由,然后通过它获取matched的资料,有的话则渲染获取的组件。

总结

Vue Router的代码量不多,但实在是绕,简单总结一下比较好。先看一下项目结构:

Vue-Router实现_Vue-Router

用一张表把所有的文件作用简述一遍:

文件作用
index.js存放VueRouter类
install.js插件类必须要有的函数,用来给Vue.use调用
create-route-map.js生成路由表,它输出一个对象,有pathList和pathMap属性,前者是存有所有路径的数组,后者是字典,把路径和它的资料对应
util/route.js一个函数接收路径为参数,返回路由对象,存有matched和path属性,matched是匹配到的路径的资料和父路径资料,它是一个数组,path是路径本身
create-matcher.js利用create-route-map创建路由表,且返回两个函数,一个是用util/route匹配路由,另一个是手动把路由规则转变成路由
history/base.jsHistory类文件,用来作历史管理,存有当前路径的路由,以及转换路径的方法
history/hash.jsHashHistory类文件,继承至History,用作hash模式下的历史管理
components/link.jsRouter-Link的组件
components/view.jsRouter-View的组件