前端进阶之旅前端进阶之旅
基础篇
进阶篇
高频篇
精选篇
手写篇
面经篇
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响应式原理模拟 手写一个迷你版Vue

首页2021-03-28 18:40:12Front-End
Vue响应式原理源码

面试里问「Vue 的响应式原理是什么」,多数人能背出 Object.defineProperty 劫持 getter/setter 这句话。再往下追一层,「那 getter 里到底收集了什么」「Dep 和 Watcher 谁持有谁」「视图为什么会自己更新」,能答清楚的就少了。这些细节光看文章记不住,得自己敲一遍。这篇就是把 Vue 2 的响应式链路拆成五个类手写一遍,从最底层的数据劫持开始,一路搭到指令编译和视图更新,中间穿插 Vue 3 换成 Proxy 之后到底改善了什么。代码不长,跑通之后你对「数据变了页面为什么会动」会有一个能站得住的解释。

在本篇文章中,我们将从浅入深,和大家一起学习以下知识:

  • 数据响应式、双向绑定、数据驱动这三个词各自指什么
  • Vue 2 的 Object.defineProperty 方案,以及它绕不过去的四个局限
  • Vue 3 的 Proxy 方案补上了哪些能力,代价是什么
  • 发布订阅模式和观察者模式的真实区别,不是同一个东西
  • 手写 Vue、Observer、Compiler、Dep、Watcher 五个类,把整条链路跑通
  • 这套模拟实现和真实 Vue 之间还差什么

# 一、数据驱动到底在说什么

数据响应式、双向绑定、数据驱动,这三个词经常被混着用,但它们说的不是一件事。

数据响应式说的是,数据模型仅仅是普通的 JavaScript 对象,而当我们修改数据时,视图会进行更新,避免了繁琐的 DOM 操作,提高开发效率。这是单向的,数据到视图。

双向绑定说的是,数据改变视图改变,视图改变数据也随之改变。我们可以使用 v-model 在表单元素上创建双向数据绑定。它是在响应式的基础上,反向再加了一条从 DOM 事件到数据的通路。

数据驱动是 Vue 最独特的特性之一。开发过程中仅需要关注数据本身,不需要关心数据是如何渲染到视图的。

顺序上,响应式是地基,双向绑定是它的一个应用,数据驱动是使用者的体感。后面手写的时候你会看到,双向绑定无非就是在 v-model 的更新函数里多绑了一个 input 事件监听而已。

# 二、数据响应式的核心原理

# Vue 2.x 的 Object.defineProperty

  • Vue 2.x深入响应式原理
  • MDN - Object.defineProperty
  • 浏览器兼容 IE8 以上(不兼容 IE8)

先用最小的代码把「劫持」这件事演示出来。下面这段模拟了 Vue 实例代理 data 的过程,读 vm.msg 走 getter,写 vm.msg 走 setter,在 setter 里顺手改一下 DOM:

// 模拟 Vue 中的 data 选项 
let data = {
    msg: 'hello'
}
// 模拟 Vue 的实例 
let vm = {}
// 数据劫持:当访问或者设置 vm 中的成员的时候,做一些干预操作
Object.defineProperty(vm, 'msg', {
  // 可枚举(可遍历)
  enumerable: true,
  // 可配置(可以使用 delete 删除,可以通过 defineProperty 重新定义) 
  configurable: true,
  // 当获取值的时候执行 
  get () {
    console.log('get: ', data.msg)
    return data.msg 
  },
  // 当设置值的时候执行 
  set (newValue) {
    console.log('set: ', newValue) 
    if (newValue === data.msg) {
      return
    }
    data.msg = newValue
    // 数据更改,更新 DOM 的值 
    document.querySelector('#app').textContent = data.msg
  } 
})

// 测试
vm.msg = 'Hello World' 
console.log(vm.msg)
@前端进阶之旅: 代码已经复制到剪贴板

跑一下就会发现,vm.msg = 'Hello World' 这一句普通的赋值,顺带把页面也改了。响应式的全部魔法,起点就是这里。

那问题来了,如果有一个对象中多个属性需要转换 getter/setter 如何处理?答案是遍历。Object.keys(data).forEach(...) 挨个定义一遍,遇到属性值还是对象就递归下去。这也是 Vue 2 方案最尴尬的地方之一。

# defineProperty 绕不过去的四个局限

先说结论,Vue 2 里那些「为什么我改了数据页面不动」的经典问题,根子都在这个 API 的能力边界上。

第一,它劫持的是属性,不是对象。所以必须在初始化的时候把 data 递归遍历一遍,对象层级越深、字段越多,初始化开销越大,而且这些开销不管你用不用得上都得先付。

第二,新增和删除属性感知不到。data.user.age = 18 这个 age 如果初始化时不存在,它就没被 defineProperty 处理过,页面不会更新。Vue 2 只能额外提供 Vue.set 和 Vue.delete 这一对补丁 API。

第三,数组的索引赋值和 length 修改也拦不住。arr[0] = 1 和 arr.length = 0 都不会触发更新。Vue 2 的做法是把数组的原型换掉,重写 push、pop、shift、unshift、splice、sort、reverse 这七个会改变原数组的方法,在里面手动触发通知。所以你用这七个方法改数组是响应式的,用下标不是。

第四,Map、Set 这类新的集合类型完全没辙。

这四条不是实现得不好,是 Object.defineProperty 的设计本来就只面向单个属性。要根治,得换一个能代理「整个对象」的东西。

# Vue 3.x 的 Proxy

  • MDN - Proxy
  • 直接监听对象,而非属性
  • ES 6 中新增,IE 不支持,性能由浏览器优化

同样的功能用 Proxy 写是这样:

// 模拟 Vue 中的 data 选项 
let data = {
  msg: 'hello',
  count: 0 
}
// 模拟 Vue 实例
let vm = new Proxy(data, {
  // 当访问 vm 的成员会执行
  get (target, key) {
    console.log('get, key: ', key, target[key])
    return target[key]
  },
  // 当设置 vm 的成员会执行
  set (target, key, newValue) {
    console.log('set, key: ', key, newValue)
    if (target[key] === newValue) {
      return
    }
    target[key] = newValue
    document.querySelector('#app').textContent = target[key]
  }
})

// 测试
vm.msg = 'Hello World'
console.log(vm.msg)
@前端进阶之旅: 代码已经复制到剪贴板

代码量差不多,但能力天差地别。注意 get 和 set 的第二个参数是 key,也就是说这一套拦截器能处理这个对象上的任意属性,包括初始化时不存在的。数组索引、length、delete 操作、in 判断,Proxy 都有对应的拦截钩子,一共十三种。

Vue 3 还在这基础上做了一件很划算的事:惰性代理。reactive 只代理最外层,某个嵌套对象只有在被真正访问到的时候才会被包成 Proxy。初始化不再需要深度遍历,大对象的启动成本一下就降下来了。

代价只有一个,Proxy 是 ES6 特性且无法被 polyfill,所以 Vue 3 不支持 IE11。这也是 Vue 3 敢直接换掉底座的原因,当时 IE 已经可以放弃了。

顺便提一句实现细节的差异。Vue 3 的依赖不再挂在每个属性的闭包里,而是用一个全局的 WeakMap 做三层映射,从 target 找到它的属性表,再从属性名找到对应的依赖集合。用 WeakMap 是为了不阻止对象被垃圾回收。这套结构在 Vue 3 的后续版本里还被优化过几轮,具体实现以官方仓库为准。

另外,Vue 3 里 setter 必须返回 true,否则严格模式下会抛 TypeError。上面这段模拟代码为了突出主线省掉了这一点,真写业务代码时别照抄。

# 三、发布订阅模式和观察者模式

手写之前得先把这两个模式分清楚,因为后面 Dep 和 Watcher 的关系正是观察者模式,而 Vue 的自定义事件是发布订阅。很多文章把它们当成同一个,其实不是。

# 发布订阅模式

发布订阅模式有三个角色:

  • 订阅者
  • 发布者
  • 信号中心

我们假定存在一个「信号中心」,某个任务执行完成,就向信号中心「发布」(publish)一个信号,其他任务可以向信号中心「订阅」(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做发布订阅模式。

Vue 的自定义事件就是这个模式:

let vm = new Vue()
vm.$on('dataChange', () => { console.log('dataChange')})
vm.$on('dataChange', () => { 
  console.log('dataChange1')
}) 
vm.$emit('dataChange')
@前端进阶之旅: 代码已经复制到剪贴板

兄弟组件通信过程也是同一套东西,中间那个 eventHub 就是信号中心:

// eventBus.js
// 事件中心
let eventHub = new Vue()

// ComponentA.vue
// 发布者
addTodo: function () {
  // 发布消息(事件)
  eventHub.$emit('add-todo', { text: this.newTodoText }) 
  this.newTodoText = ''
}
// ComponentB.vue
// 订阅者
created: function () {
  // 订阅消息(事件)
  eventHub.$on('add-todo', this.addTodo)
}
@前端进阶之旅: 代码已经复制到剪贴板

把这个信号中心自己实现一遍,也就三十行。核心是一个 { 事件名: [回调数组] } 的字典:

class EventEmitter {
  constructor(){
    // { eventType: [ handler1, handler2 ] }
    this.subs = {}
  }
  // 订阅通知
  $on(eventType, fn) {
    this.subs[eventType] = this.subs[eventType] || []
    this.subs[eventType].push(fn)
  }
  // 发布通知
  $emit(eventType) {
    if(this.subs[eventType]) {
      this.subs[eventType].forEach(v=>v())
    }
  }
}

// 测试
var bus = new EventEmitter()

// 注册事件
bus.$on('click', function () {
  console.log('click')
})

bus.$on('click', function () {
  console.log('click1')
})

// 触发事件 
bus.$emit('click')
@前端进阶之旅: 代码已经复制到剪贴板

要提一句,Vue 3 已经把实例上的 $on / $off / $emit 这套 API 移除了,事件总线的写法需要换成 mitt 这类第三方库,或者就用上面这个手写版本。

# 观察者模式

观察者模式里只有两个角色,没有中间人:

fe
  • 一、数据驱动到底在说什么
  • 二、数据响应式的核心原理
    • Vue 2.x 的 Object.defineProperty
    • defineProperty 绕不过去的四个局限
    • Vue 3.x 的 Proxy
  • 三、发布订阅模式和观察者模式
    • 发布订阅模式
    • 观察者模式
    • 两者的区别
  • 四、动手实现一个迷你Vue
    • 整体分析
    • Vue
    • Observer
    • Compiler
    • Dep(Dependency)
    • Watcher
    • 视图变化更新数据
  • 五、整体回顾
    • 这套模拟和真实 Vue 还差什么
  • 总结
  • 参考

← 虚拟DOM原理分析 Snabbdom源码与diff算法拆解Vue Router与Vuex原理解析及手写实现 →