JS 手写代码系列之数组方法

115 阅读5分钟

JS 数组的一系列方法非常值得手写一遍来了解细节。手写时可以查阅 MDN 和 ECMA-262,这对把握细节来说尤为重要。

由于某些数组方法具备共性,为保证篇幅,节省人力,只会在首次引入某些概念或者行为时作详细说明,后面则不再赘述。

Array.prototype.map()

对数组中的每个元素执行给定的回调函数,并将结果存入新数组,原数组不作任何改动。

首先,我们看一下这个方法接收什么参数:

  • callback:回调函数,它接受三个参数:当前元素的值、当前元素的索引、当前元素所在的数组。回调函数的执行结果会被存入新数组。
  • thisArg:可选参数,执行回调函数时 this 指向。如果不提供,则使用 undefined

其次,我们需要知道这个方法的返回值:

  • 一个新数组,内容是回调函数在原数组每个元素上的执行结果。

我们还需要思考:如何获取数组的对象?map() 方法执行的时候有没有什么限制?

对于第一个问题,答案是简单的:在方法体内调用 this 即可获得数组对象,这也限制了我们不能使用箭头函数。

对于第二个问题,map() 遇到数组内的空洞时会跳过,对稀疏数组执行 map() 方法得到的新数组也是稀疏数组。这一点很容易被忽视。

另外,Array.prototype.map() 是可以在类数组对象上调用的。类数组对象要求对象具有 length 属性和整数索引的属性(注意在 JS 中类数组对象的判定方法其实是有细节的,想了解的可移步 developer.mozilla.org/en-US/docs/…)。

接下来就是正片了,手写一个我们自己的 _map()。为了方便测试,我们直接操纵 Array 的原型链(现代 js 里不推荐这么做):

Array.prototype._map = function (callback, thisArg) {
  if (typeof callback !== "function") {
    throw new TypeError("Callback function must be callable.");
  }
  
  // 这里我们不检查 this 是否是数组
  // 因为 `Array.prototype.map()` 可以在类数组对象上调用,而数组本身就是一种类数组对象
  // JS 实际上将所有对象都视作类数组对象
  // 无论 `length` 属性为何,它都会被转化为 [0, 2^53 - 1] 区间内的一个整数
  // 即使该属性不存在,也会将其视为具有 `length` 属性,值为 `0`

  // 获取类数组对象
  const target = this;
  const len = target.length;

  // 此处新建一个稀疏数组,保证最终返回的新数组可以保持原数组的稀疏性(如果有)
  // eslint-disable no-new-array
  const mappedValues = new Array(len);

  for (let i = 0; i < len; i++) {
    // 跳过空位
    if (i in target) {
      // 将给定的 thisArg 用于回调函数
      const mappedValue = callback.call(thisArg, target[i], i, target);

      // 保持原数组的稀疏性(如果有),或者填实新数组
      mappedValues[i] = mappedValue;
    }
  }

  return mappedValues;
};

// 测试
const arr = [1, 2, 3, 4, 5];
console.log(arr._map((el) => el ** 2)); // [ 1, 4, 9, 16, 25 ]

delete arr[1];
delete arr[3];
console.log(arr); // [ 1, <1 empty item>, 3, <1 empty item>, 5 ]
console.log(arr._map((el) => el ** 2)); // [ 1, <1 empty item>, 9, <1 empty item>, 25 ]

const arrayLike = {
  0: 1,
  2: 4,
  7: 8,
  10: 100, // ignored; length is 8
  length: 8,
};

let mappedArrayLike = [].map.call(arrayLike, (el) => el ** 2, arrayLike);
console.log(mappedArrayLike); // [ 1, <1 empty item>, 16, <4 empty items>, 64 ]

mappedArrayLike = []._map.call(arrayLike, (el) => el ** 2, arrayLike);
console.log(mappedArrayLike); // [ 1, <1 empty item>, 16, <4 empty items>, 64 ]

至此,我们实现了一个比较完善的 Array.prototype.map()

Array.prototype.forEach()

Array.prototype.forEach() 几乎一致,只是该方法只执行副作用,不返回新数组(注意这个做法在 Array.prototype.map()里也是技术上可行的,只是不应该这么做)。

因此,很容易写出以下代码:

Array.prototype._forEach = function (callback, thisArg) {
  if (typeof callback !== "function") {
    throw new TypeError("Callback function must be callable.");
  }

  // 获取类数组对象
  const target = this;
  const len = target.length; // 注意,这个长度属性可以回退到 0

  for (let i = 0; i < len; i++) {
    // 跳过空位
    if (i in target) {
      // 将给定的 thisArg 用于回调函数
      callback.call(thisArg, target[i], i, target);
    }
  }
};

// 测试
const arr = [1, 3, 5, 7, 9]; delete arr[1]; delete arr[3];

console.log(arr); // [ 1, <1 empty item>, 5, <1 empty item>, 9 ]

arr.forEach((el) => console.log(el)); // 1, 5, 9
arr._forEach((el) => console.log(el)); // 1, 5, 9

Array.prototype.reduce()

通过执行归一化回调函数(reducer),将一个类数组对象内的元素合并成单一的值。在其他编程语言中也有叫 fold() 的,以 Kotlin 为例:

  • fold() 要求提供初始值 和 reducer
  • reduce() 只要求提供 reducer

我们先看看 Array.prototype.reduce() 的参数和返回值:

  • callbackFn,归一化函数,最多接收四个参数:
    • previousValue:回调函数的上一个执行结果
    • currentValue:当前的元素
    • currentIndex:当前元素对应的索引
    • target:当前元素所在的数组

注意 callbackFn 不接受 thisArg,其对应的 thisundefined

  • initialValue 可选的初始值。如果提供了初始值,则回调函数从第一个元素开始执行;否则,回调函数将第一个元素作为默认的初始值,并从第二个元素开始执行。

需要注意的是,如果在空数组上执行 Array.prototype.reduce(),且不提供初始值,则会抛出 TypeError

const arr = [];

console.log(arr.reduce((accumulator, curr) => accumulator + curr, 0));
// 0

console.log(arr.reduce((accumulator, curr) => accumulator + curr));
// TypeError: Reduce of empty array with no initial value

同样的,Array.prototype.reduce() 会跳过稀疏数组里的空位。

直接上代码:

Array.prototype._reduce = function (callbackFn, initialValue) {
  // 获取目标类数组对象
  const target = this;
  const len = target.length;

  // 检查回调函数是否合法
  if (typeof callbackFn !== "function") {
    throw new TypeError("Callback function must be callable.");
  }

  // 检查是否在不提供初始值的情况下在空数组上执行 `reduce()`
  if (len === 0 && !initialValue) {
    throw new TypeError("Reduce of empty array with no initial value.");
  }

  // 累计值
  let accumulator = initialValue === undefined ? target[0] : initialValue;

  let i = initialValue === undefined ? 1 : 0;

  for (i; i < len; i++) {
    // 注意跳过空位
    if (i in target) {
      accumulator = callbackFn.call(
        undefined, // thisArg 恒定为 undefined
        accumulator, // 累加值当前的值
        target[i], // 当前的元素
        i, // 当前的索引
        target // 当前元素所在的类数组对象
      );
    }
  }

  return accumulator;
};

// 测试
const arr = [1, 2, 3, 4, 5];

console.log(arr.reduce((prev, curr) => prev + curr)); // 15
console.log(arr._reduce((prev, curr) => prev + curr)); // 15

const arrayLike = {
  0: 2,
  1: 4,
  2: 8,
  3: 42, // ignored, length is 3
  length: 3,
};

console.log([].reduce.call(arrayLike, (prev, curr) => prev + curr)); // 14
console.log([]._reduce.call(arrayLike, (prev, curr) => prev + curr)); // 14

Array.prototype.at()

这是用于替代下标访问的方法,特点是支持负数索引。代码实现很简单:

Array.prototype._at = function (index) {
  const arr = this;
  const len = arr.length;

  // 通过不合法的下标访问元素直接返回 undefined
  if (typeof index !== "number" || index < -len || index > len - 1) {
    return undefined;
  }

  return index >= 0 ? arr[index] : arr[index + len];
};

const arr = [1, 3, 5, 7, 9];

console.log(arr.at(0)); // 1
console.log(arr._at(0)); // 1
console.log(arr.at(-1)); // 9
console.log(arr._at(-1)); // 9
console.log(arr.at(10)); // undefined
console.log(arr._at(10)); // undefined