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

语言: CN / TW / HK

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