前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
AI 篇
原理篇
每日一题
小程序题库
知识卡片
  • 场景篇按分类整理的大前端场景考点
  • 历年面经按年份追踪真实考点
  • 算法题库NEW在线编码即时判题
  • 专项自测100 题快速查漏
  • 前端基础
    • HTTP从报文一路讲到 HTTPS
    • 浏览器渲染、事件循环、进程
    • 计算机基础Linux、网络、操作系统
  • 进阶专项
    • 设计模式23 种模式怎么用
    • 前端系统进阶学习大型项目工程化
    • 前端综合文章长期沉淀的实践文
  • 工程与工具
    • Node学习指南从环境搭建到服务端
    • NPM工作流script、依赖与发布
    • Docker容器化部署上手
    • Canvas图形与动画实战
  • 路线与导图
    • 思维导图知识点全景图
    • 学习路线按图索骥不跑偏
  • 动态
    • 公众号动态公众号历史文章
    • 博客动态站长的技术博客
    • 开发者导航常用工具与文档站
  • AI 助手随时提问,即时解析
  • AI 模拟面试模拟真实面试 + 报告
  • AI 知识地图串起全站知识点
  • AI 定制路线按你的简历现排
AI 热点
旧版

Vue Router与Vuex原理解析及手写实现

首页2021-03-13 15:12:12Front-End
Vuevue-routerVuex源码

面试里问「说说 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 函数而不是模板,是因为这是一个库,不能假设用户的构建环境带了模板编译器。

fe
  • 一、vue-router 的接入四步
  • 二、Vue.use 到底做了什么
  • 三、hash 模式和 history 模式
  • 四、手写 vue-router
    • 4.1 VueRouter 类
    • 4.2 install 方法与混入挂载
    • 4.3 两个全局组件
    • 4.4 这个精简版还差什么
  • 五、Vuex 的核心概念
  • 六、手写 vuex
    • 6.1 借鸡生蛋的响应式 state
    • 6.2 state 的只读保护与 commit、dispatch
    • 6.3 install 与为什么 mutation 必须同步
  • 七、这两个库现在长什么样
  • 总结
  • 参考

← Vue响应式原理模拟 手写一个迷你版VueVue组件化实践详解 九种组件通信方式的取舍 →