从手写my_foreach开始,彻底理解JS数组方法的运行原理

94 阅读5分钟

在 JavaScript 中,数组是一个非常常用的数据结构。我们每天都在用 mapfilterforEach 这些方法来处理数据。但很多人只是会用,却不清楚它们到底是怎么工作的。今天,我们就来彻底搞明白这些方法背后的机制,不靠黑盒,而是自己动手,从零开始实现它们。

我们要做的,是向 Array.prototype 添加一些我们自己定义的方法。比如 my_foreachmy_mapmy_Filtermy_EverymyReduce。通过这种方式,我们不仅能理解这些方法的行为,还能掌握 JavaScript 中一个非常重要的概念:原型链与 this 的指向

一、forEach的实现

Array.prototype.my_foreach = function(callback) {
  for (let i = 0; i < this.length; i++) {
    callback(this[i], i, this);
  }
};

这段代码做了什么?我们一行一行来看。

第一行:

Array.prototype.my_foreach = function(callback) {

这表示我们正在向 Array 构造函数的原型对象上添加一个名为 my_foreach 的函数。Array.prototype 是所有数组实例的共同原型。也就是说,只要你创建一个数组,无论是用字面量 [] 还是 new Array(),它都会继承 Array.prototype 上的所有属性和方法。

所以,当我们在这里添加一个 my_foreach 方法后,所有的数组实例就都能直接调用它了。比如:

const arr = [10, 20, 30];
arr.my_foreach((item, index, array) => {
  console.log(item, index);
});

这段代码会正常运行,输出每个元素和它的索引。为什么?因为 arr 虽然没有自己定义 my_foreach,但它可以通过原型链找到 Array.prototype 上的这个方法。

接下来,函数接收一个参数 callback。这个 callback 不是一个普通的值,而是一个函数。我们在调用 my_foreach 的时候,会传入一个函数作为参数,这个函数会被 my_foreach 内部调用。

进入函数体:

for (let i = 0; i < this.length; i++) {
  callback(this[i], i, this);
}

这里是一个 for 循环,从索引 0 开始,一直遍历到 this.length - 1。关键点在于:这里的 this 指向谁?

在 JavaScript 中,函数内部的 this 取决于它是如何被调用的。当我们写 arr.my_foreach(...) 时,my_foreach 是作为 arr 的一个方法被调用的。因此,在这个函数执行时,this 就指向 arr 这个数组实例。

所以:

  • this[i] 就是数组在位置 i 上的元素;
  • i 是当前的索引;
  • this 是数组本身。

然后我们调用 callback,并把这三个值作为参数传进去。这完全模仿了原生 forEach 方法的参数顺序:元素、索引、数组。

这个方法不会返回任何有意义的值(默认返回 undefined),它的目的不是生成新数据,而是对数组中的每一项执行某个操作,比如打印、修改 DOM、发送请求等。这就是所谓的“副作用操作”。

需要注意的是,这个实现不会跳过空元素(虽然在稀疏数组中可能表现不同),也不会因为 callback 中的 return 而中断循环。如果想支持中断,需要额外的逻辑,但原生 forEach 本身也不支持中断,所以这个行为是一致的。


二、 map 的实现:

Array.prototype.my_map = function(callback) {
  const result = [];
  for (let i = 0; i < this.length; i++) {
    result.push(callback(this[i], i, this));
  }
  return result;
};

这个方法的目标是创建一个新数组,新数组的每个元素是原数组对应元素经过 callback 处理后的返回值。

我们先创建一个空数组 result,然后遍历原数组的每一项,把 callback 的返回值 pushresult。最后返回这个新数组。

重点是:它不会修改原数组,而是返回一个全新的数组。这符合函数式编程中“不可变性”的原则。

比如:

[1, 2, 3].my_map(x => x * 2); // 返回 [2, 4, 6]

每个元素都被映射成了一个新的值。


三、Filter:

Array.prototype.my_Filter = function(callback) {
  const result = [];
  for (let i = 0; i < this.length; i++) {
    if (callback(this[i], i, this)) {
      result.push(this[i]);
    }
  }
  return result;
};

或者写成简洁形式:

callback(this[i], i, this) && result.push(this[i]);

这种写法利用了逻辑与 && 的短路特性:只有当左边为 true 时,右边才会执行。所以如果 callback 返回 true,就 push 元素;否则不执行。

它的作用是筛选出所有让 callback 返回 true 的元素,组成一个新数组。

比如:

[1, 2, 3, 4, 5].my_Filter(x => x % 2 === 0); // [2, 4]

四、然后是 Every:

Array.prototype.my_Every = function(callback) {
  for (let i = 0; i < this.length; i++) {
    if (!callback(this[i], i, this)) {
      return false;
    }
  }
  return true;
};

这个方法用来判断数组中的每一个元素是否都满足某个条件。只要有一个元素让 callback 返回 false,整个方法就立刻返回 false。只有全部通过,才返回 true

它的执行是“短路”的,一旦发现不满足的项,就不再继续遍历,提高了效率。

比如判断数组是否全是正数:

[1, 2, 3].my_Every(x => x > 0); // true

五、myReduce:

Array.prototype.myReduce = function(callback, ...arg) {
  let previousValue;
  let startIndex = 0;

  if (arg.length > 0) {
    previousValue = arg[0];
  } else {
    previousValue = this[0];
    startIndex = 1;
  }

  for (let i = startIndex; i < this.length; i++) {
    previousValue = callback(previousValue, this[i], i, this);
  }

  return previousValue;
};

reduce 的作用是将数组“归约”成一个单一的值。它可以用来求和、拼接字符串、扁平化数组、统计频率等等。

它的核心是“累加器”的概念。previousValue 就是上一次调用 callback 的返回值,第一次的时候,它可能是你传入的初始值,也可能是数组的第一个元素。

...arg 是剩余参数语法,用来收集除了第一个参数 callback 之外的所有参数。我们用它来判断是否传入了初始值。

如果传了初始值,就从索引 0 开始遍历,previousValue 就是初始值;如果没有传,就用数组第一个元素作为初始值,并从第二个元素开始遍历。

然后在循环中不断调用 callback,把上一次的结果、当前元素、索引和数组本身传进去,直到遍历结束,最后返回最终的“累计值”。

比如:

[1, 2, 3, 4].myReduce((sum, item) => sum + item); // 10