前端造轮子【1】 —— vue-router

577 阅读3分钟

今天我们来研究一下 vue-router 的内部实现原理。

我们知道,vue app 是 spa,是没有页面之间的跳转的,说的直白一点就是没有刷新。那么在不刷新页面的情况下,怎么实现一个项目中,不同页面,不同组件之间的切换展示呢?

通过学习 vue-router 的源码,可以得知,浏览器为我们提供了两个方案:

  • hash(就是页面上的 #)
  • history(h5)

今天我们就简单来实现一下通过 hash 的方式实现 vue 的路由跳转。

Step 1 - 需求分析

首先通过 vue-cli 4.x 创建项目,选择使用 router,并且模式选择为 hash。

项目创建完成后,我们可以看到 src 目录下有一个 router 文件,其内容大致如下:

import Vue from 'vue'
import VueRouter from './yvue-router'
import Home from '../views/Home.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/',
    name: 'Home',
    component: Home,
  },
  {
    path: '/about',
    name: 'About',
    component: () => import('../views/About.vue'),
  },
]

const router = new VueRouter({
  routes,
})

export default router

我们逐步来分析一下上面的代码:

  • 首先是用过 Vue.use(VueRouter) 注册了插件,那么显然 VueRouter 是 Vue 的插件,需要有自己的静态 install 方法
  • 接着是我们熟悉的,配置了路有映射表,用于确定路由和组件的关系
  • 然后通过 new VueRouter({routers}) 创建了路由实例,并且将其导出
  • 最后熟悉 vue 的同学肯定知道,导出的这个 vue,我们在 main 中将其引用并且传递给了 Vue 本身

上述流程非常简单,那么从 VueRouter 的角度出发,我们应该做些什么呢?

首先这里 Vue.use 的时候会调用 VueRouter.install 方法,而在这个方法里我们应该做些什么呢?

联合 vue-router 的使用我们知道,注册 router 之后,我们可以全局使用两个组件:

  • router-view:用于显示组件
  • router-link:用于路由跳转

显然,这两个组件的注册过程是在 install 中,因为这里拥有对全局 Vue 的引用。

接下来,就是 router 的核心功能,当 hash 改变的时候,改变展示的组件,显然这里我们应该有一个响应式的 current,当 current 改变的时候,就触发 render 重新渲染组件。

以上,我们可以大致总结一下,如下图所示:

Step 2 - 功能实现

那么现在我们可以着手开始实现自己的 vue-router 了。

首先我们肯定需要一个 VueRouter 的类,应为在后面它需要被实例化:

class VueRouter {
  // 对应 Vue.use
  static install(Vue) {}

  // 对应 new
  constructor(options) {}
}

紧接着我们就可以在 install 中实现对两个全局组件的注册:

class VueRouter {
	// 对应 Vue.use
	static install(Vue){
		// step 1: 注册组件 router-link,用于路由跳转
		// 回忆一下 router-link 的使用:
		// * <router-link to="/login">login</router-link>
		Vue.component('router-link', {
      // 必要参数 to
      props: {
        to: {
          type: String,
          require: true,
        },
      },
      // 模板,可以使用 template,也可以使用 render。
      // * render 更加灵活,并且在 vue 中会把 template 编译成 render 函数。
      render() {
        // 这里可以写 jsx,也可以使用 h 函数
        // 这里我们使用 jsx,在 view-router 中使用 h,都尝试一下
        // * h 函数类似于 react 中的 createElement
        return <a href={`#${this.to}`}>{this.$slots.default}</a>
      }

			// step 2: 注册组件 router-view,用于显示组件
			// 回忆一下 router-link 的使用:
			// <router-view />
			Vue.component('router-view', {
      // 没有参数,直接 template
      render(h) {
        // 这里使用 h
        // view-router 的功能是渲染组件,那么组件从哪里获取呢?
        // 回忆 router.js 的路由表,其中有映射 url 地址和组件,所以组件显然是从这里获取
        // 而当前路由我们可以在构造函数中定义一个 current 来表示
        // 所以可以通过以下代码找到当前 current 对应的组件
        const { routeMap, current } = this.$router
        const component = routeMap[current] ? routeMap[current].component : null
        return h(component)
      },
    })
	}
	/*...*/
}

到这里,至少我们的 已经是可用的了,而 则需要我们在构造函数中实现对路由表的管理以及对 hashchange 的监听。

那么接下来自然是实现 constructor

class VueRouter {
  /*...*/

  // 对应 new
  constructor(options) {
    // 初始化路由表
    this.routerMap = {}
    options.routes.map((route) => {
      this.routerMap[route.path] = route
    })

    // 定义 current
    const current = window.location.hash.slice(1) || '/'

    // 监听 hashchange
    window.addEventListener('hashchange', this.onHashChange.bind(this))
  }

  onHashChange() {
    this.current = window.location.hash.slice(1)
  }
}

到这里,整个 vue-router 已经实现得差不多了,但还存在这一个巨大的问题,那就是 current 并不是响应式的,当路由切换,current 改变的时候,并不会触发 render 重新渲染组件:

那应该怎么办呢?

接下来的问题是:怎么将 current 转成 vue 响应式数据?

对于上述问题,这里有两个思路:

  • 新建 vue 实例,通过 data 将 current 转成响应式数据
  • 利用 vue 提供的静态方法 Vue.util.defineReactive

这里我们采取第二种方案:

class VueRouter {
  /*...*/

  // 对应 new
  constructor(options) {
    // 初始化路由表
    this.routerMap = {}
    options.routes.map((route) => {
      this.routerMap[route.path] = route
    })

    // 定义 current
    const initial = window.location.hash.slice(1) || '/'
    Vue.util.defineReactive(this, 'current', initial)

    // 监听 hashchange
    window.addEventListener('hashchange', this.onHashChange.bind(this))
  }

  onHashChange() {
    this.current = window.location.hash.slice(1)
  }
}

到这里又出现一个问题,这个 Vue 从哪里来呢?

其实这里需要让思维跳出 constructor:我们知道执行 install 的时候会传入 Vue,那么在 install 的时候我们保存一个全局变量 _Vue 即可在 constructor 中进行使用了:

let _Vue

class VueRouter {
  /*...*/

  // 对应 new
  constructor(options) {
    // 初始化路由表
    this.routerMap = {}
    options.routes.map((route) => {
      this.routerMap[route.path] = route
    })

    // 定义 current
    const initial = window.location.hash.slice(1) || '/'
    _Vue.util.defineReactive(this, 'current', initial)

    // 监听 hashchange
    window.addEventListener('hashchange', this.onHashChange.bind(this))
  }

  onHashChange() {
    this.current = window.location.hash.slice(1)
  }
}

到这里,基本的路由已经实现了:

还有一些可以扩展的地方:

比如优化一下 constructor,将 options 保存到本地:

constructor(options){
	this.$options = options
	// 初始化路由表
	this.routerMap = {}
	this.$options.routes.map((route)=>{
		this.routerMap[route.path] = route
	})

	// 定义 current
	const initial = window.location.hash.slice(1) || '/'
  _Vue.util.defineReactive(this, 'current', initial)

	// 监听 hashchange
	window.addEventListener('hashchange', this.onHashChange.bind(this))
}

这可以保证数据的单向流动。

比如为组件实例挂载 router,这样我们就可以像this.router,这样我们就可以像 this.router.push() 这样使用 router 上的方法了。

不过这里有一个点值得注意,那就是时间:

因为当我们执行 install 的时候,VueRouter 的实例还没创建,那么直接通过:

Vue.prototype.$router = this.$options.router

进行挂载显然是不行的。

这里我们可以借助 mixin 来使用 Vue 的生命周期钩子,当组件创建之前进行挂载操作:

Vue.mixin({
  beforeCreate() {
    if (this.$options.router) {
      Vue.prototype.$router = this.$options.router
    }
  },
})

这样就没什么问题了。

结语

更佳阅读体验:手写简易 vue-router