面试里问「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 这类第三方库,或者就用上面这个手写版本。
# 观察者模式
观察者模式里只有两个角色,没有中间人: