《Vuejs设计与实现》第 5 章(非原始值响应式方案)下 代理数组
目录
5.7 代理数组
5.7.2 遍历数组
5.7.3 数组查找
5.7.4 隐式修改数组长度的原型方法
5.7 代理数组
JS 中的数组是一种异质对象,其 [[DefineOwnProperty]] 内部方法与常规对象不同。
但除此之外,数组的其他内部方法与常规对象相同。因此,在实现数组代理时,大部分用于代理普通对象的代码依然适用,如下:
const arr = reactive(['foo']) // 数组的原长度为 1effect(() => {console.log(arr.length) // 1
})// 设置索引 1 的值,会导致数组的长度变为 2
arr[1] = 'bar'
为了实现这个目标,我们需要修改 set 拦截函数:
function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {// 拦截设置操作set(target, key, newVal, receiver) {if (isReadonly) {console.warn(`属性 ${key} 是只读的`)return true}const oldVal = target[key]// 如果属性不存在,则说明是在添加新的属性,否则是设置已有属性const type = Array.isArray(target)? // 如果代理目标是数组,则检测被设置的索引值是否小于数组长度,// 如果是,则视作 SET 操作,否则是 ADD 操作Number(key) < target.length? 'SET': 'ADD': Object.prototype.hasOwnProperty.call(target, key)? 'SET': 'ADD'const res = Reflect.set(target, key, newVal, receiver)if (target === receiver.raw) {if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {trigger(target, key, type)}}return res}// 省略其他拦截函数})
}
在判断操作类型时,我们新增了对数组类型的判断。如果代理的目标对象是数组,那么对于操作类型的判断会有所区别。
接下来,我们可以在 trigger 函数中正确地触发与数组对象的 length 属性相关联的副作用函数重新执行:
function trigger(target, key, type) {const depsMap = bucket.get(target)if (!depsMap) return// 省略部分内容// 当操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length 属性相关联的副作用函数if (type === 'ADD' && Array.isArray(target)) {// 取出与 length 相关联的副作用函数const lengthEffects = depsMap.get('length')// 将这些副作用函数添加到 effectsToRun 中,待执行lengthEffects &&lengthEffects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn)}})}effectsToRun.forEach(effectFn => {if (effectFn.options.scheduler) {effectFn.options.scheduler(effectFn)} else {effectFn()}})
}
这样,我们就实现了当数组长度发生变化时,正确地触发与 length 属性相关联的副作用函数重新执行。
在另一方面,实际上修改数组的 length 属性也会隐式地影响数组元素。例如:
const arr = reactive(['foo'])effect(() => {// 访问数组的第 0 个元素console.log(arr[0]) // foo
})// 将数组的长度修改为 0,导致第 0 个元素被删除,因此应该触发响应
arr.length = 0
然而,并非所有对 length 属性的修改都会影响数组中的已有元素。
上面如果我们将 length 属性设置为 100,这并不会影响第 0 个元素,所以也就不需要触发副作用函数重新执行
当修改 length 属性值时,只有那些索引值大于或等于新的 length 属性值的元素才需要触发响应。
为了实现这一目标,我们需要修改 set 拦截函数。在调用 trigger 函数触发响应时,应该把新的属性值传递过去:
function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {// 拦截设置操作set(target, key, newVal, receiver) {if (isReadonly) {console.warn(`属性 ${key} 是只读的`)return true}const oldVal = target[key]const type = Array.isArray(target)? Number(key) < target.length? 'SET': 'ADD': Object.prototype.hasOwnProperty.call(target, key)? 'SET': 'ADD'const res = Reflect.set(target, key, newVal, receiver)if (target === receiver.raw) {if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {// 增加第四个参数,即触发响应的新值trigger(target, key, type, newVal)}}return res}})
}
接着,我们还需要修改 trigger 函数:
// 为 trigger 函数增加第四个参数,newVal,即新值
function trigger(target, key, type, newVal) {const depsMap = bucket.get(target)if (!depsMap) return// 省略其他代码// 如果操作目标是数组,并且修改了数组的 length 属性if (Array.isArray(target) && key === 'length') {// 对于索引大于或等于新的 length 值的元素,// 需要把所有相关联的副作用函数取出并添加到 effectsToRun 中待执行depsMap.forEach((effects, index) => {if (index >= newVal) {effects.forEach(effectFn => {if (effectFn !== activeEffect) {effectsToRun.add(effectFn)}})}})}effectsToRun.forEach(effectFn => {if (effectFn.options.scheduler) {effectFn.options.scheduler(effectFn)} else {effectFn()}})
}
5.7.2 遍历数组
既然数组也是对象,就意味着我们同样可以使用 for...in 循环遍历数组:
const arr = reactive(['foo'])effect(() => {for (const key in arr) {console.log(key) // 0}
})
但是我们应该尽量避免使用 for...in 循环遍历数组,
前面说数组对象和常规对象的不同仅体现在 [[DefineOwnProperty]] 这个内部方法上。
因此,使用 for...in 循环遍历数组与遍历常规对象并无差异,可以使用 ownKeys 拦截函数进行拦截。
function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {// 省略其他拦截函数ownKeys(target) {track(target, ITERATE_KEY)return Reflect.ownKeys(target)}})
}
上述代码取自前文,我们为了追踪对普通对象的 for...in 操作,创建了 ITERATE_KEY 作为追踪的 key。
然而,这是为了代理普通对象而考虑的。对于普通对象来说,只有当添加或删除属性值时才会影响 for...in 循环的结果,这时候就需要取出与 ITERATE_KEY 相关联的副作用函数重新执行。
对于数组来说,情况有所不同。我们看看哪些操作会影响 for...in 循环对数组的遍历:
- 添加新元素:arr[100] = 'bar'
- 修改数组长度:arr.length = 0
实际上,无论是为数组添加新元素,还是直接修改数组的长度,本质上都是因为修改了数组的 length 属性。一旦数组的 length 属性被修改,那么 for...in 循环对数组的遍历结果就会改变。
所以,在这种情况下我们应该触发响应。我们可以在 ownKeys 拦截函数内,判断当前操作目标 target 是否是数组,如果是,则使用 length 作为 key 建立响应联系:
function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {// 省略其他拦截函数ownKeys(target) {// 如果操作目标 target 是数组,则使用 length 属性作为 key 并建立响应联系track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)return Reflect.ownKeys(target)}})
}
这样,无论是为数组添加新元素,还是直接修改 length 属性,都能够正确地触发响应:
const arr = reactive(['foo'])effect(() => {for (const key in arr) {console.log(key
}
})arr[1] = 'bar' // 能够触发副作用函数重新执行
arr.length = 0 // 能够触发副作用函数重新执行
现在,当我们为数组添加新元素或直接修改 length 属性时,都能正确地触发响应。这样,我们已经解决了数组在遍历时可能遇到的问题。
讲解了使用 for...in 遍历数组,接下来我们再看看使用 for...of 遍历数组的情况。
for...in 遍历数组与 for...of 遍历数组的区别在于,for...of 用于遍历可迭代对象(iterable object)。可迭代对象是实现了 @@iterator 方法的对象,例如 Symbol.iterator 方法。
下面创建一个实现了 Symbol.iterator 方法的对象:
const obj = {val: 0,[Symbol.iterator]() {return {next() {return {value: obj.val++,done: obj.val > 10 ? true : false}}}}
}for (const value of obj) {console.log(value) // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
}
数组内建了 Symbol.iterator 方法的实现,我们可以手动执行迭代器的 next 函数,这样也可以得到期望的结果。这也是默认情况下数组可以使用 for...of 遍历的原因:
const arr = [1, 2, 3, 4, 5]// 获取并调用数组内建的迭代器方法
const itr = arr[Symbol.iterator]()console.log(itr.next()) // {value: 1, done: false}
console.log(itr.next()) // {value: 2, done: false}
console.log(itr.next()) // {value: 3, done: false}
console.log(itr.next()) // {value: 4, done: false}
console.log(itr.next()) // {value: 5, done: false}
console.log(itr.next()) // {value: undefined, done: true}for (const val of arr) {console.log(val) // 1, 2, 3, 4, 5
}
数组迭代器的执行会读取数组的 length 属性。如果迭代的是数组元素值,还会读取数组的索引。我们可以给出一个数组迭代器的模拟实现:
const arr = [1, 2, 3, 4, 5]arr[Symbol.iterator] = function () {const target = thisconst len = target.lengthlet index = 0return {next() {return {value: index < len ? target[index] : undefined,done: index++ >= len}}}
}
上述代码,我们用自定义的实现覆盖了数组内建的迭代器方法,但它仍然能够正常工作。
这个例子表明,迭代数组时,只需要在副作用函数与数组的长度和索引之间建立响应联系,就能够实现响应式的 for...of 迭代:
const arr = reactive([1, 2, 3, 4, 5])effect(() => {for (const val of arr) {console.log(val)}
})arr[1] = 'bar' // 能够触发响应
arr.length = 0 // 能够触发响应
注意,在副作用函数与 Symbol.iterator 这类 symbol 值之间建立响应联系时,需要避免发生意外的错误,以及性能上的考虑。因此需要修改 get 拦截函数:
function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {// 拦截读取操作get(target, key, receiver) {console.log('get: ', key)if (key === 'raw') {return target}// 添加判断,如果 key 的类型是 symbol,则不进行追踪if (!isReadonly && typeof key !== 'symbol') {track(target, key)}const res = Reflect.get(target, key, receiver)if (isShallow) {return res}if (typeof res === 'object' && res !== null) {return isReadonly ? readonly(res) : reactive(res)}return res}})
}
在调用 track 函数进行追踪之前,需要添加一个判断条件,即只有当 key 的类型不是 symbol 时才进行追踪,这样就避免了上述问题。
5.7.3 数组查找
通过之前的学习,我们了解到数组的内部方法大都依赖于对象的基础语义。通常情况下,不需特殊处理就可以正常使用。例如:
const arr = reactive([1, 2])effect(() => {console.log(arr.includes(1)) // 初始打印 true
})arr[0] = 3 // 副作用函数重新执行,并打印 false
这是因为 includes 方法在寻找特定值时,会访问数组的 length 属性以及数组索引。因此,当我们更改某个索引指向的元素值时,就能触发响应。
但 includes 方法并不总是按预期工作,例如:
const obj = {}
const arr = reactive([obj])console.log(arr.includes(arr[0])) // false
在这段代码中,我们创建一个对象 obj 并将其作为数组的第一个元素。
然后创建一个响应式数组,并尝试使用 includes 方法查找数组中是否包含第一个元素。这个操作应该返回 true,但实际上返回 false。
includes 方法通过索引读取数组元素的值,但是这里的 0 是代理对象 arr。所以,通过代理对象来访问元素值时,如果值还可以被代理,那么返回的是新的代理对象而非原始对象。以下代码可以证明这一点:
if (typeof res === 'object' && res !== null) {// 如果值可以被代理,则返回代理对象return isReadonly ? readonly(res) : reactive(res)
}
在arr.includes(arr[0])中,arr[0] 得到的是一个代理对象,而在 includes 方法内部通过 arr 访问数组元素时也得到一个代理对象。
但这两个代理对象是不同的。这是因为每次调用 reactive 函数都会创建一个新的代理对象。解决方案如下:
// 定义一个 Map 实例,存储原始对象到代理对象的映射
const reactiveMap = new Map()function reactive(obj) {// 优先通过原始对象 obj 寻找之前创建的代理对象,如果找到了,直接返回已有的代理对象const existionProxy = reactiveMap.get(obj)if (existionProxy) return existionProxy// 否则,创建新的代理对象const proxy = createReactive(obj)// 存储到 Map 中,从而避免重复创建reactiveMap.set(obj, proxy)return proxy
}
当前的行为已经达到了预期。但是,我们不能过早地庆祝。让我们再来看一下以下的代码:
const obj = {}
const arr = reactive([obj])console.log(arr.includes(obj)) // false
在这段代码中,返回 false 令人费解,这是因为 includes 方法内部的 this 指向的是代理对象 arr,并且在获取数组元素时得到的也是代理对象,因此当我们使用原始对象 obj 进行查找时,肯定找不到,从而返回 false。
为了解决这个问题,我们需要重写数组的 includes 方法并实现自定义的行为:
const arrayInstrumentations = {includes: function () {/* ... */}
}function createReactive(obj, isShallow = false, isReadonly = false) {return new Proxy(obj, {// 拦截读取操作get(target, key, receiver) {console.log('get: ', key)if (key === 'raw') {return target}// 如果操作的目标对象是数组,并且 key 存在于 arrayInstrumentations 上,// 那么返回定义在 arrayInstrumentations 上的值if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {return Reflect.get(arrayInstrumentations, key, receiver)}if (!isReadonly && typeof key !== 'symbol') {track(target, key)}const res = Reflect.get(target, key, receiver)if (isShallow) {return res}if (typeof res === 'object' && res !== null) {return isReadonly ? readonly(res) : reactive(res)}return res}})
}
在上述代码中,我们修改了 get 拦截函数,以重写数组的 includes 方法。
执行 arr.includes 时,实际执行的是定义在 arrayInstrumentations 上的 includes 函数,这样我们就重写了这个方法。
接下来,我们可以自定义 includes 函数:
const originMethod = Array.prototype.includes
const arrayInstrumentations = {includes: function (...args) {// this 是代理对象,先在代理对象中查找,将结果存储到 res 中let res = originMethod.apply(this, args)if (res === false) {// res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找并更新 res 值res = originMethod.apply(this.raw, args)}// 返回最终结果return res}
}
在上述代码中,includes 方法内的 this 指向的是代理对象,我们首先在代理对象中进行查找,这其实是 arr.include(obj) 的默认行为。
如果在代理对象中找不到,我们会通过 this.raw 获取原始数组,然后在其中进行查找,最后返回结果。这样就解决了先前提到的问题。运行以下测试代码:
const obj = {}
const arr = reactive([obj])console.log(arr.includes(obj)) // true
你会发现,现在代码的行为已经符合预期。
除了 includes 方法,还有一些其他的数组方法,如 indexOf 和 lastIndexOf,也需要进行类似的处理,因为这些方法都是根据给定的值返回查找结果。以下是完整的代码:
const arrayInstrumentations = {};['includes', 'indexOf', 'lastIndexOf'].forEach(method => {const originMethod = Array.prototype[method]arrayInstrumentations[method] = function (...args) {// this 是代理对象,先在代理对象中查找,将结果存储到 res 中let res = originMethod.apply(this, args)if (res === false || res === -1) {// res 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找,并更新 res 值res = originMethod.apply(this.raw, args)}// 返回最终结果return res}
})
5.7.4 隐式修改数组长度的原型方法
push 方法会读取并设置数组的 length 属性,这可能导致两个独立的副作用函数相互影响。例如:
const arr = reactive([])
// 第一个副作用函数
effect(() => {arr.push(1)
})// 第二个副作用函数
effect(() => {arr.push(1)
})
上述代码在运行时会导致栈溢出错误(Maximum call stack size exceeded)。
这是因为,两个副作用函数都在执行 push 操作,既读取了 length 属性,又设置了 length 属性。
第一个副作用函数执行完毕后,会与 length 属性建立响应关系。当第二个副作用函数执行时,也会与 length 属性建立响应关系,同时设置 length 属性。这导致了第一个副作用函数的重新执行,从而形成了无限循环,最终导致栈溢出。
解决方法是“屏蔽”对 length 属性的读取,防止在 length 属性和副作用函数之间建立响应关系。
这是因为数组的 push 操作本质上是修改操作,而非读取操作。避免建立响应联系并不会产生其他副作用。
重写数组的 push 方法:
// 一个标记变量,代表是否进行追踪。默认值为 true,即允许追踪
let shouldTrack = true
// 重写数组的 push 方法
;['push'].forEach(method => {// 取得原始 push 方法const originMethod = Array.prototype[method]// 重写arrayInstrumentations[method] = function (...args) {// 在调用原始方法之前,禁止追踪shouldTrack = false// push 方法的默认行为let res = originMethod.apply(this, args)// 在调用原始方法之后,恢复原来的行为,即允许追踪shouldTrack = truereturn res}
})
在上述代码中,我们在执行 push 方法的默认行为前后,分别禁止和允许追踪。
我们还需要相应地修改 track 函数,代码如下:
function track(target, key) {if (!activeEffect || !shouldTrack) return// 省略部分代码
}
这样,当 push 方法间接读取 length 属性时,由于此时是禁止追踪的状态,所以 length 属性与副作用函数之间不会建立响应联系。
这样就解决了上文的问题。我们再次尝试运行测试代码:
const arr = reactive([])
// 第一个副作用函数
effect(() => {arr.push(1)
})// 第二个副作用函数
effect(() => {arr.push(1)
})
除了 push 方法,我们还需要对 pop、shift、unshift 和 splice 等方法进行类似的处理。完整的代码如下:
let shouldTrack = true
// 重写数组的 push、pop、shift、unshift 和 splice 方法
;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {const originMethod = Array.prototype[method]arrayInstrumentations[method] = function(...args) {shouldTrack = falselet res = originMethod.apply(this, args)shouldTrack = truereturn res}
})