第五章 Vue框架的响应式方案

112 阅读8分钟

非原始值的响应式方案

对象的响应式

  • 在Vue3中,使用Proxy和reflect实现对象的代理工作

  • Proxy:能够对其他对象的基本语义进行代理,但无法代理非对象值

    • 基本语义:即对象的基本操作,如get,set,apply等
    • 非对象值:string,number等
    • 代理:proxy接受两个参数,第一个是被代理的对象,第二个是拦截器对象,拥有许多拦截方法,用于拦截对象的基本操作
  • Reflect:能够实现一个对象的基本语义

    • 实现:在reflect对象拥有与proxy的拦截器同等的方法,用于实现对象的基本操作。
    • 即当我们通过trap拦截到对象基本操作后,可以调用reflect再重新执行基本操作

什么是对象

  • Js的世界中一切皆对象,对象分为两类,常规对象和异质对象
  • 对象内部维护了各自独有的一些方法,由方法的不同表现出不同的语义
  • 对象必要的内部方法如下
    对象内部固有方法.jpg
  • 对象额外的必要内部方法如下 额外必要的内部方法.jpg
  • 以上必要的内部方法,虽然是不同类型的对象都具有的,但它们具有多态性,方法内部的逻辑表现不同
  • 内部方法实现遵循指定的规范,则为常规对象,否则为异质对象
  • Proxy对象内部部署了包括必要方法和额外必要方法在内的所有方法,但是由于它的某些方法未遵循指定规范,所以为异质对象
  • Proxy对象内部方法及具体表现 Proxy对象内部方法.jpg
  • 如图所示内部方法对应的具体实现正是Proxy的拦截器(trap)

对象如何代理

  • 为了实现代理,我们需要拦截对象的所有读取操作

    • 对象所有的读取操作:

      • obj.prop 属性读取, 对应内部方法GET,拦截器为get
      • key in obj 属性判断, 对应内部方法HasProperty,拦截器为has
      • for( const key in obj ) 属性遍历, 对应内部方法OwnPropertyKeys,拦截器为oneKeys
    • 对象所有的设置操作:

      • obj.prop = a; 属性添加, 对应内部方法SET,拦截器为set
      • obj.prop = b; 属性修改, 对应内部方法SET,拦截器为set
    • 对象所有的删除操作:

      • delete obj.prop 属性删除, 对应内部方法DELETE,拦截器为deleteProperty
  • 为了实现响应式,我们需要合理的触发响应

    • 当数据修改了但是值没变,此时不应该触发响应。我们需要在set中触发之前检查新值和旧值是否一致
    • 当代理对象所代理的原始对象不存在某个属性时,会从代理对象的原型中查找,如果代理对象的原型仍然是一个代理对象,那么其拦截函数中,receiver始终保持为子级代理对象。这样通过判断拦截函数的target和receiver的关系即可决定是否要触发响应,来避免重复响应的问题
  • 浅响应与深响应

    • 将Proxy代理操作封装到reactive函数内部,即实现了浅响应

      • 我们在get拦截函数内通过reflect返回了原始对象的属性,但即便该属性仍是一个对象,但其本身非代理对象没有拦截器,无法收集和触发响应
    • 将Reflect操作返回的结果重新使用reactive包装,将包装后的代理对象返回,即实现了深响应

      • 我们可以对reflect返回的属性类型进行判断,如果其仍为对象,则递归调用reactive将其包装为代理对象,将代理对象返回,这样返回的是代理对象,那么任何对代理对象的操作都会被拦截
  • 只读和浅只读

    • 只读本质上也是代理,通过拦截set和delete实现只读
    • 深只读,只要递归调用只读函数即可

数组如何代理

  • 数组是特殊的对象,也是异质对象,主要是由于其内部方法DefineOwnProperty未遵循规范所致
    • 除了DefineOwnProperty外的其余内部方法均可沿用常规对象的代理方式

    • 数组的读取操作

      • arr[0] 数组元素值的访问,对应内部方法GET,拦截器get
      • arr.length 数组属性的访问,对应内部方法GET,拦截器get
      • for in 循环遍历key, 对应内部方法OwnPropertyKeys,拦截器ownKeys
      • for of 循环遍历value, 对应内部方法GET,拦截器get
      • concat,join,every,some,find,findIndex,includes等 数组方法的访问,没有对应的内部方法,只能通过重写方法,手动实现拦截
    • 数组的设置操作

      • arr[0] = 1 数组元素值的修改,对应内部方法SET,DefineOwnProperty,拦截器set
        • DefineOwnProperty会去比较索引值和属性length,隐式修改length,那么我们除了要将当前索引的副作用函数触发,也要将length的副作用函数触发
      • arr.length = 1 数组length属性(只有一个属性)的修改,对应内部方法SET,拦截器set
      • push,pop,shift,unshift 数组元素操作方法,也需要重写原始方法,手动触发响应
      • splice,fill,sort等,原数组修改的方法 也需要重写原始方法,手动触发响应

Set和Map如何代理

  • 与常规对象不同,Set,Map的数据有特定的属性和方法来操作
    • 数据的读取操作
      • set.size,集合的size属性访问,对应内部方法GET,拦截器get

        • 但是默认的get会从代理对象身上读取size,会报错,我们要修改为原始集合
      • set.forEach(),集合的forEach方法访问,需要重写该方法

        • 建立forEach和ITERATE_KEY的响应,在set和delete的时候去触发
        • 重写的forEach内部会调用原始的forEach,由于是通过原始对象调用的,那么forEach方法的回调函数收到的由调用者传入的key,value参数就是非响应式数据,会导致响应失效。
        • 我们需要对传递给回调函数的参数进行reactive的包装,然后在传递,保证响应式
      • set的迭代器方法,entries,keys,values,需要重写此类方法

        • 调用此类方法会返回迭代器,供forof消费,而且set和map本身也有iterate接口,可以直接被消费
        • forof会从被遍历的对象身上获取[Symbol.iterator]属性,得到迭代器然后不断调用next方法获取数据
        • 那我们可以通过重写[Symbol.iterator]属性返回的迭代器来实现响应式
        • entries会返回[Symbol.iterator]属性,与forof直接读取对象一致,直接使用以上方法即可
        • keys,values分别关心key,和value,那么对于entries中的key,value都进行响应式来分别调整即可
    • 数据的设置操作
      • set.delete(),集合的delete方法访问,需要重写该方法
        • 将重写的方法存储在mutableInstrumentations之下,同时使用bind来重新指定delete方法的this为原始对象
      • set.add(),集合的add方法访问,对应内部方法SET,拦截器set
        • 默认的set会将设置的value直接添加到原始对象身上,如果value是代理对象,我们称发生了数据污染
        • 为了避免数据污染,我们要判断属性值类型,如果是代理对象,通过代理对象的raw属性获取其原始对象,再将其设置到我们要设置的原始对象中即可
        • 为避免与用户设置的raw属性发生冲突,我们需要使用Symbol来包装raw保证唯一性

原始值的响应式方案

原始值的有哪些

  • Boolean,Number,BigInt,String,Symbol,undefined,null等

原始值如何代理

  • Proxy只能代理对象,不能代理原始值

  • 要想实现原始值的响应式,可以通过将其包裹在对象中,通过对象间接实现响应式

    • 创建一个wrapper对象,将需要实现响应式的原始值作为wrapper对象某个属性的值
    • 这样当修改原始值时,就会触发某个属性的set,进而触发响应式
    • 实现ref函数,接收原始值,创建wrapper对象,将原始值设置为属性value的值,对wrapper进行代理,将代理对象返回,我们调用ref函数得到代理对象,操作其value属性即可
    • 以上行为,就是我们最常见的访问器属性设置
  • 解决reactive数据,解构后丢失响应式问题

    • 实现toRef函数,接收响应式对象参数,创建临时对象,为响应式对象的每个属性在临时对象中创建对应的访问器属性,然后将响应式对象的该属性读取操作返回。最后将临时对象返回
    • 调用toRef函数,得到临时对象,将该对象作为响应式对象去解构,解构后得到的普通对象仍是通过访问器属性访问代理对象的属性,保证了响应式
  • 自动脱ref

    • 虽然通过toRef解决了reactive的响应式解构后丢失的问题,但由于访问的是临时对象的访问器属性,不得不每次都通过.value形式来获取,增加了用户的负担
    • 拦截get函数,判断当前属性是否是ref,如果是ref,则返回属性.value
    • 拦截set函数,判断当前属性是否是ref,如果是ref,则设置属性.value
    • 保证用户无需关心当前数据是否是ref

总结

  • 使用Proxy直接代理对象基本操作,再读取时收集副作用,设置时触发重新执行
  • 使用Proxy间接代理原始值,通过get,set拦截器获取原始值的变化,进行触发重新执行