Vue3是如何代理数组实现响应式的1:数组的索引与length

1,029 阅读3分钟

如果你阅读过Vue3源码,一定知道Vue3是通过ES6新增的Proxy对象实现了响应式机制,今天我们就来聊一聊Vue3是如何代理数组实现响应式的。在JavaScript中万物皆对象,数组也是一个特殊的对象,数组除了[[DefineOwnProperty]]内部方法与常规对象不同外其他内部方法都一样,因此当实现对数组的代理时,大部分代理对象的代码都可以继续使用:

const arr = reactive(['foo'])
effect(() => {
  console.log(arr[0])
})
arr[0] = 'bar'

当我们通过arr[0]读取和修改数组项时,get/set拦截函数也会执行,所以上面这段代码会按照预期执行。但是数组的读取和设置操作不仅仅有这些,还有arr.length,for...in,for...of,includes,push等方法,我们需要对这些操作正确地建立响应式联系或触发依赖。

首先我们来看对数组索引和length的操作,观察如下代码:

const arr = reactive(['foo'])
effect(() => {
  console.log(arr[0])
})
arr[1] = 'bar'

我们通过索引设置数组元素与设置对象属性仍然存在根本上的不同,因为数组对象部署的内部方法[[DefineOwnProperty]]规定:如果设置的索引值大于数组当前长度,那么要更新数组的length属性,所以当我们通过索引值设置元素时,可能会隐式地修改数组的length属性,因此在触发响应时,也应当触发与length相关的副作用,我们修改set拦截函数:

const p = new Proxy(obj,{
  set(target,key,newVal,receiver){
   const type = Array.isArray(target)
          ? Number(key) < target.length ? TriggerOpTypes.SET : TriggerOpTypes.ADD
          : Object.prototype.hasOwnProperty.call(target, key) ? TriggerOpTypes.SET : TriggerOpTypes.ADD
        const res = Reflect.set(target, key, value)
        trigger(target, key, type, value)
        return res
  }
})

如果target是数组,检查被设置的索引值是否小于数组长度,如果是则视为SET操作,因为他不会改变数组长度,否则是ADD操作,因为这会隐式地修改数组的length属性,接下来我们也要修改trigger函数,在target是数组且操作类型为ADD时,应该取出并执行那些与length相关的副作用函数:

export const trigger = (target, key, type: TriggerOpTypes, newValue: unknown) => {
  //...
  if (type === TriggerOpTypes.ADD) {
      //如果target是数组且操作类型为ADD,应该取出并执行那些与length相关的副作用函数
      if (Array.isArray(target)) {
          deps.push(depsMap.get('length'))
      } else {
          deps.push(depsMap.get(ITERATE_KEY))
      }
  } else if (type === TriggerOpTypes.DELETE) {
      deps.push(depsMap.get(ITERATE_KEY))
  }
  //...
}

同样的,当我们修改数组的length属性时也有可能隐式地影响数组元素:

const arr = reactive(['foo'])
effect(() => {
  console.log(arr[0])
})
arr.length = 0

上面代码中我们在副作用中访问了数组第0个元素,接着将数组的length属性修改为0,这时候数组的长度变成了0,即所有的元素都被删除,所以副作用函数应当重新执行,但是目前我们没实现这点,所以接下来需要修改set拦截函数以及trigger:

function createSetter() {
    return (target: any, key: string | symbol, value: any) => {
        //...
        trigger(target, key, type, value)
      	//...
    }
}

首先我们要把更新后的value传递给trigger方法,之后继续修改trigger方法:

export const trigger = (target, key, type: TriggerOpTypes, newValue: unknown) => {
  //...
 if (Array.isArray(target) && key === 'length') {
    //如果target是数组并且修改了数组的长度,对于索引大于等于新length的元素
    //需要把他们关联的副作用函数取出来并执行
    const newLength = Number(newValue)
    depsMap.forEach((dep, key) => {
        if (key === 'length' || key >= newLength) {
            deps.push(dep)
        }
    })
  }
  //...
}

如果target是数组并且修改了数组的长度,对于索引大于等于新length的元素,需要把他们关联的副作用函数取出来并执行,这样我们修改数组的length也能正确地触发依赖了。