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

更新日期: 2022-04-16阅读: 907标签: 响应式

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

来源: 前端一码平川


链接: https://fly63.com/article/detial/11357

移动端web app要使用rem实现自适应布局:font-size的响应式

rem是相对于根元素html,这样就意味着,我们只需要在根元素确定一个px字号,则可以来算出元素的宽高。

使用现代CSS的响应式版面

通过模块化缩放,使用传统属性和calc()来动态缩放你的字体大小.为字体大小使用百分比.给文本内容和媒体查询使用em,针对不同视口尺寸使用不同缩放值.视口越小,缩放比例越小,使用媒体查询或者media()函数基于视口来改变比例和基础字号

web响应式图片的5种实现

在目前的前端开发中,我们经常需要进行响应式的网站开发。本文着重介绍一下弹性图片,也就是响应式图片的解决方案:js或服务端、srcset 、sizes 、picture标签、svg图片

HTML5+CSS3响应式垂直时间轴,高端,大气

HTML5+CSS3响应式垂直时间轴,使用了HTML5标签<section>,时间轴中所有的内容包括标题、简介、时间和图像都放在.cd-timeline-block的DIV中,多个DIV形成一个序列,并把这些DIV放在<section>中。

实现响应式_CSS变量

CSS 变量是 CSS 引入的一个新特性,目前绝大多数浏览器已经支持了,它可以帮助我们用更少的代码写出同样多的样式,大大提高了工作效率,本篇文章将教你如何使用 CSS 变量(css variable)。CSS中原生的变量定义语法是:--*,变量使用语法是:var(--*),其中*表示变量名称

vue响应式原理及依赖收集

Vue通过设定对象属性的setter/getter方法来监听数据的变化,通过getter进行依赖收集,而每个setter方法就是一个观察者,在数据变更的时候通知订阅者更新视图。

vue响应式系统--observe、watcher、dep

Vue 最独特的特性之一,是其非侵入性的响应式系统。数据模型仅仅是普通的JavaScript 对象,而当你修改它们时,视图会进行更新,这使得状态管理非常简单直接,我们可以只关注数据本身

Responsive Web Design 响应式网页设计

常见的布局方案:固定布局:以像素作为页面的基本单位,不管设备屏幕及浏览器宽度,只设计一套尺寸;可切换的固定布局:同样以像素作为页面单位,参考主流设备尺寸

响应式布局的实现

响应式布局,即 Responsive design,在实现不同屏幕分辨率的终端上浏览网页的不同展示方式。通过响应式设计能使网站在手机和平板电脑上有更好的浏览阅读体验。响应式布局的关键不仅仅在于布局

深入响应式原理

说到响应式原理其实就是双向绑定的实现,说到 双向绑定 其实有两个操作,数据变化修改dom,input等文本框修改值的时候修改数据1. 数据变化 -> 修改dom;2. 通过表单修改value -> 修改数据

点击更多...

内容以共享、参考、研究为目的,不存在任何商业目的。其版权属原作者所有,如有侵权或违规,请与小编联系!情况属实本人将予以删除!