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对象的代理,分别对代理对象的设值、取值、删除属性等操作进行了介绍。还讨论了,如何合理触发副作用函数重新执行,以及屏蔽由原型更新引起的副作用函数不必要的重新执行。