实现数组属性读取、修改、新增和删除的拦截

2024-05-14 19:30:41

前面已经把对象的响应式处理得差不多了,但数组还有一些特殊情况没有处理。

先把这一篇要解决的问题列出来:

  • 数组查找方法要能兼容代理对象和原始对象
  • 修改索引时,要能同步处理 length
  • 直接修改 length 时,要能触发被删除索引的更新
  • 数组变更方法内部的读取行为,不应该误收集依赖

数组查找的问题

先看一段代码:

import { reactive } from './reactive.js'

const obj = {
  a: 1
}

const array = [obj]
const proxyArray = reactive(array)

proxyArray[0]
proxyArray.length

for (const key in proxyArray) {
  console.log(key)
}

for (let i = 0; i < proxyArray.length; i++) {
  proxyArray[i]
}

console.log(proxyArray.includes(obj))

上面这些操作里,索引读取、length 读取和遍历都没有问题,都会正常进入代理拦截。

proxyArray.includes(obj) 会返回 false

原因是:数组里存的是原始对象 obj,而通过 proxyArray[i] 读取出来的是代理对象。两者不是同一个引用,所以 includes 找不到。

通过 RAW 获取原始对象

解决这个问题的思路很直接:先在代理数组里找,找不到再去原始数组里找。

为了拿到原始对象,先定义一个不会冲突的标识:

// constant/enum.js
export const RAW = Symbol('raw')

然后在 get 里处理这个标识:

// behavior/index.js
function get(target, key) {
  if (key === RAW) {
    return target
  }

  track(target, TrackOpTypes.GET, key)
  // ...
}

这样代理对象就能通过 proxy[RAW] 拿到原始对象。

重写数组查找方法

接下来单独处理数组的 includesindexOflastIndexOf

如果直接在 get 里写判断,会显得很乱,所以把这些方法单独放到 arrayInstrumentation 里:

const arrayInstrumentation = {}

;['includes', 'indexOf', 'lastIndexOf'].forEach((key) => {
  arrayInstrumentation[key] = function (...args) {
    const res = Array.prototype[key].apply(this, args)

    if (res < 0 || res === false) {
      return Array.prototype[key].apply(this[RAW], args)
    }

    return res
  }
})

然后在 get 中优先返回这些被重写的方法:

function get(target, key) {
  if (key === RAW) {
    return target
  }

  track(target, TrackOpTypes.GET, key)

  if (Array.isArray(target) && Object.hasOwn(arrayInstrumentation, key)) {
    return arrayInstrumentation[key]
  }

  const result = Reflect.get(target, key)

  if (isObject(result)) {
    return reactive(result)
  }

  return result
}

这样数组方法会先在代理数组上查一次,如果没找到,就再到原始数组上查一次。

处理隐式修改 length

数组还有一个特殊点:修改索引时,可能会隐式修改 length

proxyArray[10] = 100

这次操作会触发索引 10 的新增,但同时也会让数组的 length 发生变化。

如果只对索引 10 派发更新,那么依赖 length 的副作用函数就不会重新执行。所以在 set 中需要先记录旧的 length,赋值完成后再判断 length 是否发生变化。

function set(target, key, value) {
  const type = Object.hasOwn(target, key)
    ? TriggerOpTypes.SET
    : TriggerOpTypes.ADD

  const oldValue = Reflect.get(target, key)
  const oldLength = Array.isArray(target) ? target.length : undefined
  const result = Reflect.set(target, key, value)

  if (hasChanged(oldValue, value)) {
    trigger(target, type, key)

    if (Array.isArray(target) && oldLength !== target.length) {
      trigger(target, TriggerOpTypes.SET, 'length')
    }
  }

  return result
}

这样通过索引新增数组元素时,length 也能正常派发更新。

处理直接修改 length

还有一种情况是直接修改 length

proxyArray.length = 0

这次操作会触发 length 的修改,同时也会删除数组中原有的元素。

所以如果 length 变小了,除了派发 length 的更新,还需要对被删除的索引派发删除更新:

function set(target, key, value) {
  const type = Object.hasOwn(target, key)
    ? TriggerOpTypes.SET
    : TriggerOpTypes.ADD

  const oldValue = Reflect.get(target, key)
  const oldLength = Array.isArray(target) ? target.length : undefined
  const result = Reflect.set(target, key, value)

  if (hasChanged(oldValue, value)) {
    trigger(target, type, key)

    if (Array.isArray(target) && oldLength !== target.length) {
      if (key !== 'length') {
        trigger(target, TriggerOpTypes.SET, 'length')
      }
      else {
        for (let i = target.length; i < oldLength; i++) {
          trigger(target, TriggerOpTypes.DELETE, i.toString())
        }
      }
    }
  }

  return result
}

这样执行 proxyArray.length = 0 时,依赖数组元素的副作用函数也能被重新触发。

控制数组方法的依赖收集

数组的 pushpopshiftunshiftsplice 这些方法内部都会读写 length

调用这些方法时,内部读取 length 也会触发依赖收集。但其实是没必要的

所以需要提供两个方法,用来暂停和恢复依赖收集。

// effect/track.js
let shouldTrack = true

export function pauseTracking() {
  shouldTrack = false
}

export function resumeTracking() {
  shouldTrack = true
}

function track(target, type, key) {
  if (!shouldTrack) {
    return
  }

  console.log('track', type, key)
}

export default track

然后在数组变更方法执行前暂停依赖收集,执行完成后再恢复:

// behavior/index.js
;['push', 'pop', 'shift', 'unshift', 'splice'].forEach((method) => {
  arrayInstrumentation[method] = function (...args) {
    pauseTracking()
    const res = Array.prototype[method].apply(this, args)
    resumeTracking()
    return res
  }
})

这样数组方法内部对 length 的读取就不会被误收集。

总结

  • includesindexOflastIndexOf 可以兼容代理对象和原始对象
  • 通过索引新增元素时,可以同步触发 length 更新
  • 直接修改 length 时,可以触发被删除索引的更新
  • 调用数组变更方法时,可以暂停内部读取导致的依赖收集

这样就可以继续,开始处理 effect 的逻辑。