Vue.js 设计与实现 笔记2 非原始值的响应式方案

113 阅读10分钟

理解Proxy和Reflect

Proxy和Reflect

Proxy

所谓代理,就是指对一个对象基本语义的代理,它允许我们拦截重新定义对一个对象的基本操作。

**基本语义:**给出一个对象,可以对它进行一些操作,例如读取、设置属性值这样的操作,就属于基本语义的操作,可以使用Proxy拦截。

const p = new Proxy(obj, {
  // 拦截读取属性操作
  get() { /* ... */ }
  // 拦截设置属性操作
  set() { /* ... */ }
})

在JavaScript中,万物皆对象,函数也是对象,所以调用函数也是对一个对象的基本操作,使用apply拦截函数的调用:

const fn = (name) => {
  console.log('我是', name)
}

// 调用函数是对对象的基本操作
fn()
const p2 = new Proxy(obj, {
  // 使用apply拦截函数调用
  apply(target, thisArg, argArray) {
    target.call(thisArg, ...argArray)
  }
})

p2("cpq") // 输出 我是cpq

Proxy只能拦截对对象的基本操作

**复合操作:**比如调用对象方法obj.fn(),调用对象的方法是由两个基本语义组成的,第一个基本语义是get,获得obj.fn属性; 第二个基本语义是函数调用,即通过get获得obj.fn的值后在调用它,也就是我们上面说到的apply。Proxy只能够代理对象的基本语义

Reflect

Reflect是一个全局对象,例如:

Peflect.get()
Reflect.set()
Reflect.apply()
// ...

Reflect和Proxy有很多方法名字相同,比如Reflect.get函数就是提供访问一个对象属性的默认行为,例如下面的两个操作时等价的:

const obj = { foo: 1 }
//  直接读取
console.log(obj.foo) // 1
//  使用Reflect.get读取
console.log(Reflect.get(obj, 'foo')) // 1
//  还支持第三个参数,即指定接收者,相当于函数调用过程中的this
console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 2

JavaScript对象及Proxy的工作原理

创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的。

  • Proxy对象部署的全部内部方法

image.png

如何代理Object

代理Object的本质,就是查阅规范并找到可拦截的基本操作的方法。有一些操作不是基本操作,而是复合操作,还需要查阅规范了解他们都依赖哪些基本操作,从而通过基本操作的拦截方法间接的处理复合操作。(简而言之:想要基于Proxy实现一个相对完善的响应式系统,免不了去了解ECMAScript规范)

合理的触发响应

  • 只有在新旧值不全等的时候才触发响应,但是有缺陷NaN的全等,NaN与NaN的全等总会得到false,所以要在新旧值都是全等的情况下,保证他们都不是NaN。
  • **receiver代理对象:**只有当receiver是target的代理对象时才触发更新,这样就能屏蔽由原型引起的更新,从而避免不必要的更新操作

浅响应与深响应

深响应

const obj = reactive({ foo: {bar: 1} })

effect(() => {
  console.log(obj.foo.bar)
})
// 修改obj.foo.bar不能触发响应
obj.foo.bar = 2

因为通过Reflect.get函数拿到的是普通对象,即{ bar: 1 },要解决这个问题,要对Reflect.get返回的结果做另一层的包装:

function reactive(obj) {
 return new Proxy(obj {
   get(target, key, receiver) {
     if (key === 'raw') {
       return target
     }
    
     track(target, key)
     // 得到原始值结果
     const res = Reflect.get(target, key, receiver)
     if (typeof res === 'object' && res !== null) {
       // 调用 reactive 将结果包装成响应式数据并返回
       return reactive(res)
     }
     // 返回 res
     return res
   }
  // 省略其他拦截函数
 })
}

浅响应

不是所有情况都需要深响应,所以出现了shallowReactive,即浅响应(指只有对象的第一层是可响应的)。 当读取属性操作发生时,在 get 拦截函数内如果发现是浅响应的,那么直接返回原始数据即可。

// 封装 createReactive 函数,接收一个参数 isShallow,代表是否为浅响应,默认为否
function createReactive(obj, isShallow = false) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') {
        return target;
      }
      
      const res = Relect.get(target, key, receiver);
      
      track(target, key);
      
      // 浅响应则直接返回
      if (isShallow) {
        return res;
      }
​
      if (typeof res === 'object' && res !== null) {
        return reactive(res);
      }
      return res;
    }
  })
}

现在可以轻松实现 reactive 与 shallowReactive 了。

function reactive(obj) {
  return createReactive(obj);
}
​
function shallowReactive(obj) {
  return createReactive(obj, true);
}

只读和浅只读

readonly函数

在createReactive函数中拦截设置和删除操作,如果是只读的,则打印警告信息并返回。 当一个对象是只读的,那就意味着任何方式都无法修改它,因此没有必要为只读数据建立响应式练习,所以当副作用函数读取一个只读属性值时,不需要调用track函数追踪响应。 为了实现深只读,需要递归地调用readonly将数据包装成只读的代理对象,并将其作为返回值返回。

function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') {
        return target;
      }
​
      if (!isReadonly) {
        track(target, key);
      }
      
      const res = Relect.get(target, key, receiver);
      
      track(target, key);
​
      if (isShallow) {
        return res;
      }
​
      if (typeof res === 'object' && res !== null) {
        // 如果数据只读,则调用 readonly 对值进行包装
        return isReadonly ? readonly(res) : reactive(res);
      }
      return res;
    }
  })
}
function readonly(obj) {
  return createReactive(obj, false, true);
}
​
function shallowReadonly(obj) {
  return createReactive(obj, true, true);
}

如上面的代码所示,我们在返回属性值之前,判断是否是只读的,如果是只读的,则调用readonly函数进行包装,并把包装后的只读对象返回。 在 shallowReadonly 函数内调用createReactive 函数创建代理对象时,将第二个参数 isShallow设置为 true,这样就可以创建一个浅只读的代理对象了。

代理数组

数组对象除了[[DefineOwnProperty]]内部方法以外,其他内部方法的逻辑都与常规对象的相同。当实现了数组的代理时,用于代理普通对象的大部分代码可以继续使用。 所有对数组对象读取的操作:

  • 通过索引访问数组元素值:arr[0]
  • 访问数组的长度: arr.length
  • 把数组作为对象,使用for...in循环遍历
  • 数组的原型方法,如concat/join/every/some/find/findIndex/includes等,以及其他所有不可变数组的原型方法。

所有对数组元素或属性的设置操作:

  • 通过索引修改数组元素值:arr[1] = 3
  • 修改数组长度:arr.length = 0
  • 修改数组长度:push/pop/shift/unshift
  • 修改原数组的原型方法:splice/fill/sort等

数组是一个异质对象,因为 数组对象部署的内部方法 [[DefineOwnProperty]] 不同于常规对 象。通过索引为数组设置新的元素,可能会隐式地改变数组 length 属性的值。对应地,修改数组 length 属性的值,也可能会间接影响 数组中的已有元素。所以在触发响应的时候需要额外注意。我们还讨 论了如何拦截 for...in 和 for...of 对数组的遍历操作。使用 for...in 循环遍历数组与遍历普通对象区别不大,唯一需要注意的 是,当追踪 for...in 操作时,应该使用数组的 length 作为追踪的 key。for...of 基于迭代协议工作,数组内建了 Symbol.iterator 方法。根据规范的 23.1.5.1 节可知,数组迭代器 执行时,会读取数组的 length 属性或数组的索引。因此,我们不需 要做其他额外的处理,就能够实现对 for...of 迭代的响应式支持。

我们还讨论了数组的查找方法。如 includes、indexOf 以及 lastIndexOf 等。对于数组元素的查找,需要注意的一点是,用户既 可能使用代理对象进行查找,也可能使用原始对象进行查找。为了支 持这两种形式,我们需要重写数组的查找方法。原理很简单,当用户 使用这些方法查找元素时,我们可以先去代理对象中查找,如果找不 到,再去原始数组中查找。

我们还介绍了会隐式修改数组长度的原型方法,即 push、pop、 shift、unshift 以及 splice 等方法。调用这些方法会间接地读取 和设置数组的 length 属性,因此,在不同的副作用函数内对同一个 数组执行上述方法,会导致多个副作用函数之间循环调用,最终导致 调用栈溢出。为了解决这个问题,我们使用一个标记变量 shouldTrack 来代表是否允许进行追踪,然后重写了上述这些方法, 目的是,当这些方法间接读取 length 属性值时,我们会先将 shouldTrack 的值设置为 false,即禁止追踪。这样就可以断开 length 属性与副作用函数之间的响应联系,从而避免循环调用导致的 调用栈溢出。

代理Set和Map

Set

Set类型的原型属性方法如下:

  • size:返回集合中元素的数量
  • add(value):向集合中添加给定的值
  • clear(): 清空集合
  • delete(value): 从集合中删除给定的值
  • has(value): 判断集合中是否存在给定的值
  • keys(): 返回一个迭代器,可用于for...of循环,迭代器对象返回的值为集合中元素值。
  • values(): 对于Set集合类型来说,keys()与values()等价
  • entries(): 返回一个迭代器对象。迭代过程中集合中的每一个元素产生一个数组值[value, value]
  • forEach(callback[, thisArg]): forEach函数会遍历集合中的所有元素,并对每一个元素调用callback函数,forEach函数接收可选的第二个参数thisArg,用于指定callback函数执行时的this值。

Map

  • size: 返回Map数据中的键值对数量
  • clear(): 清空Map
  • delete(key):删除指定key的键值对
  • has(key): 判断Map中是否存在指定key的键值对
  • get(key): 读取指定key对应的值
  • set(key, value): 为Map设置新的键值对
  • keys():返回一个迭代器对象,迭代过程中会产生键值对的key值
  • values(): 返回一个迭代器对象,迭代过程中会产生键值对的value值
  • entires(): 返回一个迭代器对象,迭代过程中会产生由[key, value]组成的数组值
  • forEach(callback[, thisArg]): forEach函数会遍历Map数据所有键值对,并对每一个键值对调用callback函数,forEach函数会接收第二个参数thisArg,用于指定callback函数执行时的this值

如何代理Set和Map

总结

集合类型指 Set、Map、WeakSet 以及 WeakMap。我们讨论了使用 Proxy 为集合类型创建代理对象的一些注意事项。集合类型不同于普通对象,它有特定的数据操作方法。当使用 Proxy 代理集合类型的数据时要格外注意,例如,集合类型的 size 属性是一个访问器属性,当通过代理对象访问 size 属性时,由于代理对象本身并没有部署[[SetData]] 这样的内部槽,所以会发生错误。另外,通过代理对象执行集合类型的操作方法时,要注意这些方法执行时的 this 指向,我们需要在 get 拦截函数内通过.bind 函数为这些方法绑定正确的 this 值。我们还讨论了集合类型响应式数据的实现。我们需要通过“重写”集合方法的方式来实现自定义的能力,当 Set 集合的 add 方法执行时,需要调用 trigger 函数触发响应。我们也讨论了关于“数据污染”的问题。 数据污染指的是不小心将响应式数据添加到原始数据中,它导致用户可以通过原始数据执行响应式相关操作,这不是我们所期望的。为了 避免这类问题发生,我们通过响应式数据对象的 raw 属性来访问对应的原始数据对象,后续操作使用原始数据对象就可以了。我们还讨论了关于集合类型的遍历,即 forEach 方法。集合的 forEach 方法与对象的 for...in 遍历类似,最大的不同体现在,当使用 for...in 遍历对象时,我们只关心对象的键是否变化,而不关心值;但使用 forEach 遍历集合时,我们既关心键的变化,也关心值的变化。