Vue3响应式剖析

改了好几版,都是干货了,废话不说,开冲!!!
2.x和3.x 响应式差异
Vue2的响应式是基于Object.defineProperty的。但它只对初始对象的属性有监听作用,而对新增的属性无效。
尤大也知道有这个缺陷,提供了$set来帮助我们达到响应式。升级了的响应式机制改为用Proxy 外号Vue3。
1.1 proxy比较好的点有:
Object.defineProperty 和 `Proxy` 本质差别是,defineProperty 只能对属性进行劫
持,所以出现了需要递归遍历,新增属性需要手动 `Observe` 的问题。
Vue3.x 通过 Proxy 代理目标**对象**,且一开始只代理最外层对象,嵌套对象lazy by
default(惰性监听) ,性能会更好( Proxy可以直接监听对象而非属性)。
数据响应式系统全语言特性支持,添加数组索引修改监听,对象的属性增加和删除。(Proxy可以直
接监听数组的变化)。
Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等是
Object.defineProperty不具备的。
Proxy搭配Reflect主要是为了语义化,而且方法都一一对应。
其实响应式的原理根据 Data 变化更新 View。
内部实现了四个步骤分别是:
实现一个**监听器** `Observer` ,用来劫持并监听所有属性,如果属性发生变化,就通知订阅者;
实现一个**订阅器** `Dep`,用来收集订阅者,对监听器 `Observer` 和 订阅者 `Watcher`
进行统一管理;
实现一个**订阅者** `Watcher`,可以收到属性的变化通知并执行相应的方法,从而更新视图;
实现一个**解析器** `Compile`,可以解析每个节点的相关指令,对模板数据和订阅器进行初始化。
vue在开发中最酸爽的就是修改数据,模版更新,简称:数据双向绑定,接下来我们先来一手抛砖引玉看对方上不上套。
- 浅带一下Vue2的响应式原理,以及其不足之处。
- Vue3如何解决Vue2的痛点?
- 扩展:阐述MVVM响应式原理 && 以及自己对响应式的理解。
- 最后聊到响应式,可以再聊一下,其实v-model也绑定一个响应式数据到视图的操作。
说到这儿估计对方也来了兴趣,一般情况下都会和你深度 “掰头“ 一下。
Vue2的响应式原理,以及其不足之处
2.1 大家都知道vue2的响应式是基于Object.defineProperty。
知己知彼方能干他,上才艺,举个栗子进一步深入的了解。
我们举个Object.defineProperty的一个例子,更深入了解其弊端。
// 响应式函数
function reactive(obj, key, value) {
Object.defineProperty(obj, key, {
get() {
console.log(`输入${key}属性`)
return value
},
set(val) {
console.log(`${key}由->${value}->设置成->${val}`)
if (value !== val) value = val
}
})
}
const data = { name: 'Tony' }
Object.keys(data).forEach(key => reactive(data, key, data[key]))
console.log(data.name)
// 输入name属性
// Tony
data.name = 'Dignity' // name由->Tony->设置成->Dignity
console.log(data.name)
// 输入name属性
// Dignity
可以看到,每次获取值都触发get方法,修改值则触发set方法。
那么,他有何缺陷呢?
// 接上
data.do = '整个卡布奇诺'
console.log(data.do) // 卡布奇诺
data新加do属性,访问、设置值,都不会触发get和set方法。
所以缺陷是:Object.defineProperty只对初始对象的属性有监听作用,而对新增的属性无效。
尤大也知道有这个缺陷,为了让开发者能更好的响应式,对象新增的属性修改能有响应式,提供了$set来帮助我们达到响应式。

2.2 Object.defineProperty能监控到数组下标的变化嘛?
我的一个朋友第一反应是当然监听不到了,要不你问我这个干啥,当然我们也可以主动出击。
其实原本 Object.defineProperty是支持监听到数组下标的,只不过从性能 / 体验的性价比考虑,vue2.x主动放弃了这个特性。
我这个朋友刚开始也是一脸懵,上才艺,我们举个栗子🌰
function defineReactive(data, key, value) {
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function defineGet() {
console.log(`get key: ${key} value: ${value}`)
return value
},
set: function defineSet(newVal) {
console.log(`set key: ${key} value: ${newVal}`)
value = newVal
}
})
}
function observe(data) {
Object.keys(data).forEach(function(key) {
defineReactive(data, key, data[key])
})
}
let arr = [1, 2, 3]
observe(arr)
通过下标获取某个元素和修改某个元素的值。
可以看到,通过下标获取某个元素会触发 getter 方法, 设置某个值会触发 setter 方法。
接下来,我们再试一下数组的一些操作方法,看看是否会触发
数组的 push 方法
push 并未触发 setter 和 getter方法,数组的下标可以看做是对象中的 key ,这里 push 之后相当于增加了下索引为 3 的元素,但是并未对新的下标进行 observe ,所以不会触发。
我们的结论是:Object.defineProperty是有监控数组下标变化的能力的。 push是新增,我们的监控是原有数组下标的变化。所以push不会触发get、set。
数组的 unshift 方法


这是为啥?
unshift 往数组前面添加操作会导致原来索引为 0、1、2、3 的值发生变化,这就需要将原来索引为 0、1、2、3 的值取出来,然后重新赋值,所以取值的过程触发了 getter ,赋值时触发了 setter。
整个解析过程是这样子的:
结合栗子🌰我们来分析一哈
原数组:[1, 2, 3]
1的索引是0;
2的索引是1;
3的索引是2;
push添加了4,他的索引是3;
unshift往头添加0;
原本的索引0、1、2、3 的值发生变化,因为第一位的索引变了,影响第二个,类推
这就需要将原来索引为 0、1、2、3 的值取出来,然后重新赋值,期间触发get、set
因为unshift头部添加了0,所以0的索引是0; -> key: 0, value:0
原本1的索引从0变成了1 -> key: 1, value:1
原本2的索引从1变成了2 -> key: 2, value:2
我们可以看到符合上面的图set的输出。
至于上图get的输出,是将值取出来触发。
至于数组3的值怎么没有打印出来,因为Object.defineProperty监控是原有数组下标的变化。后面下标的变化与我无关。
下面我们尝试通过索引获取一下对应的元素:
只有索引为 0、1、2 的属性才会触发 getter 。
这里我们可以对比对象来看,arr 数组初始值为 [1, 2, 3],即只对索引为 0,1,2 执行了 observe 方法,所以无论后来数组的长度发生怎样的变化,依然只有索引为 0、1、2 的元素发生变化才会触发。其他的新增索引,就相当于对象中新增的属性,需要再手动 observe 才可以。
数组的 pop 方法
当移除的元素为引用为 2 的元素时,会触发 getter 。
删除了索引为 2 的元素后,再去修改或获取它的值时,不会再触发 setter 和 getter 。

这和对象的处理是同样的,数组的索引被删除后,就相当于对象的属性被删除一样,不会再去触发 observe。
小结
我朋友听了恍然大悟,所以Object.defineProperty 是有监控数组下标变化的能力的,只是 Vue2.x 放弃了这个特性
- 通过索引访问或设置对应元素的值时,可以触发 getter 和 setter 方法。
- 通过 push 或 unshift 会增加索引,对于新增加的属性,需要再手动初始化才能被 observe。
- 通过 pop 或 shift 删除元素,会删除并更新索引,也会触发 setter 和 getter 方法。
Object.defineProperty 在数组中的表现和在对象中的表现是一致的,数组的索引就可以看做是对象中的 key。
来铁铁继续往下走。
Vue3是如何解决vue2的不足?
为啥Vue3的性能更好,是怎么解决无法监听数组变化问题!
我们可以从以下几点回答:
- Vue2.x 通过给每个对象添加
getter setter属性去改变对象,实现对数据的观测; - Object.defineProperty 和
Proxy本质差别是,defineProperty 只能对属性进行劫持,所以出现了需要递归遍历,新增属性需要手动Observe的问题。 - Vue3.x 通过 Proxy 代理目标对象,且一开始只代理最外层对象,嵌套对象lazy by default(惰性监听) ,性能会更好( Proxy可以直接监听对象而非属性)
- 数据响应式系统全语言特性支持,添加数组索引修改监听,对象的属性增加和删除。(Proxy可以直接监听数组的变化)
- Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等是Object.defineProperty不具备的。
3.1 Proxy只会代理对象的第一层,那么Vue3怎么对深层级进行监听呢?
判断当前Reflect.get的返回值是否为Object,如果是则再通过reactive方法做代理, 这样就实现了深度观测。
3.2 监测数组的时候可能触发多次get/set,那么如何防止触发多次呢?
我们可以判断key是否为当前被代理对象target自身属性,也可以判断旧值与新值是否相等,只有满足以上两个条件之一时,才有可能执行触发。
3.3 Proxy属性讲解
const person = { name: 'Tony' }
const proxyPerson = new Proxy(person, {
get(target, key, receiver) {
console.log(target) // 原来的person
console.log(key) // 属性名
console.log(receiver) // 代理后的proxyPerson
},
set(target, key, value, receiver) {
console.log(target) // 原来的person
console.log(key) // 属性名
console.log(value) // 设置的值
console.log(receiver) // 代理后的proxyPerson
}
})
proxyPerson.name // 访问属性触发get方法
proxyPerson.name = 'Dignity' // 设置属性值触发set方法
其实Proxy是搭配Reflect的。
来个例子🌰,感受下Proxy的强大吧。
const data = { name: 'Tony' }
function reactive(target) {
const handler = {
get(target, key, receiver) {
console.log(`访问了${key}属性`)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log(`${key}由->${target[key]}->设置成->${value}`)
Reflect.set(target, key, value, receiver)
}
}
return new Proxy(target, handler)
}
const proxyData = reactive(data)
console.log(proxyData.name)
// 访问了name属性
// Tony
proxyData.name = 'Dignity'
// name由->Tony->设置成->Dignity
console.log(proxyData.name)
// 访问了name属性
// Dignity
关键的来了,那就是对象新增属性,来看看效果吧:
proxyData.do = '整个卡布奇诺尝尝'
console.log(proxyData.do)
// 访问了do属性
// 打羽毛球
proxyData.do = '苦咖啡'
// do由->整个卡布奇诺尝尝->设置成->苦咖啡
console.log(proxyData.do)
// 访问了do属性
// 苦咖啡
可以看到,Proxy对新增的do属性也进行监听管理,一视同仁。
而Object.defineProperty新来的不管,管不了那么多。
3.4 Reflect属性讲解
在这列举Reflect的两个方法:
- get(target, key, receiver):访问target的key属性,但是this是指向receiver,所以实际是访问的值是receiver的key的值,但是这可不是直接访问receiver[key]属性
- set(target, key, value, receiver):设置target的key属性为value 上面提到,不能直接receiver[key]或者receiver[key] = value,而是要通过Reflect.get和Reflect.set,绕个弯去访问属性或者设置属性,这是为啥呢?下面咱们举个反例
const person = { name: 'Tony' }
const proxyPerson = new Proxy(person, {
get(target, key, receiver) {
return Reflect.get(receiver, key) // 相当于 receiver[key]
},
set(target, key, value, receiver) {
Reflect.set(receiver, key, value) // 相当于 receiver[key] = value
}
})
console.log(proxyPerson.name)
proxyPerson.name = 'Dignity'
// 会直接报错,栈内存溢出 Maximum call stack size exceeded
因为上面的get,返回Reflect.get(receiver, key)相当于receiver[key],又触发到get方法,所以直接就死循环报错了。
Proxy搭配Reflect主要是为了语义化,而且方法都一一对应。
- Proxy的get对应Reflect.get
- Proxy的set对应Reflect.set
为啥尽量把this放在receiver上,而不放在target上?
因为原对象target有可能本来也是是另一个代理的代理对象,所以如果this一直放target上的话,出bug的概率会大大提高。
其实到这儿,相亲对象已经有点动心了,咱再添把干柴火。
阐述MVVM响应式原理
4.1 原理
Vue 内部通过 Object.defineProperty方法属性拦截的方式,把data 对象里每个数据的读写转化成 getter/setter,当数据变化时通知视图更新。
那他是怎么进行依赖收集的呢?
其内部定义了一个依赖收集器叫Dep。
- vue将
data初始化为一个Observer并对对象中的每个值,重写了其中的get、set,data中的每个key,都有一个独立的依赖收集器。 - 在
get中,向依赖收集器添加了监听。 - 在
mount时,实例了一个Watcher,将收集器的目标指向了当前Watcher。 - 在
data值发生变更时,触发set,触发了依赖收集器中的所有监听的更新,来触发Watcher.update。
4.2 MVVM
MVVM 数据双向绑定主要是指:数据变化更新视图,视图变化更新数据。
即:
- 输入框内容变化时,
Data中的数据同步变化。即View => Data的变化。(通过事件监听的方式实现) Data中的数据变化时,文本节点的内容同步变化。即Data => View的变化。
本文主要讨论如何根据 Data 变化更新 View。
我们会通过实现以下 4 个步骤,来实现数据的双向绑定:
- 实现一个监听器
Observer,用来劫持并监听所有属性,如果属性发生变化,就通知订阅者; - 实现一个订阅器
Dep,用来收集订阅者,对监听器Observer和 订阅者Watcher进行统一管理; - 实现一个订阅者
Watcher,可以收到属性的变化通知并执行相应的方法,从而更新视图; - 实现一个解析器
Compile,可以解析每个节点的相关指令,对模板数据和订阅器进行初始化。

下面让我们详细讲解一下监听器Observer、订阅器Dep、订阅者Watcher、解析器Compile分别做了什么。
4.3 监听器Observer
监听器 Observer 的实现,主要是指让数据对象变得“可观测”,即每次数据读或写时,我们能感知到数据被读取了或数据被改写了。要使数据变得“可观测”,Vue 2.0 源码中用到 Object.defineProperty() 来劫持各个数据属性的 setter / getter。
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。
/**
* 循环遍历数据对象的每个属性
*/
function observable(obj) {
if (!obj || typeof obj !== 'object') {
return;
}
let keys = Object.keys(obj);
keys.forEach((key) => {
defineReactive(obj, key, obj[key])
})
return obj;
}
/**
* 将对象的属性用 Object.defineProperty() 进行设置
*/
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
console.log(`${key}属性被读取了...`);
return val;
},
set(newVal) {
console.log(`${key}属性被修改了...`);
val = newVal;
}
})
}
4.4 订阅器 Dep
发布-订阅模式又叫观察者模式,它定义对象间的一种一对多的依赖关系,当一个对象的状态改变时,所有依赖于它的对象都将得到通知。
我们需要先将所有依赖收集起来,一旦数据发生变化,就统一通知更新。其实,这就是前一节所说的“发布订阅者”模式,数据变化为“发布者”,依赖对象为“订阅者”。
相亲对象问,你知道哪些设计模式,Vue中有用到哪些设计模式? 我们可以回答: Vue在响应式内,使用了发布订阅模式也就是观察者模式,用到这个模式主要是想将所有依赖收集起来,作为订阅者,数据变化就通知所有依赖者,数据变化这个过程称为发布者。
现在,我们需要创建一个依赖收集容器,也就是消息订阅器 Dep,用来容纳所有的“订阅者”。订阅器 Dep 主要负责收集订阅者,然后当数据变化的时候后执行对应订阅者的更新函数。
创建消息订阅器 Dep:
function Dep () {
this.subs = [];
}
Dep.prototype = {
addSub: function(sub) {
this.subs.push(sub);
},
notify: function() {
this.subs.forEach(function(sub) {
sub.update();
});
}
};
Dep.target = null; // 留下伏笔,下面回答为什么要设为null
有了订阅器,我们再将 defineReactive 函数(上面【4.3 监听器Observer】的监听器一个方法)进行改造一下,向其植入订阅器:
defineReactive: function(data, key, val) {
var dep = new Dep();
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get: function getter () {
if (Dep.target) {
dep.addSub(Dep.target);
}
return val;
},
set: function setter (newVal) {
if (newVal === val) {
return;
}
val = newVal;
dep.notify();
}
});
}
从代码上看,我们设计了一个订阅器 Dep 类,该类里面定义了一些属性和方法,这里需要特别注意的是它有一个静态属性 Dep.target,这是一个全局唯一 的Watcher,因为在同一时间只能有一个全局的 Watcher被计算,另外它的自身属性 subs 也是 Watcher 的数组。(请结合以上代码观看理解)
为什么在订阅器 Dep类后面还要设置Dep.target = null?
订阅者watchs会先将自身的this赋值给Dep.target,这样就会走进get往dep添加sub,即dep.addSub()
想要添加到订阅器中,后面添加完记得将Dep.target设置为null,不然每次都会走进get往dep添加sub(因为Dep.target有值会进入)
懂了吧,就是怕重复push。

4.5 订阅者 Watcher
订阅者 Watcher 在初始化的时候需要将自己添加进订阅器 Dep 中,那该如何添加呢?
我们已经知道监听器Observer 是在 get 函数执行了添加订阅者 Wather 的操作的,所以我们只要在订阅者 Watcher 初始化的时候触发对应的 get 函数去执行添加订阅者操作即可。
那要如何触发 get 的函数?
再简单不过了,只要获取对应的属性值就可以触发了,核心原因就是因为我们使用了 Object.defineProperty() 进行数据监听。
这里还有一个细节点需要处理,我们只要在订阅者 Watcher 初始化的时候才需要添加订阅者,所以需要做一个判断操作,因此可以在订阅器上做一下手脚:在 Dep.target 上缓存下订阅者,添加成功后再将其去掉就可以了。
订阅者 Watcher 的实现如下:
function Watcher(vm, exp, cb) {
this.vm = vm;
this.exp = exp;
this.cb = cb;
this.value = this.get(); // 将自己添加到订阅器的操作
}
Watcher.prototype = {
update: function() {
this.run();
},
run: function() {
var value = this.vm.data[this.exp];
var oldVal = this.value;
if (value !== oldVal) {
this.value = value;
this.cb.call(this.vm, value, oldVal);
}
},
get: function() {
Dep.target = this; // 全局变量 订阅者 赋值
let value = this.vm.data[this.exp] // 强制执行监听器里的get函数
Dep.target = null; // 全局变量 订阅者 释放
return value;
}
};
对以上订阅者 Watcher 代码分析:
订阅者 Watcher 是一个 类,在它的构造函数中,定义了一些属性:
vm:一个 Vue 的实例对象;exp:是 node 节点的 v-model 等指令的属性值 或者插值符号中的属性。 如:v-model="name",exp 就是name;cb:是 Watcher 绑定的更新函数;
当我们去实例化一个渲染 watcher 的时候,首先进入 watcher 的构造函数逻辑,就会执行它的 this.get() 方法,即构造函数中:this.value = this.get()。
进入 get 函数,首先会执行:
Dep.target = this; // 将自己赋值为全局的订阅者
实际上就是把 Dep.target 赋值为当前的渲染 watcher ,接着又执行了:
let value = this.vm.data[this.exp] // 强制执行监听器里的get函数,因为你获取data的值了
在这个过程中会对 vm 上的数据访问,其实就是为了触发数据对象的 getter。
每个对象值的 getter 都持有一个 dep,在触发 getter 的时候会调用 dep.depend() 方法,也就会执行this.addSub(Dep.target),即把当前的 watcher订阅者 订阅到这个数据持有的 dep 的 watchers 中,这个目的是为后续数据变化时候能通知到哪些 watchers 做准备。
简而言之,就是想把订阅者添加到订阅器中,方便到时候订阅器统一通知订阅者更新。
这样实际上已经完成了一个依赖收集的过程。那么到这里就结束了吗?其实并没有,完成依赖收集后,还需要把 Dep.target 恢复成上一个状态,即:
Dep.target = null; // 释放自己
而 update() 函数是用来当数据发生变化时调用 Watcher订阅者 自身的更新函数进行更新的操作。
先通过Watcher订阅者的run方法中: let value = this.vm.data[this.exp]; 获取到最新的数据,然后将其与之前 get() 获得的旧数据进行比较,如果不一样,则调用更新函数 cb 进行更新。
4.6 解析器 Compile
通过监听器 Observer 订阅器 Dep 和订阅者 Watcher 的实现,其实就已经实现了一个双向数据绑定的例子。
但是整个过程都没有去解析 dom 节点,而是直接固定某个节点进行替换数据的,所以接下来需要实现一个解析器 Compile 来做解析和绑定工作。解析器 Compile 实现步骤:
- 解析模板指令,并替换模板数据,初始化视图;
- 将模板指令对应的节点绑定对应的更新函数,初始化相应的订阅器;
我们下面对 '{{变量}}' 这种形式的指令处理的关键代码进行分析,感受解析器 Compile 的处理逻辑,关键代码如下:
compileText: function(node, exp) {
var self = this;
var initText = this.vm[exp]; // 获取属性值
this.updateText(node, initText); // dom 更新节点文本值
// 将这个指令初始化为一个订阅者,后续 exp 改变时,就会触发这个更新回调,从而更新视图
new Watcher(this.vm, exp, function (value) {
self.updateText(node, value);
});
}
多看几遍,以咱超群的智商,是可以懂的。

自己对响应式的理解?
你都把响应式原理和相对对象吹了,不得说一下自己的理解?
我知道大家意犹未尽,那我们再说一下自己的理解吧。
我个人觉得可以从以下五个方面去回答。
- 响应式是什么?
- 为什么需要响应式?
- 它能给我们带来什么好处?
- vue的响应式是怎么实现的?有哪些优缺点?
- vue3中的响应式的新变化
第一:是什么
所谓数据响应式就是能够使数据变化可以被检测并对这种变化做出响应的机制。
第二:为什么需要响应式
MVVM框架中要解决的一个核心问题是连接数据层和视图层,通过数据驱动应用,数据变化,视图更新,要做到这点的就需要对数据做响应式处理,这样一旦数据发生变化就可以立即做出更新处理。
第三:有什么好处
以vue为例说明,通过数据响应式加上虚拟DOM和patch算法,开发人员只需要操作数据,关心业务,完全不用接触繁琐的DOM操作,从而大大提升开发效率,降低开发难度。
你知道在工作中为什么不要用 index 作为 key嘛?(diff 算法详解)
第四:实现、优缺点
vue2中的数据响应式会根据数据类型来做不同处理。
如果是对象则采用Object.defineProperty() 的方式定义数据拦截,当数据被访问或发生变化时,我们感知并作出响应;如果是 数组则通过覆盖数组对象原型的7个变更方法,使这些方法可以额外的做更新通知,从而作出响应。这种机制很好的解决了数据响应化的问题。
但在实际使用中也存在一些缺点:比如初始化时的递归遍历会造成性能损失;新增或删除属性时需要用户使用Vue.set/delete这样特殊的api才能生效;对于es6中新产生的Map、Set这些数据结构不支持等问题。
第五:vue3变化
为了解决这些问题,vue3重新编写了这一部分的实现:利用ES6的Proxy代理要响应化的数据,它有很多好处,编程体验是一致的,不需要使用特殊api,初始化性能和内存消耗都得到了大幅改善;另外由于响应化的实现代码抽取为独立的reactivity包,使得我们可以更灵活的使用它,第三方的扩展开发起来更加灵活了。
结尾
英雄不问出处,学到就是赚到
转载自:https://juejin.cn/post/7246291189679112253