手写数组方法既能加深对 JavaScript 语言本身的理解,也是面试中的高频考题。本文基于 ECMA 规范,剖析并手写 push、pop、map、reduce、filter、slice、find、every、some、indexOf、flat、concat 等常用方法的底层逻辑,并给出对应的实现。
push 方法的底层实现
push 的作用是在数组末尾追加一个或多个元素,并返回新的长度。ECMA 规范中的定义如下:
When the push method is called with zero or more arguments, the following steps are taken:
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(F(len)), E, true).
b. Set len to len + 1.
6. Perform ? Set(O, "length", F(len), true).
7. Return F(len).根据上述规范,转换为代码:
Array.prototype.push = function(...items) {
let O = Object(this); // 规范中的 ToObject:先转换为对象
let len = this.length >>> 0; // LengthOfArrayLike
let argCount = items.length >>> 0;
// 2^53 - 1 为 JS 能表示的最大安全正整数
if (len + argCount > 2 ** 53 - 1) {
throw new TypeError("The number of array is over the max value");
}
for (let i = 0; i < argCount; i++) {
O[len + i] = items[i]; // 依次追加元素
}
let newLength = len + argCount;
O.length = newLength; // 更新 length
return newLength;
};核心思路:向数组自身循环追加新元素,并把 length 调整为最新长度。其中对长度做 >>> 0 无符号位移,是为了将值规范化为 0 到 2^32-1 之间的无符号整数,这在很多底层源码中都会出现。
pop 方法的底层实现
pop 的作用是删除并返回数组的最后一个元素,数组为空时返回 undefined。ECMA 规范:
When the pop method is called, the following steps are taken:
1. Let O be ? ToObject(this value).
2. Let len be ? LengthOfArrayLike(O).
3. If len = 0, then
Perform ? Set(O, "length", +0F, true).
Return undefined.
4. Else,
Assert: len > 0.
Let newLen be F(len - 1).
Let index be ! ToString(newLen).
Let element be ? Get(O, index).
Perform ? DeletePropertyOrThrow(O, index).
Perform ? Set(O, "length", newLen, true).
Return element.转换为代码:
Array.prototype.pop = function() {
let O = Object(this);
let len = this.length >>> 0;
if (len === 0) {
O.length = 0;
return undefined; // 空数组返回 undefined
}
len--;
let value = O[len];
delete O[len]; // 删除最后一个元素
O.length = len; // 更新长度
return value;
};核心思路:删掉数组最后一个元素,更新 length,并返回被删除的元素。需要注意空数组时返回 undefined 的特殊处理。
map 方法的底层实现
map 的作用是对数组每个元素执行回调,返回由回调返回值组成的新数组。ECMA 规范:
When the map method is called with one or two arguments, the following steps are taken:
1. Let O be ? ToObject(this value).
2. Let len be ? LengthOfArrayLike(O).
3. If IsCallable(callbackfn) is false, throw a TypeError exception.
4. Let A be ? ArraySpeciesCreate(O, len).
5. Let k be 0.
6. Repeat, while k < len,
a. Let Pk be ! ToString(F(k)).
b. Let kPresent be ? HasProperty(O, Pk).
c. If kPresent is true, then
Let kValue be ? Get(O, Pk).
Let mappedValue be ? Call(callbackfn, thisArg, « kValue, F(k), O »).
Perform ? CreateDataPropertyOrThrow(A, Pk, mappedValue).
d. Set k to k + 1.
7. Return A.转换为代码:
Array.prototype.map = function(callbackfn, thisArg) {
// 处理 this 为 null/undefined
if (this === null || this === undefined) {
throw new TypeError("Cannot read property 'map' of null");
}
// 处理回调非函数
if (Object.prototype.toString.call(callbackfn) !== "[object Function]") {
throw new TypeError(callbackfn + " is not a function");
}
let O = Object(this);
let T = thisArg;
let len = O.length >>> 0;
let A = new Array(len); // 新数组
for (let k = 0; k < len; k++) {
if (k in O) {
let kValue = O[k];
// 依次传入 this、当前项、当前索引、整个数组
let mappedValue = callbackfn.call(T, kValue, k, O);
A[k] = mappedValue;
}
}
return A; // 返回新数组,不改变原数组
};map 的实现建立在 push/pop 的思路之上,关键在于遍历时调用回调并把返回值存入新数组,且不修改原数组。
reduce 方法的底层实现
reduce 的作用是对数组元素依次执行回调,将结果累积为单个值。ECMA 规范:
When the reduce method is called with one or two arguments, the following steps are taken:
1. Let O be ? ToObject(this value).
2. Let len be ? LengthOfArrayLike(O).
3. If IsCallable(callbackfn) is false, throw a TypeError exception.
4. If len = 0 and initialValue is not present, throw a TypeError exception.
5. Let k be 0.
6. Let accumulator be undefined.
7. If initialValue is present, then
Set accumulator to initialValue.
8. Else,
Let kPresent be false.
Repeat, while kPresent is false and k < len,
Let Pk be ! ToString(F(k)).
Set kPresent to ? HasProperty(O, Pk).
If kPresent is true, then
Set accumulator to ? Get(O, Pk).
Set k to k + 1.
If kPresent is false, throw a TypeError exception.
9. Repeat, while k < len, ...
10. Return accumulator.转换为代码:
Array.prototype.reduce = function(callbackfn, initialValue) {
// 处理 this 为 null/undefined
if (this === null || this === undefined) {
throw new TypeError("Cannot read property 'reduce' of null");
}
// 处理回调非函数
if (Object.prototype.toString.call(callbackfn) !== "[object Function]") {
throw new TypeError(callbackfn + " is not a function");
}
let O = Object(this);
let len = O.length >>> 0;
let k = 0;
let accumulator;
// 未提供初始值时,取第一个存在的元素作为累加器
if (arguments.length < 2) {
let kPresent = false;
while (!kPresent && k < len) {
kPresent = k in O;
if (kPresent) accumulator = O[k];
k++;
}
// 数组为空且无初始值,抛出异常
if (!kPresent) {
throw new TypeError("Reduce of empty array with no initial value");
}
} else {
accumulator = initialValue;
}
for (; k < len; k++) {
if (k in O) {
// reduce 的核心:累加器不断累积
accumulator = callbackfn.call(undefined, accumulator, O[k], O);
}
}
return accumulator;
};两个关键点需要注意:
- 初始值的特殊处理:未传初始值时,需跳过空槽取第一个存在的元素作为累加器;若数组为空则抛
TypeError。 - 累加器逻辑:每次把上一次的结果
accumulator作为回调的第一个参数传入。
slice 方法的底层实现
slice 返回由 start 到 end(不含)之间的元素组成的新数组,不改变原数组。支持负数索引。
Array.prototype.slice = function(start, end) {
let O = Object(this);
let len = O.length >>> 0;
// 处理 start:undefined 视为 0,负数则从末尾开始计算
let k = start === undefined ? 0 : start;
if (k < 0) k = Math.max(len + k, 0);
else k = Math.min(k, len);
// 处理 end:undefined 视为 len,负数则从末尾开始计算
let final = end === undefined ? len : end;
if (final < 0) final = Math.max(len + final, 0);
else final = Math.min(final, len);
let count = Math.max(final - k, 0);
let A = new Array(count);
let n = 0;
while (k < final) {
if (k in O) A[n] = O[k];
k++;
n++;
}
return A;
};核心思路:先规范化 start/end(兼容负数索引),再遍历取值放入新数组。slice 不会修改原数组,因此常用于拷贝数组的浅拷贝场景。
filter 方法的底层实现
filter 返回由所有满足回调条件的元素组成的新数组。不满足条件的元素被过滤掉。
Array.prototype.filter = function(callbackfn, thisArg) {
if (this === null || this === undefined) {
throw new TypeError("Cannot read property 'filter' of null");
}
if (Object.prototype.toString.call(callbackfn) !== "[object Function]") {
throw new TypeError(callbackfn + " is not a function");
}
let O = Object(this);
let len = O.length >>> 0;
let A = [];
let k = 0;
let to = 0;
while (k < len) {
if (k in O) {
let kValue = O[k];
// 只有回调返回 true 的元素才放入新数组
if (callbackfn.call(thisArg, kValue, k, O)) {
A[to++] = kValue;
}
}
k++;
}
return A;
};核心思路:遍历数组,对满足条件的元素执行 push 到新数组,最终返回过滤后的新数组,原数组不变。
find 方法的底层实现
find 返回数组中第一个满足回调条件的元素值,没有则返回 undefined。
Array.prototype.find = function(callbackfn, thisArg) {
if (this === null || this === undefined) {
throw new TypeError("Cannot read property 'find' of null");
}
if (Object.prototype.toString.call(callbackfn) !== "[object Function]") {
throw new TypeError(callbackfn + " is not a function");
}
let O = Object(this);
let len = O.length >>> 0;
let k = 0;
while (k < len) {
let kValue = O[k];
// 命中第一个满足条件的元素立即返回
if (callbackfn.call(thisArg, kValue, k, O)) {
return kValue;
}
k++;
}
return undefined;
};核心思路:遍历数组,遇到第一个满足条件的元素立即返回,整体采用「短路」逻辑,因此比 filter 性能更高(不遍历完整数组)。
every 与 some 方法的底层实现
every 判断数组所有元素是否都满足条件;some 判断是否至少一个元素满足条件。两者都返回布尔值。
Array.prototype.every = function(callbackfn, thisArg) {
if (this === null || this === undefined) {
throw new TypeError("Cannot read property 'every' of null");
}
if (Object.prototype.toString.call(callbackfn) !== "[object Function]") {
throw new TypeError(callbackfn + " is not a function");
}
let O = Object(this);
let len = O.length >>> 0;
let k = 0;
while (k < len) {
if (k in O && !callbackfn.call(thisArg, O[k], k, O)) {
return false; // 出现一个不满足即返回 false
}
k++;
}
return true;
};
Array.prototype.some = function(callbackfn, thisArg) {
if (this === null || this === undefined) {
throw new TypeError("Cannot read property 'some' of null");
}
if (Object.prototype.toString.call(callbackfn) !== "[object Function]") {
throw new TypeError(callbackfn + " is not a function");
}
let O = Object(this);
let len = O.length >>> 0;
let k = 0;
while (k < len) {
if (k in O && callbackfn.call(thisArg, O[k], k, O)) {
return true; // 出现一个满足即返回 true
}
k++;
}
return false;
};every 与 some 都是短路求值:every 遇不满足即返回 false,some 遇满足即返回 true,两者都能提前结束遍历。
indexOf 方法的底层实现
indexOf 返回指定元素在数组中首次出现的位置,不存在则返回 -1。
Array.prototype.indexOf = function(searchElement, fromIndex) {
let O = Object(this);
let len = O.length >>> 0;
if (len === 0) return -1;
// 处理 fromIndex:默认从 0 开始,支持负数
let n = fromIndex === undefined ? 0 : fromIndex;
let k;
if (n >= 0) {
k = Math.min(n, len - 1);
} else {
k = len - Math.abs(n); // 从末尾开始计数
if (k < 0) k = 0;
}
while (k < len) {
if (k in O && O[k] === searchElement) {
return k;
}
k++;
}
return -1;
};核心思路:从 fromIndex(支持负数)开始,使用全等运算符 === 逐个比较,找到即返回索引,否则返回 -1。
flat 方法的底层实现
flat(depth) 将数组按指定深度递归展平,返回新数组,不修改原数组。默认深度为 1。
Array.prototype.flat = function(depth = 1) {
let O = Object(this);
let len = O.length >>> 0;
let result = [];
const flatDepth = (arr, d) => {
for (let i = 0; i < arr.length; i++) {
const item = arr[i];
// 若仍是数组且未达到深度限制,则继续递归展平
if (Array.isArray(item) && d > 0) {
flatDepth(item, d - 1);
} else {
result.push(item);
}
}
};
flatDepth(O, depth);
return result;
};核心思路:借助递归 + 深度计数,遇到嵌套数组且深度未耗尽时继续递归,否则把元素推入结果。传入 Infinity 可展平任意深度。
concat 方法的底层实现
concat 将参数(数组或单个值)连接到原数组末尾,返回新数组,不修改原数组。若参数是数组则展开一层。
Array.prototype.concat = function(...items) {
let O = Object(this);
let A = [];
// 先拷贝原数组元素
for (let i = 0; i < O.length; i++) {
A.push(O[i]);
}
// 处理每个参数:数组展开一层,其余直接追加
for (let j = 0; j < items.length; j++) {
const item = items[j];
if (Array.isArray(item)) {
for (let k = 0; k < item.length; k++) {
A.push(item[k]);
}
} else {
A.push(item);
}
}
return A;
};核心思路:先拷贝自身元素,再遍历参数——数组参数展开一层后追加,非数组值直接追加,最终返回拼接后的新数组。
V8 源码参考
V8 引擎中数组方法的实现源码(供对照参考):
| 方法 | V8 源码 |
|---|---|
| pop | pop 的实现 |
| push | push 的实现 |
| map | map 的实现 |
| slice | slice 的实现 |
| filter | filter 的实现 |
| find | find 的实现 |
| every | every 的实现 |
| some | some 的实现 |
| indexOf | indexOf 的实现 |
| concat | concat 的实现 |
总结
手写数组方法的关键在于理解每个方法的规范步骤,并把它们转化为代码。本文覆盖了 push、pop、map、reduce 四个核心方法,并补充了 slice、filter、find、every、some、indexOf、flat、concat 等常用方法的手写实现。
整体可归纳为几类思路:
- 栈操作类(
push/pop):依赖数组自身的length与索引追加、删除。 - 遍历回调类(
map/filter/find/every/some):通过for循环 + 回调,关键是处理好回调参数、thisArg与返回值。 - 切片拼接类(
slice/concat/flat):返回新数组,不修改原数组,重点处理索引规范化与嵌套展开。
建议在此基础上举一反三,自己实现 reduceRight、flatMap、sort 等更多方法,从而系统化地掌握数组机制的底层原理。