理解数组方法的内部原理,不仅能让你在面试中应对手写代码题,更能帮助你在日常开发中做出正确的选型——知道某个方法背后做了什么,才能预判它对性能和内存的影响。本节挑选最常用的几个数组方法,逐一拆解实现思路,并分析它们的性能特点。
手写 forEach
forEach 对数组的每个元素执行一次给定的函数,没有返回值,纯粹用于副作用操作。
Array.prototype.myForEach = function(callback, thisArg) {
// this 指向调用该方法的数组实例
const arr = this;
const len = arr.length;
for (let i = 0; i < len; i++) {
// 跳过空位(稀疏数组)
if (i in arr) {
callback.call(thisArg, arr[i], i, arr);
}
}
};
关键点
this:方法内部的this就是调用该方法的数组对象。thisArg:如果调用者传了第二个参数,回调函数内部通过call将this绑定到传入的对象,否则回调内的this就是undefined(严格模式下)。- 稀疏数组处理:使用
i in arr检查下标是否实际存在属性,跳过undefined的空位,这与原生行为一致。
性能特点
for循环简单直接,时间复杂度 O(n)。forEach无法中途跳出(break、return只能跳出本次回调,不会终止循环),如果确实需要中途退出,推荐用for...of或普通for循环。- 原生
forEach的性能略低于for循环,因为每个元素都要调用一次函数,存在额外的函数调用开销。在热点路径上,如果性能敏感,优先使用for循环。
手写 map
map 对数组的每个元素调用回调,将返回值组成一个新数组返回,原数组保持不变。
Array.prototype.myMap = function(callback, thisArg) {
const arr = this;
const len = arr.length;
const result = new Array(len); // 预分配空间
for (let i = 0; i < len; i++) {
if (i in arr) {
result[i] = callback.call(thisArg, arr[i], i, arr);
}
// 空位保留(不填充任何值,result[i] 此时是 empty)
}
return result;
};
关键点
- 预先创建好指定长度的数组
new Array(len),避免在循环中动态扩容。 - 同样需要处理稀疏数组,保持与原数组一致的空位。
- 返回值是一个新数组,原数组不受影响(纯函数特性)。
性能特点
- 时间复杂度 O(n),空间复杂度 O(n)(需要存储新数组)。
map适合“转换型”场景——将一组数据映射为另一组数据。如果不需要返回新数组(只做副作用),请用forEach,避免不必要的内存分配。- 与
for循环相比,map的声明式写法可读性更强,但在超大数组上可能有轻微的性能损失,通常不需要担心。
手写 filter
filter 对数组的每个元素调用回调,返回值为 true 的元素会被放入新数组。
Array.prototype.myFilter = function(callback, thisArg) {
const arr = this;
const len = arr.length;
const result = [];
for (let i = 0; i < len; i++) {
if (i in arr) {
const value = arr[i];
if (callback.call(thisArg, value, i, arr)) {
result.push(value);
}
}
}
return result;
};
性能特点
- 时间复杂度 O(n),空间复杂度取决于匹配项数量。
- 使用
push往结果数组追加元素,而不是预分配数组,因为最终数组长度未知。大量push可能会触发数组扩容和内存复制,但现代引擎对push在连续内存上的处理已经高度优化。 - 如果希望同时做筛选和转换,可以链式调用
filter和map,但会遍历两次。对于超大型数组,可以将逻辑合并到一个reduce中,一次遍历完成。
手写 reduce
reduce 将数组元素逐个处理,累积为一个值。它的灵活度最高,可以模拟 map、filter 等其他方法。
Array.prototype.myReduce = function(callback, initialValue) {
const arr = this;
const len = arr.length;
let accumulator = initialValue;
let startIndex = 0;
// 如果没有提供初始值,取数组第一个有效值作为初始累加器
if (arguments.length < 2) {
// 寻找第一个非空位的元素
while (startIndex < len && !(startIndex in arr)) {
startIndex++;
}
if (startIndex >= len) {
throw new TypeError('Reduce of empty array with no initial value');
}
accumulator = arr[startIndex];
startIndex++;
}
for (let i = startIndex; i < len; i++) {
if (i in arr) {
accumulator = callback(accumulator, arr[i], i, arr);
}
}
return accumulator;
};
关键点
- 对于
initialValue未传的情况,需要找到第一个实际存在的元素作为初始累加器,然后从下一个位置开始遍历。这和原生行为完全一致。 - 空数组且不传初始值会抛出
TypeError,这是reduce的唯一错误场景。
性能特点
- 时间复杂度 O(n),每次迭代都更新累加器,典型的“折叠”操作。
reduce因为灵活,代码可能不够直观,对于简单的求和、拍平操作很合适,但过于复杂的累加逻辑会降低可读性。团队中建议优先使用map、filter等语义明确的方法,除非它们无法表达。
手写 find
find 返回第一个满足条件的元素,找不到返回 undefined。
Array.prototype.myFind = function(callback, thisArg) {
const arr = this;
const len = arr.length;
for (let i = 0; i < len; i++) {
if (i in arr && callback.call(thisArg, arr[i], i, arr)) {
return arr[i]; // 找到立即返回
}
}
return undefined;
};
性能特点
- 一旦找到满足条件的元素,立即终止循环,不需要遍历剩余元素。
- 这与
filter不同,filter必须遍历完毕。当只需要第一个匹配项时,find明显更快。
手写 some / every
some 判断是否至少有一个元素满足条件,every 判断是否全部满足。
Array.prototype.mySome = function(callback, thisArg) {
const arr = this;
const len = arr.length;
for (let i = 0; i < len; i++) {
if (i in arr && callback.call(thisArg, arr[i], i, arr)) {
return true; // 短路
}
}
return false;
};
Array.prototype.myEvery = function(callback, thisArg) {
const arr = this;
const len = arr.length;
for (let i = 0; i < len; i++) {
if (i in arr && !callback.call(thisArg, arr[i], i, arr)) {
return false; // 短路
}
}
return true;
};
性能特点
- 都支持短路运算:
some遇到true就返回,every遇到false就返回,不会遍历多余元素。 - 对于空数组,
some返回false,every返回true(数学上的空真),手写实现时要保持一致。
性能小结与选型建议
- 普通
for循环:性能之王,遍历开销最小,但代码相对繁琐且容易引入变量泄漏(var声明时)。ES6 后let在块级作用域中更加安全。 forEach:语义清晰,适合只读遍历、执行副作用,但无法break。map/filter/reduce:声明式风格,可组合,适合数据转换管道。额外函数调用和数组创建带来轻微开销,但在绝大多数业务场景都可忽略不计。find/some/every:在查找或判断场景下使用,短路特性避免了不必要的遍历,性能优于全量遍历。
实用建议:先追求代码的正确性和可读性,再用性能分析工具定位真正的瓶颈。除非处理数万级以上的数组且处于 UI 切换的关键路径,否则这些方法的性能差异都比不上清晰的代码结构重要。