非原始值的响应式方案 - Array

在 JavaScript 中,数据只是一个特殊的对象,因此想要更好地实现对数组的代理,就有必要了解相比普通对象,数组到底有有何特殊之处。

我们知道 JavaScript 有两种对象,分别是常规对象和异质对象。数组就是一个异质对象,因为数组对象的 [[DefineOwnProperty]] 内部方法与常规对象不同。换句话说,数组对象处理 [[DefineOwnProperty]] 这个内部方法之外,其他内部方法的逻辑都与常规对象相同。因此,当实现对数组的代理时,用于代理普通对象的大部分代码都可以继续使用。

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

上面这段代理能够按预期工作。实际上,当我们通过索引或设置数组元素的值时,代理对象的 get/set 拦截函数也会执行,因此我们不需要做任何额外的工作,就能够让数组索引的读取和设置操作是响应式。

对数组操作与普通对象的操作仍然存在不同,下面总结了所有对数组元素或属性的 “读取” 操作:

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

对数组的操作要比普通对象丰富得多。下面来看下对数组元素或属性 的设置操作有哪些。

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

除了通过数组索引修改数据元素值这种基本操作之外,数组本身还有很多会修改原数组的原型方法。调用这些方法也属于对数组的操作,有些方法的操作语义是 “读取”,有些方法的操作语义是 “设置”。因此,当这些操作发生时,也应该正确地建立响应联系或触发响应。

似乎代理数组的难度要比代理普通对象的难度大很多。但事实并非如此,因为数组本身也是对象,只不过它是异质对象,它与常规对象的差异并不大。因此,大部分用来代理常规对象的代码对于数组也是生效的。

数组的索引与 length

通过数组的索引访问元素的值时,已经可以建立响应联系。

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

但通过索引设置数组的元素值与设置对象的属性值从根本上是不同的,这是因为数组部署的内部 DefineOwnProperty 不同于常规对象。实际上,当我们通过索引设置数组元素的值时,会执行数组对象所部署的内部方法 [[Set]] ,这一步与设置常规对象的属性值一样。根据规范可知,内部方法 [[Set]] 其实依赖于 [[DefineOwnProperty]] ,到了这里就体现出了差异。

数组对象所部署的内部方法 [[DefineOwnProperty]] 的逻辑定义在规范的 10.4.2.1 节。

https://tc39.es/ecma262/#sec-array-exotic-objects-defineownproperty-p-desc

1. If P is "length", then a. Return ? ArraySetLength(A, Desc). 2. Else if P is an array index, then a. Let oldLenDesc be OrdinaryGetOwnProperty(A, "length"). b. Assert: IsDataDescriptor(oldLenDesc) is true. c. Assert: oldLenDesc.[[Configurable]] is false. d. Let oldLen be oldLenDesc.[[Value]]. e. Assert: oldLen is a non-negative integral Number. f. Let index be ! ToUint32(P). g. If index ≥ oldLen and oldLenDesc.[[Writable]] is false, return false. h. Let succeeded be ! OrdinaryDefineOwnProperty(A, P, Desc). i. If succeeded is false, return false. j. If index ≥ oldLen, then i. Set oldLenDesc.[[Value]] to index + 1𝔽. ii. Set succeeded to ! OrdinaryDefineOwnProperty(A, "length", oldLenDesc). iii. Assert: succeeded is true. k. Return true. 3. Return ? OrdinaryDefineOwnProperty(A, P, Desc).

第 2 步的 j 子步骤描述的内容如下:

  1. 如果 index >= oldLen , 那么
    1. oldLenDesc.[[Value]] 设置为 index + 1
    2. 让 succeeded 的值为 OrdinaryDefineOwnProperty(A, 'length', oldLenDesc)
    3. 断言:succeeded 是 true

可以看到,规范中明确说明,如果设置的索引值大于数组当前的长度,那么要更新数组的 length 数组。所以当通过索引设置元素值时,可能会隐式地修改 length 的属性值。因此在触发响应响应时,也应该触发与 length 属性相关联的副作用函数重新执行。

const arr = reactive(['foo']); effect(() => { console.log(arr.length); // 1 }); // 设置索引为 1 的值,会导致数组的长度变为 2 arr[1] = 'bar';

数据的原长度为 1,并且在副作用函数中访问了 length 属性。然后设置数组索引为 1 的元素值,这会导致数组的长度变为 2,因此应该触发副作用函数重新执行。但目前的实现还做不到这一点,为了实现目标,我们需要修改 set 拦截函数。

function crateReactive (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 ? TRIGGER_TYPE.SET : TRIGGER_TYPE.ADD : Object.prototype.hasOwnProperty.call(target, key) ? TRIGGER_TYPE.SET : TRIGGER_TYPE.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; }, // ... }); }

我们在判断操作类型时,新增了对数组类型的校验。如果代理的目标对象是数组,那么对于操作类型的判断会有所区别。即被设置的索引值如果小于数组长度,就视做 SET 操作,因为它不会改变数组长度;如果设置的索引值大于数组的当前长度,则视为 ADD 操作,因为这汇隐式地修改数组的 length 属性值。有了这些信息,我们就可以在 trigger 函数中正确地触发与数组对象的 length 属性相关联的副作用函数重新执行了。

function trigger (target, key, type) { // ... // 操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length 属性相关联的副作用函数 if (type === TRIGGER_TYPE.ADD && Array.isArray(target)) { // 取出与 length 相关联的副作用函数 const lengthEffects = depsMap.get('length'); // 将这些副作用函数添加到 effectsToRun 中,待执行 lengthEffects && lengthEffects.forEach((effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } })); } // effects && effects.forEach(fn => fn()); 避免与 cleanup 产生死循环 effectsToRun.forEach(effectFn => { // 如果存在调度器,则调用该调度器,并将副作用函数作为参数传递 if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { effectFn(); } }); }

反过来思考,其实修改数组的 length 属性也会隐式地影响数组元素。

const arr = reactive(['foo']); effect(() => { console.log(arr[0]); }); // 将数组的长度修改为 0,导致第 0 个元素被删除,因此应该触发响应 arr.length = 0;

在副作用函数内访问了数组的第 0 个元素,接着将数组的 length 属性修改为 0。这会隐式地影响数组元素,即所有元素都被删除,所以应该触发副作用函数重新执行。然后并非所有对 length 属性的修改都会影响数组中的已有元素。拿上面例子来说,如果我们将 length 属性设置为 100,这并不会影响第 0 个元素,所以也就不需要触发副作用函数重新执行。当修改 length 属性值时,只有那些索引值大于或等于新的 length 属性值的元素才需要触发响应。但无论如何,目前的实现还做不到这一点,为了实现目标,我们需要修改 set 拦截函数。在调用 trigger 函数触发响应时,应该把新的属性值传递过去。

function crateReactive (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 ? TRIGGER_TYPE.SET : TRIGGER_TYPE.ADD : Object.prototype.hasOwnProperty.call(target, key) ? TRIGGER_TYPE.SET : TRIGGER_TYPE.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 函数。

function trigger (target, key, type, newVal) { // ... // 操作类型为 ADD 并且目标对象是数组时,应该取出并执行那些与 length 属性相关联的副作用函数 if (type === TRIGGER_TYPE.ADD && Array.isArray(target)) { // 取出与 length 相关联的副作用函数 const lengthEffects = depsMap.get('length'); // 将这些副作用函数添加到 effectsToRun 中,待执行 lengthEffects && lengthEffects.forEach((effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } })); } // 如果操作目标是数组,并且修改了数组的 length 属性 if (Array.isArray(target) && key === 'length') { // 对于索引大于或等于新的 length 值的元素 // 需要把所有相关联的副作用函数取出并添加到 effectsToRun 函数中 depsMap.forEach((effects, key) => { if (key >= newVal) { effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn); } }); } }); } // effects && effects.forEach(fn => fn()); 避免与 cleanup 产生死循环 effectsToRun.forEach(effectFn => { // 如果存在调度器,则调用该调度器,并将副作用函数作为参数传递 if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn); } else { effectFn(); } }); }

如上面的代码所示,为 trigger 函数增加了第四个参数,即触发响应时的新值。这里的新值指的是新的 length 属性值,它代表新的数组长度。接着,我们判断操作的目标是否是数组,如果是,则需要找到所有索引值大于或等于新的 length 值的元素,然后把它与它们相关联的副作用函数取出并执行。

遍历数组

数组也是对象,这意味着同样可以使用 for...in 循环遍历:

const arr = reactive(['foo']); effect(() => { for (const key in arr) { console.log(key); // 0 } });

我们应该尽量避免使用 for...in 循环遍历数组。不过既然在语法上是可行的,我们当然也要考虑这个问题。数据对象和常规对象的不同体现在 [[DefineOwnProperty]] 这个内部方法上,也就是说,使用 for...in 循环遍历数组与遍历常规对象并无差异,因此同样可以使用 ownKeys 拦截函数进行拦截。

function crateReactive (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 crateReactive (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); // 0 } }); arr[1] = 'bar'; arr.length = 0;

接下来我们再看看使用 for...of 遍历数组的情况。与 for...in 不同,for...of 是用来遍历 可迭代对象(iterable object) 的,因此我们需要先搞清楚什么是可迭代对象。ES2015 为 JavaScript 定义了 迭代协议(iteration protocol) ,它不是新的语法,而是一种协议。具体来i说,一个对象是否能够被迭代,取决于该对象或者该对象的原型是否实现了 @@iterator 方法。这里的 @@[name] 标志在 ECMAScript 规范里用来代指 JavaScript 内建的 symbols 值,例如 @@iterator 指的就是 Symbol.iterator 这个值。如果一个对象实现了 Symbol.iterator 方法,那么这个对象就是可迭代的。

const obj = { value: 0, [Symbol.iterator]() { return { next () { return { value: obj.value++, done: obj.value > 10 ? true : false } } } } }; for (const value of obj) { console.log(value); // 0 1 2 3 4 5 6 7 8 9 }

数组内建了 Symbol.iterator 方法的实现。

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: 1, done: false } console.log(itr.next()); // { value: 1, done: false } console.log(itr.next()); // { value: 1, done: false } console.log(itr.next()); // { value: 1, done: false } console.log(itr.next()); // { value: undefined, done: true }

可以看到,我们能够通过 Symbol.iterator 作为键,获取数组内建的迭代器方法。然后手动执行迭代器的 next 函数,这样也可以得到期望的结果。这也是默认情况下数据可以使用 for...of 遍历的原因。

const arr = [1, 2, 3, 4, 5]; for (const val of arr) { console.log(val); // 1 2 3 4 5 }

实际上,想要实现对数组进行 for...of 遍历的拦截,关键点就在于找到 for...of 操作依赖的基本语义。在规范的 23.1.5.1 节中定义了数组迭代器的执行流程。

https://tc39.es/ecma262/#sec-createarrayiterator

1. Let closure be a new Abstract Closure with no parameters that captures kind and array and performs the following steps when called: a. Let index be 0. b. Repeat, i. If array has a [[TypedArrayName]] internal slot, then 1. If IsDetachedBuffer(array.[[ViewedArrayBuffer]]) is true, throw a TypeError exception. 2. Let len be array.[[ArrayLength]]. ii. Else, 1. Let len be ? LengthOfArrayLike(array). iii. If index ≥ len, return undefined. iv. If kind is key, perform ? GeneratorYield(CreateIterResultObject(𝔽(index), false)). v. Else, 1. Let elementKey be ! ToString(𝔽(index)). 2. Let elementValue be ? Get(array, elementKey). 3. If kind is value, perform ? GeneratorYield(CreateIterResultObject(elementValue, false)). 4. Else, a. Assert: kind is key+value. b. Let result be CreateArrayFromList(« 𝔽(index), elementValue »). c. Perform ? GeneratorYield(CreateIterResultObject(result, false)). vi. Set index to index + 1. 2. Return CreateIteratorFromClosure(closure, "%ArrayIteratorPrototype%", %ArrayIteratorPrototype%).

第 1 步的 b 子步骤所描述的内容如下:

  • 重复以下步骤
    • 如果 array 有 [[TypedArrayName]] 内部槽,那么
      • 如果 IsDetachedBuffer(array.[[ViewedArrayBuffer]]) 是 true,则抛出 TypeError 异常
      • len 的值为 array.[[ArrayLength]]
    • 否则
      • len 的值为 LengthOfArrayLike(array)
    • 如果 index >= len,则返回 undefined
    • 如果 kindkey,则执行 ? GeneratorYield(CreateIterResultObject(𝔽(index), false))
    • 否则
      • elementKey 的值为 ! ToString(𝔽(index))
      • elementValue 的值为 ? Get(array, elementKey)
      • 如果 kindvalue,执行 ? GeneratorYield(CreateIterResultObject(elementValue, false))
      • 否则
        • 断言:kindkey + value
        • 让结果是 CreateArrayFromList(« 𝔽(index), elementValue »)
        • 执行:? GeneratorYield(CreateIterResultObject(result, false)).
    • 将 index 设置为 index + 1

可以看到,数组迭代器的执行回去读数组的 length 属性。如果迭代的是数组元素值,还会读取数组的索引。其实我们可以给出一个数组迭代器的模拟实现。

const arr = [1, 2, 3, 4, 5]; arr[Symbol.iterator] = function () { const target = this; const len = target.length; let index = 0; return { next () { return { value: index < len ? target[index] : undefined, done: index++ >= len } } } } for (const val of arr) { console.log(val); // 1 2 3 4 5 }

这个例子表明,迭代数组时,只需要在副作用函数与数组的长度和索引之间建立响应联系,就能够实现响应式的 for...of 迭代。

const arr = reactive([1, 2, 3, 4, 5]); effect(() => { for (const val in arr) { console.log(val); } }); arr['1'] = 'bar'; arr.length = 0;

可以看到,不需要增加任何代理就能够使其正确地工作。这是因为只要数组的长度和元素值发生改变,副作用函数自然会重新执行。

TypeError: Cannot convert a Symbol value to a number

无论是使用 for...of 循环,还是调用 values 等方法,它们都会去读数组的 Symbol.iterator 属性。该属性是一个 symbol 值,为了避免发生意外的错误,以及性能上的考虑,我们不应该在副作用函数与 Symbol.iterator 值之间建立响应联系,因此需要修改 get 拦截函数。

function crateReactive (obj, isShallow = false, isReadonly = false) { return new Proxy(obj, { get (target, key, receiver) { if (key === 'raw') { return target; } if (!isReadonly && typeof key !== 'symbol') { track(target, key); } const res = Reflect.get(target, key, receiver); if (isShallow) { return res; } if (isPlainObject(res)) { return isReadonly ? readonly(res) : reactive(res); } return res; }, // ... ownKeys (target) { // 如果操作目标 target 是数组,使用 length 属性作为 key 建立响应联系 track(target, Array.isArray(target) ? 'length' : ITERATE_KEY); return Reflect.ownKeys(target); }, // ... }); }

在调用 track 函数进行追踪之前,需要添加一个判断条件,即只有当 key 的类型不是 symbol 时才进行追踪,这样就避免了上述问题。

数组的 values 方法的返回值实际上就是数组内建的迭代器,我们可以验证这一点。

console.log(Array.prototype.values === Array.prototype[Symbol.iterator]); // true

在不增加任何代码的情况下,我们也能够让数组的迭代器方法正确地工作。

const arr = reactive([1, 2, 3, 4, 5]); effect(() => { for (const val of arr.values()) { console.log(val); } }); arr['1'] = 'bar'; arr.length = 0;

数组的查找方法

数据的方法内部其实都依赖了对象的基本语义。所以大多数情况下,我们不需要做特殊处理即可让这些方法按预期工作。

const arr = reactive([1, 2]); effect(() => { console.log(arr.includes(1)); }); arr[0] = 3;

比如上面这个例子,includes 为了找到给定的值,它内部会访问数组的 length 属性以及数组的索引,因此当我们修改某个索引指向的元值后能够触发响应。

但是 includes 也不总是按照预期工作。

const obj = {}; const arr = reactive([ obj ]); console.log(arr.includes(arr[0])) // false

如上面代码所示。我们首先定一个对象 obj,并将其作为数组的第一个元素,然后调用 reactive 函数为其创建一个响应式对象,接着尝试调用 includes 方法在数组中进行查找,看看其中是否包含第一个元素。很显然,这个操作应该返回 true,但如果你尝试运行这段代码,会发现它返回了 false。

语言规范 23.1.3.14 节给出了 includes 方法的执行流程。

https://tc39.es/ecma262/#sec-array.prototype.includes

1. Let O be ? ToObject(this value). 2. Let len be ? LengthOfArrayLike(O). 3. If len is 0, return false. 4. Let n be ? ToIntegerOrInfinity(fromIndex). 5. Assert: If fromIndex is undefined, then n is 0. 6. If n is +∞, return false. 7. Else if n is -∞, set n to 0. 8. If n ≥ 0, then a. Let k be n. 9. Else, a. Let k be len + n. b. If k < 0, set k to 0. 10. Repeat, while k < len, a. Let elementK be ? Get(O, ! ToString(𝔽(k))). b. If SameValueZero(searchElement, elementK) is true, return true. c. Set k to k + 1. 11. Return false.

上面是数组的 includes 方法的执行流程,我们重点关注第 1 步和第 10 步。其中,第 1 步所描述的内容如下。

  • O 的值为 ? ToObject(this value)

第 10 步的描述如下。

  • 重复,while 循环(条件 k < len
    • elementK 的值为 ? Get(O, ! ToString(𝔽(k)))
    • 如果 SameValueZero(searchElement, elementK) 是 true,则返回 true
    • 将 k 设置为 k + 1

第 1 步,让 O 的值为 ? ToObject(this value),这里的 this 是谁?在 arr.includes(arr[0]) 语句中,arr 是代理对象,所以 includes 函数执行时的 this 指向的就是代理对象,即 arr。接着我们看第 10.a 步,可以看到 includes 方法会通过索引读取数组元素的值,但是这里的 O 是代理对象 arr。我们知道,通过代理对象来访问元素值时,如果值仍然是可以被代理的,那么得到的值就是新的代理对象而非原始对象。下面这段 get 拦截函数内的代码可以证明这一点。

const isPlainObject = (data) => typeof data === 'object' && data !== null; if (isPlainObject(res)) { return isReadonly ? readonly(res) : reactive(res); }

知道这些后,我们再回头看这句代码:arr.includes(arr[0]) 。其中,arr[0] 得到的是一个代理对象,而在 includes 方法内部也会通过 arr 访问数组元素,从而得到一个代理对象,问题是这两个代理对象是不同的。这是因为每次调用 reactive 函数时都会创建一个新的代理对象。

function reactive (obj) { return crateReactive(obj); }

即使参数 obj 相同的,每次调用 reactive 函数时,都会创建新的代理对象。这个问题的解决方案如下所示。

// 定义一个 Map 实例,存储原始对象到代理对象的映射 const reactiveMap = new Map(); function reactive (obj) { // 优先通过原始对象 obj 寻找之前创建的代理对象,如果找到了,直接返回已有的代理对象 const existionProxy = reactiveMap.get(obj); if (existionProxy) return existionProxy; const proxy = crateReactive(obj); reactiveMap.set(obj, proxy); return proxy; }

在上面这段代码中,我们定义了 reactiveMap ,用来存储原始对象到代理对象的映射。每次调用 reactive 函数创建代理对象之前,优先检查是否已经存在相应的代理对象。如果存在,则直接返回已有的代理对象,这样就避免了为同一个原始对象多次创建代理对象的我呢提。

const obj = {}; const arr = reactive([ obj ]); console.log(arr.includes(arr[0])) // true

现在输出的结果已经符合我们预期。然而还不能高兴的太早,再来看下面的代码。

const obj = {}; const arr = reactive([ obj ]); console.log(arr.includes(obj)) // false

在上面的代码中,我们直接把原始对象作为参数传递给 includes 方法,这是很符合直觉的行为。而从用户的角度来看,自己明明把 obj 作为数组的第一个元素了,为什么在数组中却仍然找不到 obj 对象?其实原因很简单,因为 includes 内部的 this 指向的是代理对象 arr,并且在获取数组元素时得到的值也是代理对象,所以拿原始对象 obj 去查找肯定查不到,因此返回 false。为此,我们需要重写数组的 includes 方法并实现自定义的行为,才能解决这个问题,首先,我们来看如何重写 includes 方法。

const arrayInstrumentations = { includes: function () {} } function crateReactive (obj, isShallow = false, isReadonly = false) { return new Proxy(obj, { get (target, key, receiver) { 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 (isPlainObject(res)) { return isReadonly ? readonly(res) : reactive(res); } return res; }, // ... }); }

上段代码中,我们修改了 get 拦截函数,目的是重写数组的 includes 方法。arr.includes 可以理解为读取代理对象 arr 的 includes 属性,这就会触发 get 拦截函数,在该函数内检查 target 是否是数组,如果是数组并且读取的键值存在于 arrayInstrumentations 上,则返回定义在 arrayInstrumentations 对象上相应的值。也就是说,当执行 arr.includes 时,实际执行的是定义在 arrayInstrumentations 上的 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.includes(obj) 的默认行为。如果找不到,通过 this.raw 拿到原始数组,再去其中查找,最后返回结果,这样就解决了上述问题。

const obj = {}; const arr = reactive([ obj ]); console.log(arr.includes(obj)) // true

现在代码的行为已经符合预期。除了 includes 方法之外,还需要做类似处理的方法有 indexOflastIndexOf ,因为它们都属于根据给定的值返回查找结果的方法。

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 为 false 说明没找到,通过 this.raw 拿到原始数组,再去其中查找并更新 res 值 res = originMethod.apply(this.raw, args); } return res; } });

隐式修改数组长度的原型方法

这一节我们讲解如何处理那些因是修改数组长度的方法,主要指的是数组的栈方法,例如 push/pop/shift/unshift 。除此之外,splice 方法也会隐式地修改数组长度,我们可以查阅规范来证实这一点。以 push 方法为例,规范 23.1.3.21 节定义了 push 方法的执行流程。

https://tc39.es/ecma262/#sec-array.prototype.push

1. Let O be ? ToObject(this value). 2. Let len be ? LengthOfArrayLike(O). 3. Let argCount be the number of elements in items. 4. If len + argCount > 2^53 - 1, throw a TypeError exception. 5. For each element E of items, do a. Perform ? Set(O, ! ToString(𝔽(len)), E, true). b. Set len to len + 1. 6. Perform ? Set(O, "length", 𝔽(len), true). 7. Return 𝔽(len).

当调用 push 方法并传递 0 个或多个参数时,会执行以下步骤。

  • O 的值为 ? ToObject(this value)
  • len 的值为 ? LengthOfArrayLike(O)
  • argCount 的值为 items 的元素数组
  • 如果 len + argCount > 2^53 - 1 ,抛出 TypeError 异常
  • 对于 items 中的每一个元素 E
    • 执行 ? Set(O, ! ToString(𝔽(len)), E, true)
    • len 设置为 len + 1
  • 执行 ? Set(O, "length", 𝔽(len), true)
  • 返回 𝔽(len)

由第 2 步和第 6 步可知,当调用数组的 push 方法向数组中添加元素时,既会读取数组的 length 属性值,也会设置数组的 length 属性值。这会导致两个独立的副作用函数互相影响。

const arr = reactive([]); effect(() => { arr.push(1); }); effect(() => { arr.push(1); });

如果你尝试运行上面这段代码,会得到栈溢出的错误(Maximum call stack size exceeded)。为什么会这样呢?

  • 第一个副作用函数执行。在该函数内,调用 arr.push 方法向数组中添加一个元素。我们知道,调用函数的 push 方法会间接读取数组的 length 属性。所以,当第一个副作用函数执行完毕后,会与 length 属性建立响应联系;
  • 接着,第二个副作用函数执行。同样,它也会与 length 属性建立响应关系。但不要忘记,调用 arr.push 方法不仅会间接读取数组的 length 属性,还会间接设置 length 属性的值;
  • 第二个函数内的 arr.push 方法的调用设置了数组的 length 属性值。于是,响应系统尝试把与 length 属性相关联的副作用函数全部取出并执行,其中就包括第一个副作用函数。问题就在这里,可以发现,第二个副作用函数还未执行完毕,就要再次执行第一个副作用函数了;
  • 第一个副作用函数再次执行。同样,这样间接设置数组的 length 数组。于是,响应系统又要尝试把所有与 length 属性相关联的副作用函数取出并执行,其中就包含第二个副作用函数;
  • 如此循环往复,最终导致调用栈溢出。

问题的原因是 push 方法的调用会间接读取 length 属性。所以,只要我们 “屏蔽” 对 length 属性的读取,从而避免在它与副作用函数之间建立响应联系,问题就可以解决。这个思路是正确的,因为数组的 push 方法在语义上是修改操作,而非读取操作,所以避免建立响应联系并不会产生其他副作用。这需要重写数组的 push 方法。

// 一个标记变量,代表是否进行追踪。默认值是 true,即允许追踪 let shouldTrack = true; ;['push'].forEach(method => { const originMethod = Array.prototype[method]; arrayInstrumentations[method] = function (...args) { // 调用原始方法之前禁止追踪 shouldTrack = false; // push 方法的默认行为 let res = originMethod.call(this, args); // 调用原始方法之后,恢复原来的行为,即允许追踪 shouldTrack = true; return res; } });

在这段代码中,我们定义了一个标记变量 shouldTrack,它是一个布尔值,代表是否允许追踪。接着,我们重写了数组的 push 方法,利用前文介绍的 arrayInstrumentations 对象。重写后的 push 方法保留了默认行为,只不过在执行默认行为之前,先将标记变量 shouldTrack 的值设置为 false,即禁止追踪。当 push 方法的默认行为执行完毕后,再将标记变量 shouldTrack 的值还原为 true,代表允许追踪。最后,我们还需要修改 track 函数。

function track (target, key) { // 禁止追踪时,直接返回 if (!activeEffect || !shouldTrack) return; // ... }

可以看到,当标记为 shouldTrack 的值为 false 时,即禁止追踪时,track 函数会直接返回。这样,当 push 方法间接读取 length 属性值时,由于此时是禁止追踪的状态,所以 length 属性与副作用函数之间不会建立响应联系。这样就实现了前文给出的方案。我们再次尝试运行下面这段测试代码。

const arr = reactive([]); effect(() => { arr.push(1); }); effect(() => { arr.push(1); });

会发现它能够正常地工作,并且不会导致调用栈溢出。

除了 push 方法之外,popshiftunshfitsplice 方法都需要做类似的处理。

// 一个标记变量,代表是否进行追踪。默认值是 true,即允许追踪 let shouldTrack = true; ;['push', 'pop', 'shift', 'unshfit', 'splice'].forEach(method => { const originMethod = Array.prototype[method]; arrayInstrumentations[method] = function (...args) { // 调用原始方法之前禁止追踪 shouldTrack = false; // push 方法的默认行为 let res = originMethod.call(this, args); // 调用原始方法之后,恢复原来的行为,即允许追踪 shouldTrack = true; return res; } });