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()要求提供初始值 和 reducerreduce()只要求提供 reducer
我们先看看 Array.prototype.reduce() 的参数和返回值:
callbackFn,归一化函数,最多接收四个参数:previousValue:回调函数的上一个执行结果currentValue:当前的元素currentIndex:当前元素对应的索引target:当前元素所在的数组
注意 callbackFn 不接受 thisArg,其对应的 this 是 undefined。
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