实现数组属性读取、修改、新增和删除的拦截
前面已经把对象的响应式处理得差不多了,但数组还有一些特殊情况没有处理。
先把这一篇要解决的问题列出来:
- 数组查找方法要能兼容代理对象和原始对象
- 修改索引时,要能同步处理
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] 拿到原始对象。
重写数组查找方法
接下来单独处理数组的 includes、indexOf 和 lastIndexOf。
如果直接在 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 时,依赖数组元素的副作用函数也能被重新触发。
控制数组方法的依赖收集
数组的 push、pop、shift、unshift、splice 这些方法内部都会读写 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 的读取就不会被误收集。
总结
includes、indexOf、lastIndexOf可以兼容代理对象和原始对象- 通过索引新增元素时,可以同步触发
length更新 - 直接修改
length时,可以触发被删除索引的更新 - 调用数组变更方法时,可以暂停内部读取导致的依赖收集
这样就可以继续,开始处理 effect 的逻辑。