Vue.js设计与实现之九-Object对象类型的响应式代理

开发 前端
这篇文章介绍了Proxy如何实现对Object对象的代理,分别对代理对象的设值、取值、删除属性等操作进行了介绍。还讨论了,如何合理触发副作用函数重新执行,以及屏蔽由原型更新引起的副作用函数不必要的重新执行。

1、写在前面

在Javascript中,我们知道“万物皆对象”,而对象的实际语义又是由对象的内部方法来指定的。所谓内部方法,指的是在对一个对象进行操作时在引擎内部调用的方法,这些方法对使用者是不可见的。

如何区分一个对象是普通对象还是函数呢?

可以通过内部方法和内部槽来区分对象,函数对象会部署方法[[call]],而普通对象不会。

2、Proxy的工作原理

当然,内部方法是具有多态性的,不同类型的对象部署相同的内部方法,却有可能有不同的逻辑。

如果在创建代理对象时没有指定对应的拦截方法,那么就会通过代理对象访问属性值时,代理的内部方法(如[[Get]])会去调用原始对象的内部方法(如[[Get]])去获取属性值,这就会代理透明。

Proxy也是对象,在它身上也会部署许多内部方法,当我们通过代理对象去访问属性值时,会调用部署在代理对象上的内部方法[[Get]]。

Proxy对象的内部方法:

  • handler.apply()
  • handler.construct()
  • handler.defineProperty()
  • handler.deleteProperty()
  • handler.get()
  • handler.getOwnPropertyDescriptor()
  • handler.getPrototypeOf()
  • handler.has()
  • handler.isExtensible()
  • handler.ownKeys()
  • handler.preventExtensions()
  • handler.set()
  • handler.setPrototypeOf()

在被代理对象是函数时,会部署另外的两个内部方法[[Call]]和[[Constructor]]。

当我们使用Proxy的deleteProperty()删除属性时,实际上是代理对象的内部方法和行为,改变的只是代理对象的属性值。想要改变原始数据上的属性值,必须通过Reflect.deleteProperty(target,key)来实现。

3、如何代理Object对象

在前面的文章中,使用get拦截方法对属性的读取操作,其实是片面的,因为使用in操作符检查对象的属性、使用for...in循环遍历对象,都是对象的读取操作。

读取属性

普通对象的所有读取操作:

  • 访问属性:data.name。
  • 判断对象或原型上是否存在指定的key:key in data。
  • 使用for...in遍历对象:for(const key in data){}。

直接访问属性

const data = {
name:"pingping"
}
const state = new Proxy(data,{
get(target, key, receiver){
//追踪函数 建立副作用函数与代理对象的联系
track(target, key);
//返回属性值
Reflect.get(target, key, receiver);
}
})

in操作符

const data = {
name:"pingping"
}
const state = new Proxy(data,{
has(target, key, receiver){
//追踪函数 建立副作用函数与代理对象的联系
track(target, key);
//返回属性值
Reflect.has(target, key, receiver);
}
})

for...in

通过拦截ownKeys操作,可以实现对for...in循环的间接拦截,在ownKeys中只能获取到目标对象target的所有键值,但是没有和具体的键绑定。对此需要使用Symbol构造唯一的key值进行标识,即ITERATE_KEY。

const data = {
name:"pingping"
}
const ITERATE_KEY = Symbol();
const state = new Proxy(data,{
ownKeys(target){
//追踪函数 建立副作用函数与ITERATE_KEY的联系
track(target, ITERATE_KEY);
//返回属性值
Reflect.ownKeys(target);
}
})

设置属性

如果代理对象state只有一个属性时,for...in循环只会执行一次,但是当state上添加了新的属性,那么for...in便会执行多次。这是因为给对象添加新的属性时,会触发与ITERATE_KEY相关联的副作用函数重新执行。

const data = {
name:"pingping"
}
const ITERATE_KEY = Symbol();
const state = new Proxy(data,{
set(target, key, newVal){
const res = Reflect.set(target, key, newVal, receiver);
trigger(target, key);
return res;
},
ownKeys(target){
//追踪函数 建立副作用函数与ITERATE_KEY的联系
track(target, ITERATE_KEY);
//返回属性值
Reflect.ownKeys(target);
}
})
effect(()=>{
for(const key in state){
console.log(key);//name
}
})

trigger函数:

function trigger(target, key){
const depsMap = bucket.get(target);
if(!depsMap) return;
const effects = depsMap.get(key);
const iterateEffects = depsMap.get(ITERATE_KEY);
const effectsToRun = new Set();
// 将与key相关联的副作用函数添加到effectsToRun中
effect && effects.forEach(effectFn=>{
if(effectFn !== activeEffect){
effectsToRun.add(effectFn);
}
});
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun中
iterateEffects && iterateEffects.forEach(effectFn=>{
if(effectFn !== activeEffect){
effectsToRun.add(effectFn);
}
});
effectsToRun.forEach(effectFn=>{
if(effectFn.options.scheduler){
effectFn.options.scheduler(effectFn);
}else{
effectFn();
}
});
}

在上面trigger函数中,在添加属性时,除了将与key值直接相关联的副作用函数取出来执行外,还需要将那些与ITERATE_KEY相关联的副作用函数也取出来执行。

在上面的代码中,对于代理对象添加新的属性而言,是可以这样做的,但是对于修改现有对象的现有属性是不可行的。因为在修改现有属性值,不会对for...in循环造成影响,无论如何修改值都只会执行一次循环。对此,不需要触发副作用函数的重新执行,否则会造成额外的性能开销。

那么,应该如何处理呢?

事实上,无论是在现有对象新增属性还是修改现有属性,都是使用set拦截函数来实现拦截的。所以,我们可以将上面代码片段进行整合,在进行设置操作拦截的时候进行判断,判断当前对象上是否有该属性。

  • 如果是新增属性,则多次执行触发ITERATE_KEY相关联的副作用函数执行。
  • 如果是修改属性,则不需要触发ITERATE_KEY相关联的副作用函数执行。
const TriggerType = {
SET:"SET",
ADD:"ADD"
};
const state = new Proxy(data,{
set(target, key, newVal){
const type = Object.prototype.hasOwnProperty.call(target,key) ? TriggerType.SET : TriggerType.ADD;
const res = Reflect.set(target, key, newVal, receiver);
// 传入判断当前是否新增属性
trigger(target, key, type);
return res;
}
})
function trigger(target, key, type){
const depsMap = bucket.get(target);
if(!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set();
// 将与key相关联的副作用函数添加到effectsToRun中
effect && effects.forEach(effectFn=>{
if(effectFn !== activeEffect){
effectsToRun.add(effectFn);
}
});
if(type === TriggerType.ADD){
const iterateEffects = depsMap.get(ITERATE_KEY);
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun中
iterateEffects && iterateEffects.forEach(effectFn=>{
if(effectFn !== activeEffect){
effectsToRun.add(effectFn);
}
});
}
effectsToRun.forEach(effectFn=>{
if(effectFn.options.scheduler){
effectFn.options.scheduler(effectFn);
}else{
effectFn();
}
});
}

删除属性

在代理对象商,删除属性可以通过delete进行删除,那么delete操作符依赖Proxy对象内部方法deleteProperty。同样的,在删除指定属性时,需要先检查当前属性是否在对象自身上,然后再考虑Reflect.deleteProperty函数完成属性的删除。

既然是操作代理对象的属性删除,那么就会触发trigger的依赖收集操作,副作用函数会重新执行。对象属性的数目变少,那么就会影响for...in循环的次数,会触发与ITERATE_KEY相关联的副作用函数的重新执行。

const TriggerType = {
SET:"SET",
ADD:"ADD",
DELETE:"DELETE"
};
const state = new Proxy(data, {
deleteProperty(target, key){
// 检查当前要删除的属性是否在对象上
const hadKey = Object.property.hasOwnProperty.call(target, key);
// 使用`Reflect.deleteProperty`函数完成属性的删除
const res = Reflect.deleteProperty(target, key);
if(res && hadKey){
//只有删除成功才会触发更新
trigger(target, key, "DELETE");
}
}
})
function trigger(target, key, type){
const depsMap = bucket.get(target);
if(!depsMap) return;
const effects = depsMap.get(key);
const effectsToRun = new Set();
// 将与key相关联的副作用函数添加到effectsToRun中
effect && effects.forEach(effectFn=>{
if(effectFn !== activeEffect){
effectsToRun.add(effectFn);
}
});
if(type === TriggerType.ADD || type === TriggerType.DELETE){
const iterateEffects = depsMap.get(ITERATE_KEY);
// 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun中
iterateEffects && iterateEffects.forEach(effectFn=>{
if(effectFn !== activeEffect){
effectsToRun.add(effectFn);
}
});
}
effectsToRun.forEach(effectFn=>{
if(effectFn.options.scheduler){
effectFn.options.scheduler(effectFn);
}else{
effectFn();
}
});
}

4、合理触发响应

在前面的文字中,从规范的角度详细地介绍了如何实现对象代理,与此同时,处理了很多边界条件。需要明确知道操作类型才能触发响应,但是在触发响应时也要看是否合理,在值没有发生变化时就不需要触发响应。

对此,在修改set拦截函数的代码时,在调用trigger函数触发响应前,需要检查值是否发生真实改变。

const data = {
name:"pingping"
};
const state = new Proxy(data,{
set(target, key, newVal, receiver){
// 先获取旧值
const oldVal = target[key];
const type = Object.prototype.hasOwnProperty.call(target, key) ? "SET" : "ADD";
const res = Reflect.set(target, key, newVal, receiver);
if(oldVal !== newVal){
trigger(target, key, type);
}
return res
})
effect(()=>{
console.log(state.name);
});
state.name = "onechuan";

在调用set拦截函数时,需要先获取oldVal与新值newVal进行比较,只有二者不全等的时候才会触发响应。当时,当oldVal和newVal的值都为NaN时,使用全等进行比较得到的是false。

NaN === NaN //false
NaN !== NaN //true

我们看到NaN值的比较值,当data.num的初始值为NaN时,后续修改其值为NaN作为新值,此时还是使用全等比较,得到NaN !== NaN值为true,就会触发响应函数,导致不必要的更新。对此需要先判断oldVal和newVal的值都不为NaN,那么需要加上判断oldVal === oldVal || newVal === newVal,其实等价于Number.isNaN(newVal) || Number.isNaN(oldVal)。

为了方便使用,我们对对象的代理进行函数封装。

function reactive(){
return new Proxy(data,{
set(target, key, newVal, receiver){
// 先获取旧值
const oldVal = target[key];
const type = Object.prototype.hasOwnProperty.call(target, key) ? "SET" : "ADD";
const res = Reflect.set(target, key, newVal, receiver);
if(oldVal !== newVal && (oldVal === oldVal || newVal === newVal)){
trigger(target, key, type);
}
return res
})
}

这样,在使用时:

const obj = {};
const data = {
name:"pingping"
}
const parent = reactive(data);
const child = reactive(obj);
//使用parent对象作为child的原型对象
Object.setPrototypeOf(child, parent);
effect(()=>{
console.log(child.name);//pingping
});
//修改了child.name的值
child.name = "onechuan";//会导致副作用函数重新执行两次

在上面的代码中,会导致副作用函数重新执行两次。其实做的处理就是分别使用Proxy对obj和data进行代理,并将parent对象作为child的原型对象。在副作用函数中读取child.name的值时,会触发child代理对象的get拦截函数,而拦截函数的实现是Reflect.get(obj, "name", receiver)。

但是呢,child对象本身上本不存在name属性,对此就会去获取对象的原型parent并调用原型的[[Get]]方法得到结果parent.name的值。而parent本身又是响应式数据,对此在副作用函数中访问parent.name的值,会导致副作用函数被收集并建立响应联系。parent.name和child.name都会触发副作用函数的依赖收集,即都与副作用函数建立了联系。

重新分析下上面的代码,当child.name = 2被执行时,会调用child对象的set拦截函数,而在set拦截函数内部实现是Reflect.get(target, key, newVale, receiver)完成默认设置行为。由于child和其所代理的对象obj上没有name属性,则会去原型parent上进行寻找,即导致parent代理对象的set拦截函数被执行。

而在读取child.name的值时,副作用函数不仅会被child.name触发执行,还会被parent.name所收集,对此在parent代理对象的set拦截函数被执行时,会触发副作用函数重新执行。对此,副作用函数被执行了两次。

那么,应该如何避免执行两次副作用函数呢?

其实,我们需要区分两次副作用函数执行是谁触发的,其实只需要确定recevier是不是target的代理对象,然后将parent.name触发的副作用函数执行进行屏蔽即可。

function reactive(){
return new Proxy(data,{
get(target, key, receiver){
// 代理对象可以通过raw属性访问数据
if(key === "raw"){
return target
}
track(target, key);
return Reflect.get(target, key, receiver);
},
set(target, key, newVal, receiver){
// 先获取旧值
const oldVal = target[key];
const type = Object.prototype.hasOwnProperty.call(target, key) ? "SET" : "ADD";
const res = Reflect.set(target, key, newVal, receiver);
// target === receiver.raw可以说明receiver是target的代理对象
if(target === receiver.raw){
if(oldVal !== newVal && (oldVal === oldVal || newVal === newVal)){
trigger(target, key, type);
}
}
return res
})
}

在上面代码中,我们新增判断条件target === receiver.raw,只有的那个其为true,即recevier是target的代理对象时触发更新,就可以屏蔽由于原型引起的更新,从而避免不必要的更新操作。

5、写在最后

上篇文章中介绍了好哥们Proxy和Reflect的作用,这篇文章介绍了Proxy如何实现对Object对象的代理,分别对代理对象的设值、取值、删除属性等操作进行了介绍。还讨论了,如何合理触发副作用函数重新执行,以及屏蔽由原型更新引起的副作用函数不必要的重新执行。

责任编辑:姜华 来源: 前端一码平川
相关推荐

2022-04-17 09:18:11

响应式数据Vue.js

2022-04-05 16:44:59

系统Vue.js响应式

2022-04-04 16:53:56

Vue.js设计框架

2022-04-09 17:53:56

Vue.js分支切换嵌套的effect

2022-04-01 08:08:27

Vue.js框架命令式

2022-04-25 07:36:21

组件数据函数

2022-04-12 08:08:57

watch函数options封装

2017-08-30 17:10:43

前端JavascriptVue.js

2021-01-22 11:47:27

Vue.js响应式代码

2022-04-26 05:55:06

Vue.js异步组件

2022-04-18 08:09:44

渲染器DOM挂载Vue.js

2022-04-11 08:03:30

Vue.jscomputed计算属性

2022-04-14 09:35:03

Vue.js设计Reflect

2022-05-03 21:18:38

Vue.js组件KeepAlive

2022-04-03 15:44:55

Vue.js框架设计设计与实现

2021-04-14 12:47:50

Vue.jsMJML电子邮件

2022-04-20 09:07:04

Vue.js的事件处理

2022-04-19 23:01:54

Vue.jsDOM节点DOM树

2016-11-01 19:10:33

vue.js前端前端框架

2019-04-01 19:38:28

Vue.jsJavascript前端
点赞
收藏

51CTO技术栈公众号