一、前言
数据响应式
所谓数据响应式就是建立响应式数据与依赖(调用了响应式数据的操作)之间的关系,当响应式数据发生变化时,可以通知那些使用了这些响应式数据的依赖操作进行相关更新操作,可以是DOM更新,也可以是执行一些回调函数。
从Vue2到Vue3都使用了响应式,那么它们之间有什么区别?
- Vue2响应式:基于Object.defineProperty()实现的。
- Vue3响应式:基于Proxy实现的。
那么它们之间有什么区别?为什么Vue3会选择Proxy替代defineProperty?
请听我娓娓道来~~
二、Object.defineProperty()
1、Object.defineProperty
Object.defineProperty(obj, prop, descriptor) 方法会直接在一个对象上定义一个新属性,或修改一个对象的现有属性,并返回此对象,其参数具体为:
obj:要定义属性的对象
prop:要定义或修改的 属性名称 或
Symbol
descriptor:要定义或修改的 属性描述符
从以上的描述就可以看出一些限制,比如: 目标是 对象属性,不是 整个对象 一次只能 定义或修改一个属性
当然有对应的一次处理多个属性的方法Object.defineProperties()
,但在 vue 中并不适用,因为 vue不能提前知道用户传入的对象都有什么属性,因此还是得经过类似 Object.keys() + for 循环的方式获取所有的 key ->value,而这其实是没有必要使用Object.defineProperties()
2、为什么使用Object.defineProperty
const obj = {
a: 1,
b: 2,
c: {
a: 1,
b: 2
}
}
// obj.a
// obj.a = 3
首先思考:vue的响应式到底要干什么?无非就是做一件事,就是当我们读这个对象属性的时候,我们要知道它读了,我要做些别的事我要插一脚。当给它重新赋值的时候,我要知道它在重新赋值,我要插一脚。
上面代码现在这脚就插不进去,所以要想一个办法,把这个属性的读取和赋值变成一个函数,希望将来读这个属性的时候,运行这么个函数,给这个属性赋值的时候,把新的值传给我。一变函数就简单了,不要说插一脚,100脚都不是问题。
那怎么变成函数呢,在ES6之前,没有别的办法。只有Object.defineProperty()
const obj = {
a: 1,
b: 2,
c: {
a: 1,
b: 2
}
}
let v = obj.a // 拿到原始值
Object.defineProperty(obj, 'a', {
get () { // 读的时候 运行get
console.log('a', '读取')
return v
},
set (val) { // 赋值的时候,运行set
if (val !== v) {
console.log('a', '更改')
v = val
}
}
})
obj.a
obj.a = 3
由于vue2是针对属性的监听,所以就必须要去深度遍历每一个属性,所以在vue2里面就有一个observe(),要玩这个对象之前,先对它进行监听。
const obj = {
a: 1,
b: 2,
c: {
a: 1,
b: 2
}
}
// 判断是不是Object
function isObject(v) {
return typeof v === 'object' && v !== null
}
// 观察 在这一步完成监听
function observe(obj) {
for (const k in obj) {
let v = obj[k]
if (isObject(v)) { // 如果属性值仍然是个对象,深度遍历
observe(v)
}
Object.defineProperty(obj, 'k', {
get () { // 读的时候 运行get
console.log('k', '读取')
return v
},
set (val) { // 赋值的时候,运行set
if (val !== v) {
console.log('k', '更改')
v = val
}
}
})
}
}
obj.a
obj.a = 3
obj.bbbbb = 666 // 没有被监听到
delete obj.a // 没有被监听到
在vue2里面观察的方式就是深度遍历每一个属性,把每一个属性的读取和赋值变成函数,只要变成函数,就可以插一脚。具体这一脚是做啥,再次先不讨论哈。
这就是vue2的做法,但是有一个天生的缺陷
由于它是针对每个属性的监听,所以他就必须要进行深度的遍历,这会有效率的损失。由于在观察这个步骤里面,它完成了深度遍历,在观察这个步骤时间点,有的属性都被监听到了,都被改成了get和set函数了,观察这一步做完后,再去新增属性,就不知道了,对这个属性而言,是没有被监听的。
我们学vue生命周期,在created()之前就完成了监听。后修再添加属性,它就不知道了,这就是为什么vue2无法监听属性的新增、删除。
我们再整理下:
defineReactive(data,key,val){
Object.defineProperty(data,key,{
enumerable:true,
configurable:true,
get:function(){
console.log(`对象属性:${key}访问defineReactive的get!`)
return val;
},
set:function(newVal){
if(val===newVal){
return;
}
val = newVal;
console.log(`对象属性:${key}访问defineReactive的get!`)
}
})
}
let obj = {};
this.defineReactive(obj,'name','sapper');
// 修改obj的name属性
obj.name = '工兵';
console.log('obj',obj.name);
// 为obj添加age属性
obj.age = 12;
console.log('obj',obj);
console.log('obj.age',obj.age);
// 为obj添加数组属性
obj.hobby = ['游戏', '原神'];
obj.hobby[0] = '王者';
console.log('obj.hobby',obj.hobby);
// 为obj添加对象属性
obj.student = {school:'大学'};
obj.student.school = '学院';
console.log('obj.student.school',obj.student.school);
从上图可以看出使用defineProperty定义了包含name属性的对象obj,然后添加age属性、添加hobby属性(数组)、添加student属性并分别访问,都没有触发obj对象中的get、set方法。
也就是说defineProperty定义对象不能监听添加额外属性或修改额外添加的属性的变化,我们再看看这样一个例子:
let obj = {};
// 初始化就添加hobby
this.defineReactive(obj,'hobby',['游戏', '原神']);
// 改变数组下标0的值
obj.hobby[0] = '王者';
console.log('obj.hobby',obj.hobby);
假如我们一开始就为obj添加hobby属性,我们发现修改数组下标0的值,并没有触发obj里的set方法
也就是说defineProperty定义对象不能监听根据自身数组下标修改数组元素的变化。
Object.defineProperty():
- defineProperty定义对象不能监听添加额外属性或修改额外添加的属性的变化
- defineProperty定义对象不能监听根据自身数组下标修改数组元素的变化
3、Object.defineProperty 拦截 Array
Object.defineProperty 可用于实现对象属性的 get 和 set 拦截,而数组其实也是对象,那自然是可以实现对应的拦截操作,如下:
Vue2 为什么不使用 Object.defineProperty 拦截 Array?
尤大在曾在 GitHub 的 Issue 中做回复: 我是因为性能问题
- 数组 和 普通对象 在使用场景下有区别,在项目中使用数组的目的大多是为了 遍历,即比较少会使用 array[index] = xxx 的形式,更多的是使用数组的 Api 的方式
- 数组长度是多变的,不可能像普通对象一样先在 data 选项中提前声明好所有元素,比如通过 array[index] = xxx 方式赋值时,一旦 index 的值超过了现有的最大索引值,那么当前的添加的新元素也不会具有响应式
- 数组存储的元素比较多,不可能为每个数组元素都设置 getter/setter
- 无法拦截数组原生方法如 push、pop、shift、unshift 等的调用,最终仍需 重写/增强 原生方法
4、缺陷
三、Proxy
无论是vue2还是vue3,都必须把读取和赋值变成函数,这是必须的,不玩玩不了。
只不过变函数的方式不一样,在vue3里面就不会针对这些属性进行监听了,而是直接监听整个对象。那这就简单了,都不需要遍历了。只要在动这个对象就能收到通知,那么是怎么做到的呢?就是proxy。
这样不管是读的哪一个属性,给属性重新赋值的时候,也会收到通知。这样就会产生一个代理对象。使用这个属性都是通过这个代理对象去做的。
const obj = {
a: 1,
b: 2,
c: {
a: 1,
b: 2
}
}
// 观察
new Proxy(obj, {
get (target, k) { // 读的时候 运行get
let v = target[k]
console.log('k', '读取')
return v
},
set (target, k, val) { // 赋值的时候,运行set
if (target[k] !== val) {
console.log('k', '更改')
target[k] = val
}
},
deleteProperty(){ // 删除属性监听
}
})
proxy.a = 3
proxy.b
proxy.ccccccccc
由于它不去监听属性了,就不需要遍历了,监听的是整个对象,所以之后对属性的操作,都是能收到通知的。
虽然这个代码和vue的源码还有很多细节上的差别,但是核心道理就是如此。
我们再整理下:
const obj = {
a: 1,
b: 2,
c: {
a: 1,
b: 2
}
}
// 判断是不是Object
function isObject(v) {
return typeof v === 'object' && v !== null
}
// 观察
function observe(obj){
const proxy = new Proxy(obj, {
get (target, k) { // 读的时候 运行get
console.log('k', '读取')
let v = target[k]
if (isObject(v)) { // 虽然是递归,但是不会影响一开始的效率
v = observe(v)
}
return v
},
set (target, k, val) { // 赋值的时候,运行set
if (target[k] !== val) {
console.log('k', '更改')
target[k] = val
}
},
deleteProperty(){ // 删除属性监听
}
})
return proxy
}
const proxy = observe(obj)
proxy.a = 3
proxy.b
proxy.ccccccccc
再看看例子:
// proxy实现
let targetProxy = {name:'sapper'};
let objProxy = new Proxy(targetProxy,{
get(target,key){
console.log(`对象属性:${key}访问Proxy的get!`)
return target[key];
},
set(target,key,newVal){
if(target[key]===newVal){
return;
}
console.log(`对象属性:${key}访问Proxy的set!`)
target[key]=newVal;
return target[key];
}
})
// 修改objProxy的name属性
objProxy.name = '工兵';
console.log('objProxy.name',objProxy.name);
// 为objProxy添加age属性
objProxy.age = 12;
console.log('objProxy.age',objProxy.age);
// 为objProxy添加hobby属性
objProxy.hobby = ['游戏', '原神'];
objProxy.hobby[0] = '王者';
console.log('objProxy.hobby',objProxy.hobby);
// 为objProxy添加对象属性
objProxy.student = {school:'大学'};
objProxy.student.school = '学院';
console.log('objProxy.student.school',objProxy.student.school);
从上图是不是发现了Proxy与defineProperty的明显区别之处了,Proxy能支持对象添加或修改触发get、set方法,不管对象内部有什么属性。
我们再看看Vue里的用法例子:
data() {
return {
name: 'sapper',
student: {
name: 'sapper',
hobby: ['原神', '天涯明月刀'],
},
};
},
methods: {
deleteName() {
delete this.student.name;
console.log('删除了name', this.student);
},
addItem() {
this.student.age = 21;
console.log('添加了this.student的属性', this.student);
},
updateArr() {
this.student.hobby[0] = '王者';
console.log('更新了this.student的hobby', this.student);
},
}
从图中确实可以修改data里的属性,但是不能及时渲染,所以Vue2提供了两个属性方法解决了这个问题:Vue.
s
e
t
和
V
u
e
.
set和Vue.
set和Vue.delete。注意不能直接this._ data.age这样去添加age属性,也是不支持的。
this.$delete(this.student, 'name');// 删除student对象属性name
this.$set(this.student, 'age', '21');// 添加student对象属性age
this.$set(this.student.hobby, 0, '王者');// 更新student对象属性hobby数组
const user = {name:'张三'}
const obj = new Proxy(user,{
get:function (target,key){
console.log("get run");
return target[key];
},
set:function (target,key,val){
console.log("set run");
target[key]=val;
return true;
}
})
obj.age = 22;
console.log(obj); // 监听对象添加额外属性打印set run!
const obj = new Proxy([2,1],{
get:function (target,key){
console.log("get run");
return target[key];
},
set:function (target,key,val){
console.log("set run");
target[key]=val;
return true;
}
})
obj[0] = 3;
console.log(obj); // 监听到了数组元素的变化打印set run!
- Proxy:解决了上面两个弊端,proxy可以实现:
- 可以直接监听对象而非对象属性,可以监听对象添加额外属性的变化;
- 可以直接监听数组的变化
- Proxy 返回的是一个新对象,而 Object.defineProperty 只能遍历对象属性直接修改。
- 支持多达13 种拦截方法,不限于 apply、ownKeys、deleteProperty、has 等等是Object.defineProperty 不具备的。