面试里问「说说 vue-router 的原理」,很多人会答监听 hashchange。这个答案不能算错,但只答到了一半。真正让页面在 URL 变化时重新渲染的,不是那个事件监听,而是一个被做成响应式的变量。
Vuex 也一样。「集中式状态管理」这句定义谁都会背,但为什么 mutation 必须是同步的、state 的响应式是怎么来的、commit 和 dispatch 的区别到底在哪,这些才是能拉开差距的地方。
这篇把 vue-router 和 vuex 各手写一遍,代码不长,每一段都拆开讲清楚它在解决什么问题。两个库的实现思路其实高度一致,看完一个再看另一个会顺很多。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- vue-router 接入的四个步骤分别做了什么
- Vue 的插件机制,
Vue.use背后发生了什么 - hash 模式和 history 模式监听的事件不同,部署要求也不同
- 手写 vue-router:install 混入、响应式 current、两个全局组件
- 精简版实现和真实的 vue-router 差在哪些地方
- Vuex 的四个核心概念以及各自的定位
- 手写 vuex:借 Vue 实例实现响应式 state
commit与dispatch的实现差异,以及 mutation 为什么必须同步
# 一、vue-router 的接入四步
Vue Router 是 Vue.js 官方的路由管理器。它和 Vue.js 的核心深度集成,让构建单页面应用变得容易很多。
安装用 vue add router,接入分成四个步骤。
第一步,注册插件。
import Router from 'vue-router'
Vue.use(Router)
第二步,创建 Router 实例,在 router.js 里。
export default new Router({...})
第三步,在根组件上挂载这个实例,在 main.js 里。
import router from './router'
new Vue({
router,
}).$mount("#app");
第四步,在 App.vue 里添加路由视图。
<router-view></router-view>
导航用 router-link:
<router-link to="/">Home</router-link>
<router-link to="/about">About</router-link>
这四步看着是流程,其实每一步都对应实现里的一块逻辑。第一步触发 install,第二步构造实例并开始监听 URL,第三步通过混入把实例挂到每个组件上,第四步用两个全局组件消费前面的成果。
把这个对应关系记住,后面看实现会轻松很多。
# 二、Vue.use 到底做了什么
Vue.use 的逻辑非常简单,它接收一个插件对象,调用这个对象上的 install 方法,并把 Vue 构造函数作为第一个参数传进去。同一个插件重复 use 只会执行一次。
为什么要把 Vue 传进去,而不是让插件自己 import Vue?
因为插件如果自己 import,打包时 Vue 会被打进插件的产物里,用户的项目里就有两份 Vue 代码,两套响应式系统互不相认,出的 bug 极其诡异。把构造函数当参数传进来,插件就可以声明 Vue 为 peerDependencies,永远用宿主项目那一份。
这个设计在几乎所有 Vue 生态的库里都能看到,理解它比记住 Vue.use 的用法重要得多。
# 三、hash 模式和 history 模式
vue-router 支持两种模式,实现方式和部署要求都不一样。
| 维度 | hash 模式 | history 模式 |
|---|---|---|
| URL 形态 | example.com/#/about |
example.com/about |
| 监听事件 | hashchange |
popstate |
| 跳转 API | 改 location.hash |
history.pushState |
| 是否发请求 | 不会,# 后的内容不发给服务器 |
直接访问会真的请求这个路径 |
| 服务器配置 | 不需要 | 必须配置回退 |
history 模式那个「必须配置回退」是踩坑重灾区。前端跳转走的是 pushState,压根没发请求所以一切正常;但用户在 /about 上按一下刷新,浏览器会老老实实向服务器要这个路径,服务器上没有这个文件,直接 404。
解决办法是让服务器在找不到文件时统一返回 index.html。Nginx 上就是那行 try_files $uri $uri/ /index.html;,具体可以看 Nginx try_files 指令详解。
下面的手写实现走的是 hash 模式,因为它不需要任何服务端配合,本地打开就能跑。
# 四、手写 vue-router
先把要做的事列清楚:
- 作为一个插件存在,实现
VueRouter类和install方法 - 实现两个全局组件,
router-view用于显示匹配的组件内容,router-link用于跳转 - 监控 URL 变化,监听
hashchange或popstate事件 - 响应最新 URL,创建一个响应式的属性
current,当它改变时获取对应组件并显示
第四条是整个实现的胜负手,下面会重点讲。
# 4.1 VueRouter 类
创建 kvue-router.js,先写类本身。
// 我们的插件:
// 1. 实现一个 Router 类并挂载其实例
// 2. 实现两个全局组件 router-link 和 router-view
let Vue;
class VueRouter {
constructor(options) {
this.$options = options;
// 缓存 path 和 route 的映射关系,这样找组件更快
this.routeMap = {}
this.$options.routes.forEach(route => {
this.routeMap[route.path] = route
})
// 数据响应式
// 定义一个响应式的 current,它变了,使用它的组件会 rerender
Vue.util.defineReactive(this, 'current', '')
// 请确保 onHashChange 中 this 指向当前实例
window.addEventListener('hashchange', this.onHashChange.bind(this))
window.addEventListener('load', this.onHashChange.bind(this))
}
onHashChange() {
this.current = window.location.hash.slice(1) || '/'
}
}
这段里有三处值得单独说。
routeMap 是一个空间换时间的优化。路由表是一个数组,每次匹配都遍历一遍,路由多了就是线性开销。转成以 path 为 key 的对象之后,匹配变成一次哈希查找。真实的 vue-router 因为要支持动态路由和嵌套路由,做的是路径解析加正则匹配,比这复杂得多,但缓存映射这个思路是一样的。
Vue.util.defineReactive 是整个实现的核心。它把 current 变成一个带 getter/setter 的响应式属性。router-view 的 render 函数里读了 current,就完成了依赖收集;onHashChange 里给 current 赋值,setter 触发通知,router-view 自动重新渲染。
没有这一行,你监听到 hashchange 之后只能手动去操作 DOM,那就不是 Vue 的玩法了。顺带提一句,Vue.util 属于内部 API,官方文档里没有承诺稳定,教学用没问题,生产代码里别依赖它。
第三处是 bind(this)。事件回调里的 this 默认指向触发事件的对象(这里是 window),不绑定的话 this.current 会直接报错。同时监听 load 是为了处理首次进入页面的情况,此时不会触发 hashchange,需要主动初始化一次。
# 4.2 install 方法与混入挂载
// 插件需要实现 install 方法
// 接收 Vue 构造函数,主要用于数据响应式
VueRouter.install = function (_Vue) {
// 保存 Vue 构造函数,供 VueRouter 内部使用
Vue = _Vue
// 任务 1:使用混入来做 router 挂载这件事
Vue.mixin({
beforeCreate() {
// 只有根实例才有 router 选项
if (this.$options.router) {
Vue.prototype.$router = this.$options.router
}
}
})
}
这里为什么要用 mixin 而不是直接赋值?
因为 install 执行的时候,用户还没有创建 router 实例,拿不到它。Vue.mixin 注册的 beforeCreate 会在每个组件初始化时执行,等根实例创建的那一刻,this.$options.router 才真正存在。用 beforeCreate 而不是 created,是为了保证在任何组件用到 $router 之前就挂好。
判断 this.$options.router 是为了只在根实例上执行一次,子组件的 $options 上没有这个字段。挂到 Vue.prototype 上之后,所有组件实例都能通过原型链访问到 this.$router。
# 4.3 两个全局组件
// router-link: 生成一个 a 标签,在 url 后面添加 #
// <router-link to="/about">aaa</router-link> 渲染成 <a href="#/about">aaa</a>
Vue.component('router-link', {
props: {
to: {
type: String,
required: true
},
},
render(h) {
// h(tag, props, children)
return h('a',
{ attrs: { href: '#' + this.to } },
this.$slots.default
)
}
})
Vue.component('router-view', {
render(h) {
// 根据 current 获取组件并 render
let component = null
const { routeMap, current } = this.$router
if (routeMap[current]) {
component = routeMap[current].component
}
return h(component)
}
})
最后别忘了导出:
export default VueRouter
router-link 就是一个 a 标签加上 # 前缀,点击后浏览器改变 hash,触发 hashchange,链路闭上了。用 render 函数而不是模板,是因为这是一个库,不能假设用户的构建环境带了模板编译器。