ShawDubie

Vue 系列(五):数组的响应式方案

我们通过上一篇文章实现了一个更加完善的响应式系统,该响应式系统可以很好地将普通对象转换为响应式数据。但是在处理数组和集合的时候还存在着一些不足,那么本篇我们将继续对响应式系统进行优化,使之能够更好地代理数组。

我们知道,在 JavaScript 中,数组也是对象。所以,一般情况下,我们上一篇文章中实现的响应式系统也是能够代理数组的:

// 响应式数组
const data = reactive([0, 1, 2, 3 ]);

effect(() => {
  console.log('data[0]--->', data[0]);  // data[0]--->0
});

setTimeout(() => {
  data[0] = 5;
}, 1000); // data[0]--->5

在上面的例子中,我们首先通过 reactive 方法定义了一个响应式数组。然后注册了一个副作用函数,用来打印数组的第一个元素。最后在一秒钟之后修改数组的第一个元素。运行上面的代码,当修改 data[0] = 5 的时候,能够触发副作用函数的执行。这说明当我们通过索引来读取和操作数组的时候,能够触发 get 和 set 函数的执行。那这样是不是说明我们什么也不用做了呢?很显然不是,要实现对数组的代理也有很多需要注意的地方。

在上一篇文章中,我们在完善响应式系统之前,首先梳理了普通对象上存在哪些读取和设置操作,那么本篇我们也用同样的方式先来梳理一下数组中有哪些读取和设置操作。

对一个数组的读取操作有如下几种方式:

  • 通过索引获取数组中的元素:data[0]

  • 访问数组的 length 属性:data.length

  • 把数组作为对象遍历:for...in

  • 使用迭代器遍历:for...of

  • 所有会读取数组但不改变原数组的原型方法:find、findIndex、includes、some、concat、join 等等

对一个数组的设置操作有如下几种方式:

  • 通过索引修改数组中的元素:data[0] = 2、data[100] = 1

  • 改变数组的长度:data.length = 0

  • 数组的栈方法:push、pop、shift、unshift

  • 所有会改变原数组的原型方法:splice、fill、sort 等等

看上去有非常多的地方等着我们去实现,但是别忘了,数组也是对象,我们当前实现的大部分代码都是适用于数组的。而且不要看到上面列举了这么多的方法,就心生怯意,她们中的大多数都有着千丝万缕的联系,我们只需要解决其中的一点就解决了一大片。那么接下来咱就各个击破,冲!

index 和 length


在文章的开头我们列举了一个通过索引来读取和设置数组的例子,上面的列子能够正确地触发代理数组 get 和 set 的执行。但是我们在通过索引修改数组的时候,有时候并不只是修改了某个元素的值,比如下面的例子:

const data = reactive([0, 1, 2, 3]);

effect(() => {
  console.log('data.length--->', data.length); // data.length--->4
})

setTimeout(() => {
  data[4] = 4 // 设置数组的第 5 个元素为 4
}, 1000);

// 不会打印任何东西

在上面的例子中,我们首先定义了一个长度为4的数组,然后我们通过一个副作用函数来读取数组的 length 属性,当她执行的时候会打印 data.length--->4,最后我们在1秒钟之后设置数组的第5个元素的值为4。可以看到,我们虽然是通过索引来修改数组的,但是我们在修改数组中某个元素值的时候,同样也修改了数组的长度,也就是 length 属性,此时数组的长度将不再是4,而是5。

所以,当我们通过索引修改数组时,如果索引值大于或等于数组的长度,则会导致数组的 length 属性被修改(就是增加了数组的长度)。那么在这种情况下,我们需要触发跟 length 相关的响应:

查看代码
const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    // 省略其他代码
    set(target: any, key: string, value: any, receiver: any) {
      if (isReadOnly) {
        console.warn(`属性 ${key} 是只读的`)
        return true;
      }
      // 旧值
      const oldValue = target[key];
      // 如果是数组,判断当前索引是否大于原数组的长度,如果大于则为新增,反之则是修改
      // 判断对象中是否有该属性,如果有,则本次操作为修改,反之则是新增
      const triggerType = Array.isArray(target) ? Number(key) >= target.length ? TRIGGER_TYPE.ADD : TRIGGER_TYPE.SET : target.hasOwnProperty(key) ? TRIGGER_TYPE.SET : TRIGGER_TYPE.ADD;
      const res = Reflect.set(target, key, value, receiver);
      // 相等说明 receiver 是 target 的代理对象
      if (target === receiver[TARGET_KEY]) {
        // 新旧值不一样才触发副作用函数执行
        // 处理NaN的情况
        if (oldValue !== value && (oldValue === oldValue || value === value)) {
          trigger(target, key, triggerType);
        }
      }
      return res;
    }
    // 省略其他代码
  });
}

在上面的代码中,我们首先修改了原来 set 函数中判断操作类型的逻辑。当原始数据为数组的时候,如果当前设置的索引大于等于数组的长度,那么 triggerType 的值就为 ADD,也就是说给数组新增了元素,反之,则为 SET,就是一个修改操作。为了能够触发和 length 相关的响应,我们也需要修改 trigger 方法:

查看代码
// 修改trigger
const trigger = (target: any, key: string | symbol, triggerType: TRIGGER_TYPE) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
  // 省略其他代码

  // 取出和 length 相关的副作用函数
  const lengthEffects = depsMap.get('length');
  // 当给数组新增元素的时候需要触发length响应
  if (lengthEffects && Array.isArray(target) && triggerType === TRIGGER_TYPE.ADD) {
    const effectsToRun: Set<EffectFunction> = new Set();
    lengthEffects.forEach(fn => {
      if (fn !== activeEffect) {
        effectsToRun.add(fn);
      }
    })
    effectsToRun.forEach(fn => fn());
  }

  // 省略其他代码
};

在 trigger 中,我们增加了对 length 属性的响应。当给数组增加元素(也就是 triggerType === TRIGGER_TYPE.ADD)时,就会触发与 length 相关的副作用函数的执行。那么,我们现在再执行前面的例子,就能正确响应了:

const data = reactive([0, 1, 2, 3]);

effect(() => {
  console.log('data.length--->', data.length); // data.length--->4
})

setTimeout(() => {
  data[4] = 4 // 设置数组的第 5 个元素为 4
}, 1000);

// data.length--->5

前面我们通过索引修改数组元素的时候,间接修改了数组的长度。反过来,当我们直接修改数组的 length 属性,也可能会间接影响数组的元素,比如:

const data = reactive([0, 1, 2, 3]);

effect(() => {
  console.log('data[3]--->', data[3]); // data[3]--->3
})

setTimeout(() => {
  data.length = 2;
}, 1000);

// 不会打印任何东西,此时data[3]的值为undefined

在上面的例子中,我们注册了一个副作用函数,该副作用函数会读取数组的第4个元素,并将其打印。一秒之后我们修改了数组的 length 属性,这时数组的长度变成了2,换句话说数组被删掉了两个元素。那么此时 data[3] 的值也被间接修改了,她的值变成了 undefined。但上面的修改并没有触发副作用函数的执行,那么我们应该怎么去实现呢?分析上面的代码,我们发现,如果数组的长度变小了,才会间接影响原来的数组中的元素,但是也不一定会影响所有的元素,只会影响那些索引值大于等于 length 的元素,比如上面例子中 data[0] 和 data[1] 就没有被影响。如果增加数组的长度也不会影响原数组中的长度,比如:

setTimeout(() => {
  data.length = 10;
}, 1000);

// 不会影响data[3]

知道了这一点,我们先修改 set 函数:

查看代码
const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    set(target: any, key: string, value: any, receiver: any) {
      if (isReadOnly) {
        console.warn(`属性 ${key} 是只读的`)
        return true;
      }
      // 旧值
      const oldValue = target[key];
      // 如果是数组,判断当前索引是否大于原数组的长度,如果大于则为新增,反之则是修改
      // 判断对象中是否有该属性,如果有,则本次操作为修改,反之则是新增
      const triggerType = Array.isArray(target) ? Number(key) >= target.length ? TRIGGER_TYPE.ADD : TRIGGER_TYPE.SET : target.hasOwnProperty(key) ? TRIGGER_TYPE.SET : TRIGGER_TYPE.ADD;
      const res = Reflect.set(target, key, value, receiver);
      // 相等说明 receiver 是 target 的代理对象
      if (target === receiver[TARGET_KEY]) {
        // 新旧值不一样才触发副作用函数执行
        // 处理NaN的情况
        if (oldValue !== value && (oldValue === oldValue || value === value)) {
          // 增加一个参数,将value传给trigger
          trigger(target, key, triggerType, value);
        }
      }
      return res;
    }
  });
}

在上面的代码中,我们修改了 set 函数中的一行代码,给 trigger 方法多传了一个参数,该参数为新的属性值,那么我们接下来就修改 trigger 方法:

Details
// 给 trigger 方法新增一个参数
const trigger = (target: any, key: string | symbol, triggerType: TRIGGER_TYPE, value?: any) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    // 省略其他代码

    // 当修改了数组中的length属性
    if (Array.isArray(target) && key === 'length') {
      depsMap.forEach((effects: Set<EffectFunction>, key: string | symbol) => {
        // 修改length会导致index>=length的数组元素被删除,所以要触发响应
        if (typeof key === 'string' && key !== 'length' && Number(key) >= value) {
          const effectsToRun: Set<EffectFunction> = new Set();
          effects.forEach(fn => {
            if (fn !== activeEffect) {
              effectsToRun.add(fn);
            }
          })
          effectsToRun.forEach(fn => fn());
        }
      })
    }
    
    // 省略其他代码
  }
};

在 trigger 方法中,我们增加了一个参数用来接收新的属性值。当原始数据为数组时且当前 key 等于 length,说明此时在设置数组的 length 属性。那么新的属性值就是新的数组长度。这时我们找到所有索引值大于或等于新的 length 值的元素,然后把与它们相关联的副作用函数取出并执行即可。再次执行上面的例子,就能正确打印了:

const data = reactive([0, 1, 2, 3]);

effect(() => {
  console.log('data[3]--->', data[3]); // data[3]---> 3
})

setTimeout(() => {
  data.length = 2;
}, 1000);

// data[3]---> undefined

遍历


遍历数组也是一种读取操作,遍历数组有多种方式。我们先来看看我们在上一篇文章中介绍过的 for...in 操作符:

const data = reactive(['foo', 'bar']);

effect(() => {
  for(const k in data) {
    console.log('k--->', k)
  }
});

// k---> 0
// k---> 1

setTimeout(() => {
  data.length = 1;
}, 1000);

// 不会打印任何东西

在上面的例子中,我们注册了一个副作用函数用来遍历数组并打印 key,当数组的长度发生改变的时候,我们是希望副作用函数被执行的,但是目前还做不到这一点。在上一篇文章中,我们通过 ownKeys 来实现对 for...in 的拦截。在数组中同样也是如此,只不过我们在处理对象的时候是通过 ITERATOR_KEY 作为副作用函数的关联 key,在数组中我们可以直接使用 length 来作为副作用函数的关联 key。这是因为对于数组来说,改变数组的 length 属性其实才会影响遍历操作,所以当 length 发生改变的时候去触发响应即可:

查看代码
const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    // 省略其他代码

    // 拦截 for...in 操作
    ownKeys(target: any) {
      // 通过 ITERATOR_KEY 作为副作用函数的关联key
      // 如果是数组,则使用 length 作为副作用函数的关联key
      track(target, Array.isArray(target) ? 'length' :ITERATOR_KEY);
      return Reflect.ownKeys(target);
    }

    // 省略其他代码
  });
}

我们再来运行上面的例子,就能正确触发响应了:

const data = reactive(['foo', 'bar']);

effect(() => {
  for(const k in data) {
    console.log('k--->', k)
  }
});

// k---> 0
// k---> 1

setTimeout(() => {
  data.length = 1;
}, 1000);

// k---> 0

上面使用 for...in 是将数组当做普通对象进行遍历,在遍历数组的时候我们一般不推荐使用 for...in。我们一般采用 for...of 来对数组进行遍历,比如:

const data = reactive(['foo', 'bar']);

effect(() => {
  for(const k of data) {
    console.log('k--->', k)
  }
});

// k---> foo
// k---> bar

setTimeout(() => {
  data.length = 1;
}, 1000);

// k---> foo
// k---> foo

在上面的例子中我们注册了一个副作用函数,在副作用函数内,我们通过 for...of 来遍历数组。当数组的 length 属性被修改的时候,我们发现副作用被触发执行了,这符合我们的预期,但是 k---> foo 被打印了两遍。这是因为我们当前的 trigger 方法存在bug,导致副作用函数被重复执行了,为此我们先解决这个bug:

查看完整代码
const trigger = (target: any, key: string | symbol, triggerType: TRIGGER_TYPE, value?: any) => {
  const depsMap = bucket.get(target);
  if (depsMap) {
    const effectsToRun: Set<EffectFunction> = new Set();
    // 当修改了数组中的length属性
    if (Array.isArray(target) && key === 'length') {
      depsMap.forEach((effects: Set<EffectFunction>, key: string | symbol) => {
        // 修改length会导致index>=length的数组元素被删除,所以要触发响应
        if (typeof key === 'string' && key !== 'length' && Number(key) >= value) {
          effects.forEach(fn => {
            if (fn !== activeEffect) {
              effectsToRun.add(fn);
            }
          })
        }
      })
    }

    // 当给数组新增元素的时候需要触发length响应
    const lengthEffects = depsMap.get('length');
    if (lengthEffects && Array.isArray(target) && triggerType === TRIGGER_TYPE.ADD) {
      lengthEffects.forEach(fn => {
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
    }

    // 当新增属性和删除属性的时候才触发副作用函数的执行
    // 取出 for...in 的副作用函数集合
    const iteratorEffects = depsMap.get(ITERATOR_KEY);
    if (iteratorEffects && (triggerType === TRIGGER_TYPE.ADD || triggerType === TRIGGER_TYPE.DELETE)) {
      iteratorEffects.forEach(fn => {
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
    }

    const effects = depsMap.get(key);
    if (effects) {
      effects.forEach(fn => {
        if (fn !== activeEffect) {
          effectsToRun.add(fn);
        }
      })
    }

    effectsToRun.forEach(fn => fn());
  }
};

我们将 effectsToRun 从每个 if 代码块中抽离了出来,等收集完之后再统一执行,这样就解决了重复执行的问题。既然问题解决了,接下来我们就看看为什么我们什么都没做,当前的响应式系统就已经能够支持 for...of 了。

我们先来了解一下 for...of 操作符,一个对象如果能够通过 for...of 进行遍历,那么需要该对象是可迭代对象,判断一个对象是否是可迭代对象就是看该对象或该对象的原型是否实现了 @@iterator 方法。@@iterator 的值为 Symbol.iterator,举个例子:

查看代码
class Range {
  value: number;

  stop: number;

  constructor(start: number, stop: number) {
    if (start <= stop) {
      this.value = start;
      this.stop = stop;
    } else {
      this.value = 0;
      this.stop = 0;
    }
  }

  next = () => {
    let value = this.value;
    if (value < this.stop) {
      this.value++;
      return {
        done: false, 
        value: value
      };
    }
    return {
      done: true,
      value: undefined
    };
  }

  [Symbol.iterator] = () => ({
    next: this.next
  })
}

const newRange = (start: number, stop: number) => new Range(start, stop);

for(const value of newRange(0, 3)) {
  console.log('value---->', value);
}

// value----> 0
// value----> 1
// value----> 2

在上面的例子中,我们在对象内部实现了 Symbol.iterator 方法,所以我们能够通过 for...of 来遍历对象。数组也是因为在底层实现了 Symbol.iterator,下面是我们按照语言规范实现的数组迭代器伪代码:

class MyArray {
  currentIndex: number;
  length: number;

  next = () => {
    return {
      value: this.currentIndex < this.length ? this[index]:undefined,
      done: this.currentIndex++ >= this.length
    }
  }

  [Symbol.iterator] = () => ({
    next: this.next
  })
}

可以看到数组迭代器的底层实现会读取 length 和 index,所以当迭代数组的时候,我们需要在副作用函数与数组的索引和 length 属性之间建立联系。可喜的是我们的响应式系统已经实现了这一点,所以我们什么都不用做。当 length 和 index 发生改变的时候,副作用函数自然会重新执行。

values 方法与 for...of 类似,values 方法会返回数组内置的迭代器,所以我们也什么都不需要做,就能实现对 values 的响应。

const data = reactive(['foo', 'bar']);

effect(() => {
  for(const value of data.values()) {
    console.log('value--->', value);
  }
});

// value---> foo
// value---> bar

setTimeout(() => {
  data.length = 1;
}, 1000);

// value---> foo

使用数组迭代器的时候,我们会访问 Symbol.iterator 属性,比如 Array.prototype[Symbol.iterator],这会触发 get,从而导致 track 方法对 Symbol.iterator 进行副作用函数的收集。这样可能会出现一些意外的错误或者在性能上产生问题,因为我们根本就不需要响应式系统去收集 Symbol.iterator 相关的副作用函数。为了避免这一点,我们要在 get 中加一个判断条件。

查看代码
const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    // 省略部分代码

    get(target: any, key: string | symbol, receiver: any) {
      // 当使用 TARGET_KEY 访问对象时,返回原始数据
      if (key === TARGET_KEY) {
        return target;
      }
      // 非只读的时候才收集
      // 如果 key 的类型是 symbol,也不进行收集
      if (!isReadOnly && typeof key !== 'symbol') {
        track(target, key);
      }
      const res = Reflect.get(target, key, receiver);
      // 浅响应或浅只读直接返回
      if (isShallow) {
        return res;
      }
      if (res && typeof res === 'object') {
        // 深只读和深响应递归调用相应函数
        return isReadOnly ? readOnly(res): reactive(res);
      }
      return res
    }

    // 省略部分代码
  })
}

查询


当我们在数组中查找某个元素的时候,也会去读取数组,我们先来看看下面这个例子:

const data = reactive(['foo', 'bar']);

effect(() => {
  console.log('foo in data--->', data.includes('foo')) // foo in data---> true
});

setTimeout(() => {
  data[0] = 'hello';
}, 1000);

// foo in data---> false

在上面的例子中,我们首先定义了一个响应式数组,然后在副作用函数中使用 includes 方法查找 foo 元素,并打印出查找的结果。当我们修改数组元素值的时候 data[0] = 'hello',可以发现副作用函数被触发执行了,这非常符合我们的预期。之所以能够正确触发,是因为 includes 底层实现会访问数组的索引以及 length 属性,因此当我们通过索引来改修某个元素值的时候能够触发响应。但是,当我们使用 includes 查找对象的时候会出现问题:

const foo = {};
const data = reactive([foo]);

effect(() => {
  console.log('foo in data--->', data.includes(data[0])) // foo in data---> false
});

在上面的例子中,我们首先定义了一个对象 foo,然后通过这个对象构建了一个响应式数组 data。最后我们在副作用函数中使用 includes 查找 data[0],当运行这段代码的时候,会打印 foo in data---> false,可是 foo 明明存在于 data 中啊,为什么会出现这种情况呢?我们先来看看 includes 的底层实现。以下是我们根据语言规范实现的 includes 方法:

const includes = (searchElement: any, fromIndex: number): boolean => {
  if (this === null) {
    throw new typeError('"this" is null or undefined');
  }
  const o = new Object(this);
  const length = o.length;
  fromIndex = fromIndex || 0;
  if (fromIndex >= length) {
    return false;
  }
  if (length === 0) {
    return false;
  }
  let k = fromIndex;
  if (fromIndex < 0 ) {
    k = Math.max(fromIndex + length, 0);
  }
  while(k < length) {
    if (o[k] === searchElement) {
      return true;
    }
    k++;
  }
  return false;
}

主要看上面显示高亮的代码,首先看这一行:

const o = new Object(this);

以我们上面的例子来说,这里的 this 其实就是代理对象 data。接下来看这一行:

if (o[k] === searchElement) {
  return true;
}

这里的 searchElement 就是 data[0],我们知道,通过 reactive 方法创建的是深响应对象,所以 data[0] 也是一个响应式对象,而且这个响应式对象还是重新创建的:

if (res && typeof res === 'object') {
  // 深只读和深响应递归调用相应函数
  return isReadOnly ? readOnly(res): reactive(res);
}

所以当执行 o[k] === searchElement 时候,o[k] 取的是代理对象 data 里面的值,searchElement 就是 data[0],但是她是一个新的响应式对象,所以 o[k] === searchElement 的返回值一定是 false。

o[key] 也是一个响应式对象,传入的 searchElement 也就是 data[0],会被转换成一个响应式对象。这两个响应式对象不是同一个,所以不会相等。我们重新创建一个 Map 来解决这个问题:

// 将原始数据与代理数据相绑定
const reactiveMap: Map<any, any> = new Map();

// 深响应
export const reactive = (obj: any) => {
  // 如果代理数据存在,则不重复创建
  let res = reactiveMap.get(obj);
  if (!res) {
    res = createReactive(obj);
    reactiveMap.set(obj, res);
  }
  return res;
}

我们重新创建了一个数据结构用来将原始数据与代理数据相绑定,当我们使用 reactive 创建响应式对象的时候,先查询代理数据是否存在,如果存在就使用已经创建的,这样就不用去重复创建了。我们再运行一下上面的例子:

const foo = {};
const data = reactive([foo]);

effect(() => {
  console.log('foo in data--->', data.includes(data[0])) // foo in data---> true
});

可以发现问题已经被解决了,但是我们再来看看下面这个例子:

const foo = {};
const data = reactive([foo]);

effect(() => {
  console.log('foo in data--->', data.includes(foo)) // foo in data---> false
});

当我们传入原始值 foo 的时候,又出现了问题,这是因为我们上面创建的 Map 只能用来解决代理数据产生的问题,无法解决原始值带来的问题。实际上为了解决这个问题, Vue 重写了 includes 方法:

查看代码
interface ArrayInstrumentations {
  includes: (...args: Array<any>) => any,
}

const getSearchInstrumentation = (name: string) => {
  // 数组的原生方法
  const originMethod = Array.prototype[name];
  // 返回重写后的数组方法
  return function (...args: any) {
    // 调用数组的原生方法,这里的this为代理数据
    const res = originMethod.apply(this, args);
    // 如果在代理数据中没有找到,则通过this[TARGET_KEY]访问原始数据
    if (res === false || res < 0) {
      // 返回在原始数据中查找的结果
      return originMethod.apply(this[TARGET_KEY], args);
    }
    return res;
  }
}

const arrayInstrumentations: ArrayInstrumentations = {
  includes: getSearchInstrumentation('includes'),
};

在上面的代码中,我们定义了一个对象 arrayInstrumentations 用来存储所有需要重写的数组方法。然后在 getSearchInstrumentation 函数内重写了 includes。我们首先调用数组的原生方法去代理数据中查找,如果没有找到就去原始数据中查找,最后返回查找结果。这样就实现了对 includes 的重写。除此之外我们还需要修改 get 方法:

查看代码
const createReactive = (obj: any, isShallow = false, isReadOnly = false) => {
  return new Proxy(obj, {
    get(target: any, key: string | symbol, receiver: any) {
      // 当使用 TARGET_KEY 访问对象时,返回原始数据
      if (key === TARGET_KEY) {
        return target;
      }

      // 如果是数组且key以在arrayInstrumentations中存在,说明在调用我们重写的数组方法
      if (Array.isArray(target) && arrayInstrumentations.hasOwnProperty(key)) {
        // 从arrayInstrumentations返回相应的方法
        return Reflect.get(arrayInstrumentations, key, receiver);
      }

      // 非只读的时候才收集
      // 如果 key 的类型是 symbol,也不进行收集
      if (!isReadOnly && typeof key !== 'symbol') {
        track(target, key);
      }
      const res = Reflect.get(target, key, receiver);
      // 浅响应或浅只读直接返回
      if (isShallow) {
        return res;
      }
      if (res && typeof res === 'object') {
        // 深只读和深响应递归调用相应函数
        return isReadOnly ? readOnly(res): reactive(res);
      }
      return res
    }
  })
};

当 get 被触发的时候,我们会先去 arrayInstrumentations 查找,看看代理数据是否在访问重写的数组方法。通过这样的实现,当上面的响应式数据 data 再次访问 data.includes(data[0]) 时,会触发 get,这样就能拿到重写的 includes 方法去执行了。此外,indexOf、lastIndexOf 与 includes 是类似的,都属于根据给定值来返回查找结果。所以都需要进行重写:

查看代码
interface ArrayInstrumentations {
  includes: (...args: Array<any>) => any,
  indexOf: (...args: Array<any>) => any,
  lastIndexOf: (...args: Array<any>) => any,
}

const getSearchInstrumentation = (name: string) => {
  // 数组的原生方法
  const originMethod = Array.prototype[name];
  // 返回重写后的数组方法
  return function (...args: any) {
    // 调用数组的原生方法,这里的this为代理数据
    const res = originMethod.apply(this, args);
    // 如果在代理数据中没有找到,则通过this[TARGET_KEY]访问原始数据
    if (res === false || res < 0) {
      // 返回在原始数据中查找的结果
      return originMethod.apply(this[TARGET_KEY], args);
    }
    return res;
  }
}

const arrayInstrumentations: ArrayInstrumentations = {
  includes: getSearchInstrumentation('includes'),
  indexOf: getSearchInstrumentation('indexOf'),
  lastIndexOf: getSearchInstrumentation('lastIndexOf'),
};

再次执行上面的例子,就都能正常工作了:

const foo = {};
const data = reactive([foo]);

effect(() => {
  console.log('foo in data--->', data.includes(data[0])) // foo in data---> true
});

effect(() => {
  console.log('foo in data--->', data.includes(foo)) // foo in data---> true
});

修改数组的方法


我们在前面介绍了许多读取数组的方法,那么接下来我们将要处理一些会修改数组的方法。先看一个例子:

const data = reactive([1, 2, 3, 4]);

effect(() => {
  data.push(5);
});

effect(() => {
  data.push(6);
});

在上面的例子中,我们首先定义了一个响应式数组 data,然后我们连续注册了两个副作用函数,这两个副作用函数都会向数组添加元素。但是如果执行上面的代码,会出现如下错误提示:

Uncaught (in promise) RangeError: Maximum call stack size exceeded

可以看到,报了一个栈溢出的错误,这是为什么呢?我们先来看看 push 是如何实现的。以下是我们根据语言规范实现的 push 方法:

function push(value) {
  var len = this.length;
  this[len] = value;
  len++;
  this.length = len;
  return len;
}

可以看到,当我们使用 push 操作数组的时候,既要读取 length 属性,又要修改 length 属性。就是因为这样,所以导致了问题的出现,我们先来看看执行上述代码的时候发生了什么:

  • 执行第一个副作用函数,会将副作用函数与 length 进行关联

  • 执行第二个副作用函数,也会将该副作用函数与 length 进行关联。但是第二个副作用函数执行的时候也会修改 length 的值,这时会触发与 length 相关联的副作用函数执行,比如第一个副作用函数。而此时,第二个副作用函数还没有执行完

  • 这时第一个副作用函数再次执行,又会修改 length 的值。这同样也会触发与 length 相关联的副作用函数执行,这个时候第二个副作用函数也要被触发执行

  • 陷入死循环,导致栈溢出

我们知道,push 在语义上是一个修改操作,并不是读取操作,所以我们不需要将副作用函数与其进行关联。那么我们执行 push 的时候不要去触发 get 是不是就能解决问题了呢?的确如此。那么要怎么去做呢?Vue 是通过重写 push 来解决这个问题的,先来看看如何重写 push 方法:

查看代码
// 定义全局变量,来确定是否可以对副作用函数进行追踪
let shouldTrack = true;

export interface ArrayInstrumentations {
  includes: (...args: Array<any>) => any,
  indexOf: (...args: Array<any>) => any,
  lastIndexOf: (...args: Array<any>) => any,
  push: (...args: Array<any>) => any,
}

// 重写数组栈操作的通用方法
const getStackInstrumentation = (name: string) => {
  // 数组的原生方法
  const originMethod = Array.prototype[name];
  // 返回重写后的数组方法
  return function(...args: any) {
    // 执行栈方法的时候不允许追踪
    shouldTrack = false;
    const res = originMethod.apply(this, args);
    // 执行完之后允许追踪
    shouldTrack = true;
    return res;
  }
}

const arrayInstrumentations: ArrayInstrumentations = {
  includes: getSearchInstrumentation('includes'),
  indexOf: getSearchInstrumentation('indexOf'),
  lastIndexOf: getSearchInstrumentation('lastIndexOf'),
  push: getStackInstrumentation('push'),
  pop: getStackInstrumentation('pop'),
};

在上面的代码中,我们首先定义了一个全局变量 shouldTrack,用该变量来标识是否能够对副作用函数进行追踪(track)。然后我们重写了 push 方法,当 push 方法执行的时候,将 shouldTrack 置为 false,执行完毕之后再将 shouldTrack 置为 true。我们同样需要修改 track 方法:

查看代码
const track = (target: any, key: string | symbol) => {
  // 禁止追踪,直接返回
  if (!activeEffect || !shouldTrack) {
    return;
  }
  let depsMap = bucket.get(target);
  if (!depsMap) {
    depsMap = new Map();
    bucket.set(target, depsMap);
  }
  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }
  deps.add(activeEffect);
  activeEffect.deps.push(deps);
};

可以看到,当 shouldTrack 为 false 的时候,不能对副作用函数进行收集,所以直接返回。 除了 push 方法之外,pop、shift、unshift 以及 splice 等方法都需要做类似的处理:

查看代码
let shouldTrack = true;

// 重写数组栈操作的通用方法
const getStackInstrumentation = (name: string) => {
  // 数组的原生方法
  const originMethod = Array.prototype[name];
  // 返回重写后的数组方法
  return function(...args: any) {
    // 执行栈方法的时候不允许追踪
    shouldTrack = false;
    const res = originMethod.apply(this, args);
    // 执行完之后允许追踪
    shouldTrack = true;
    return res;
  }
}

const arrayInstrumentations: ArrayInstrumentations = {
  includes: getSearchInstrumentation('includes'),
  indexOf: getSearchInstrumentation('indexOf'),
  lastIndexOf: getSearchInstrumentation('lastIndexOf'),
  push: getStackInstrumentation('push'),
  pop: getStackInstrumentation('pop'),
  shift: getStackInstrumentation('shift'),
  unshift: getStackInstrumentation('unshift'),
  splice: getStackInstrumentation('splice')
};

最后


本篇我们讲了数组的响应式方案,虽然能够读取和修改数组的方法异常丰富,但是本质上还是在通过数组的索引和 length 属性进行操作的。而且数组也是对象,很多支持普通对象的代码也能复用,所以我们在实现响应式数组的时候就没有想象中的那么复杂了。当然我们的响应式系统还不够完善,因为诸如 Map、Set 这样的集合我们还是无法代理,我们将在后面的文章中进行实现。本篇的代码请戳