Vue-Router实现

700 阅读10分钟

看完拉勾前端训练营关于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 = null
export default function install (Vue) {
  _Vue = Vue
  _Vue.mixin({
    beforeCreate () {
      if (this.$options.router) {
        this._router = this.$options.router
        this._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.history

    const 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,最后添加routerrouter和route。 完整代码如下:

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

export let _Vue = null
export default function install (Vue) {
  // 判断该插件是否注册略过,可以参考源码
  _Vue = Vue
  // Vue.prototype.xx
  _Vue.mixin({
    beforeCreate () {
      // 给所有 Vue 实例,增加 router 的属性
      // 根实例
      // 以及所有的组件增加 router 属性
      if (this.$options.router) {
        this._router = this.$options.router
        this._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 }
  })
}

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

RouterView

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

export default {
  name: 'RouterView',
  render (h) {

    const route = this.$route
    let depth = 0
    //routerView表示已经完成渲染了
    this.routerView = true
    let parent = this.$parent
    while (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的代码量不多,但实在是绕,简单总结一下比较好。先看一下项目结构:

ProjectStructure

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

文件作用
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的组件