手写数组方法实现深度解析:从 map/filter/reduce 到 flat 的多路径实现
一句话概括
手写数组方法(map/filter/reduce/flat)是 JavaScript 函数式编程的基础试炼——每个方法都蕴含了”高阶函数 + 迭代器模式 + 内存管理”的底层逻辑,而 flat 的多层展开则涉及递归/栈/迭代器三种实现策略的博弈。
背景与意义
为什么面试爱考数组手写题
数组的 map、filter、reduce、flat 是日常开发中使用频率最高的方法。但”会用”和”会实现”之间差了一个鸿沟:
- 理解高阶函数:这些方法都接收回调函数作为参数,手写它们需要对”函数作为一等公民”有切实体会
- 聚沙成塔:
reduce可以模拟几乎所有其他数组方法(map/filter/some/every 等),手写 reduce 等于掌握了数组操作的”元方法” - 边界条件:
sparse array(稀疏数组,如[1, , 3])、thisArg、length变化的处理 - 多解思维:
flat可以用递归、栈、迭代器、Generator 四种方式实现,考验算法广度
这些方法的微妙之处
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 你以为很了解 map?试试这些:
[1, 2, 3].map(parseInt); // [1, NaN, NaN] — 为什么?
[1, , 3].map(x => x * 2); // [2, empty, 6] — 稀疏数组保留空洞
[1, 2, 3].map((x) => x); // 正常
// filter 的隐秘行为
[1, 2, 3].filter(x => x); // [1, 2, 3]
[1, NaN, 2].filter(x => x); // [1, 2] — NaN 被过滤了
[, null, 0, undefined].filter(x => x); // [] — 所有都被过滤
// reduce 的初始值
[].reduce((a, b) => a + b); // TypeError!
[].reduce((a, b) => a + b, 0); // 0 — 初始值的魔力
[1].reduce((a, b) => a + b); // 1 — 无初始值时直接返回第一个元素
概念与定义
核心方法签名
| 方法 | 签名 | 返回值 | 是否改变原数组 |
|---|---|---|---|
map | (callback(item, index, array), thisArg?) | 新数组 | 否 |
filter | (callback(item, index, array), thisArg?) | 过滤后的新数组 | 否 |
reduce | (callback(acc, item, index, array), initialValue?) | 累计值 | 否 |
flat | (depth = 1) | 展平后的新数组 | 否 |
共同特性
- 所有方法在调用前都会获取
this.length的快照,回调中添加或删除元素不会影响迭代范围 - 回调中跳过稀疏数组的”空洞”(empty slots)
- 它们都属于 ECMAScript 的”数组迭代方法”系列
最小示例
Array.prototype.map
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Array.prototype.myMap = function (callback, thisArg) {
if (this == null) {
throw new TypeError('Cannot read properties of null or undefined');
}
if (typeof callback !== 'function') {
throw new TypeError(callback + ' is not a function');
}
const array = Object(this); // 处理类数组对象
const length = array.length >>> 0; // 确保 length 是正整数
const result = new Array(length);
for (let i = 0; i < length; i++) {
if (i in array) { // 跳过稀疏数组的空洞
result[i] = callback.call(thisArg, array[i], i, array);
}
// 注意:空洞在结果数组中也是空洞,而不是 undefined
}
return result;
};
Array.prototype.filter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Array.prototype.myFilter = function (callback, thisArg) {
if (this == null) throw new TypeError();
if (typeof callback !== 'function') throw new TypeError();
const array = Object(this);
const length = array.length >>> 0;
const result = [];
for (let i = 0; i < length; i++) {
if (i in array) {
const value = array[i];
if (callback.call(thisArg, value, i, array)) {
result.push(value);
}
}
}
return result;
};
Array.prototype.reduce
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
Array.prototype.myReduce = function (callback, initialValue) {
if (this == null) throw new TypeError();
if (typeof callback !== 'function') throw new TypeError();
const array = Object(this);
const length = array.length >>> 0;
let accumulator = initialValue;
let startIndex = 0;
// 如果没有提供初始值,使用第一个存在的元素作为初始值
if (arguments.length < 2) {
if (length === 0) {
throw new TypeError('Reduce of empty array with no initial value');
}
// 找到第一个非空洞的索引
while (startIndex < length && !(startIndex in array)) {
startIndex++;
}
if (startIndex >= length) {
throw new TypeError('Reduce of empty array with no initial value');
}
accumulator = array[startIndex];
startIndex++;
}
for (let i = startIndex; i < length; i++) {
if (i in array) {
accumulator = callback(accumulator, array[i], i, array);
}
}
return accumulator;
};
Array.prototype.flat (递归版)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Array.prototype.myFlat = function (depth = 1) {
if (this == null) throw new TypeError();
const array = Object(this);
const length = array.length >>> 0;
function flatten(arr, currentDepth) {
const result = [];
for (let i = 0; i < arr.length; i++) {
if (i in arr) {
const value = arr[i];
if (Array.isArray(value) && currentDepth > 0) {
result.push(...flatten(value, currentDepth - 1));
} else {
result.push(value);
}
}
}
return result;
}
return flatten(array, depth);
};
核心知识点拆解
1. map 的稀疏数组处理
稀疏数组是面试中最容易被遗忘的细节:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const sparse = [1, , 3]; // 索引 1 处不存在
// 原生 map 的行为
sparse.map((x, i) => {
console.log(`index ${i}:`, x);
return x * 2;
});
// 输出: index 0: 1
// index 2: 3
// 结果: [2, empty, 6] — 不是 [2, undefined, 6]!
// 错误实现(未检查 i in array)
Array.prototype.badMap = function (callback, thisArg) {
const result = [];
for (let i = 0; i < this.length; i++) {
result.push(callback.call(thisArg, this[i], i, this));
// ^^^^^ 如果是稀疏数组,this[1] 是 undefined
// 结果变成 [2, undefined, 6]
}
return result;
};
关键:使用 i in array 检查索引是否存在,而不是直接读取 array[i]。
2. reduce 的无初始值处理
reduce 是最复杂的数组方法,需要处理两个关键场景:
1
2
3
4
5
6
7
8
9
// 场景 1:空数组 + 无初始值 → 抛错
[].myReduce((a, b) => a + b);
// TypeError: Reduce of empty array with no initial value
// 场景 2:单元素数组 + 无初始值 → 直接返回该元素
[42].myReduce((a, b) => a + b); // 42 — 不调用 callback
// 场景 3:跳过稀疏数组的空洞
[1, , 2].myReduce((a, b) => a + b); // 3 — 跳过空洞
3. reduce 的万能性
reduce 可以模拟几乎所有数组迭代方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 用 reduce 实现 map
function mapWithReduce(arr, fn) {
return arr.reduce((acc, item, index, array) => {
acc.push(fn(item, index, array));
return acc;
}, []);
}
// 用 reduce 实现 filter
function filterWithReduce(arr, fn) {
return arr.reduce((acc, item, index, array) => {
if (fn(item, index, array)) {
acc.push(item);
}
return acc;
}, []);
}
// 用 reduce 实现 flat (一层)
function flatWithReduce(arr) {
return arr.reduce((acc, item) => {
return acc.concat(Array.isArray(item) ? item : [item]);
}, []);
}
// 用 reduce 实现 some
function someWithReduce(arr, fn) {
return arr.reduce((acc, item, index, array) => {
return acc || !!fn(item, index, array);
}, false);
}
// 用 reduce 实现 every
function everyWithReduce(arr, fn) {
return arr.reduce((acc, item, index, array) => {
return acc && !!fn(item, index, array);
}, true);
}
4. flat 的多种实现策略
flat 的实现有四种主要策略,各有优劣:
策略 1:递归
1
2
3
4
5
6
7
8
9
10
11
12
13
function flatRecursive(arr, depth = 1) {
if (depth <= 0) return arr.slice();
const result = [];
for (const item of arr) {
if (Array.isArray(item)) {
result.push(...flatRecursive(item, depth - 1));
} else {
result.push(item);
}
}
return result;
}
优点:代码简洁 缺点:深度大时栈溢出
策略 2:显式栈
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function flatStack(arr, depth = 1) {
const result = [];
const stack = arr.map(item => ({ item, depth }));
while (stack.length > 0) {
const { item, depth } = stack.pop();
if (Array.isArray(item) && depth > 0) {
// 将数组元素反向压入栈(保持顺序)
for (let i = item.length - 1; i >= 0; i--) {
stack.push({ item: item[i], depth: depth - 1 });
}
} else {
result.push(item);
}
}
return result;
}
优点:无栈溢出风险 缺点:需要管理栈
策略 3:Generator
1
2
3
4
5
6
7
8
9
10
11
12
13
function* flatGenerator(arr, depth = 1) {
for (const item of arr) {
if (Array.isArray(item) && depth > 0) {
yield* flatGenerator(item, depth - 1);
} else {
yield item;
}
}
}
// 使用
const flatArr = [...flatGenerator([1, [2, [3, [4]]]], 2)];
// [1, 2, 3, [4]]
优点:惰性求值,只消耗需要的元素 缺点:需要转为数组时开销大
策略 4:循环 + concat
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function flatLoop(arr, depth = 1) {
let result = arr.slice();
while (depth > 0) {
let hasArray = false;
const next = [];
for (const item of result) {
if (Array.isArray(item)) {
next.push(...item);
hasArray = true;
} else {
next.push(item);
}
}
result = next;
depth--;
if (!hasArray) break; // 提前终止
}
return result;
}
优点:无递归风险,性能较好 缺点:空间开销大(多次创建数组)
实战案例:数据处理管道
构建一个通用的数据处理管道,将多个数组方法组合起来,用于处理真实业务数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
// 数据:电商平台订单列表
const orders = [
{ id: 1, userId: 101, items: [
{ product: '手机', price: 3999, qty: 1 },
{ product: '手机壳', price: 19, qty: 2 },
], createdAt: '2026-07-01' },
{ id: 2, userId: 102, items: [
{ product: '笔记本', price: 8999, qty: 1 },
], createdAt: '2026-07-01' },
{ id: 3, userId: 101, items: [
{ product: '耳机', price: 599, qty: 1 },
], createdAt: '2026-07-02' },
{ id: 4, userId: 103, items: [
{ product: '手机', price: 3999, qty: 2 },
{ product: '充电器', price: 129, qty: 1 },
{ product: '保护膜', price: 9.9, qty: 3 },
], createdAt: '2026-07-03' },
];
// 数据分析管道
class DataPipeline {
constructor(data) {
this.data = data;
}
// 自定义 map
map(fn) {
this.data = this.data.myMap(fn);
return this;
}
// 自定义 filter
filter(fn) {
this.data = this.data.myFilter(fn);
return this;
}
// 自定义 reduce
reduce(fn, initial) {
return this.data.myReduce(fn, initial);
}
// 自定义 flatMap
flatMap(fn) {
this.data = this.data.myMap(fn).myFlat(1);
return this;
}
// 获取处理后的数据
value() {
return this.data;
}
}
// 使用管道进行数据分析
// 需求 1:计算用户 101 的所有订单总金额
const totalForUser101 = new DataPipeline(orders)
.filter(order => order.userId === 101)
.flatMap(order => order.items)
.reduce((total, item) => total + item.price * item.qty, 0);
console.log(`用户 101 总消费: ¥${totalForUser101}`);
// 用户 101 总消费: ¥(3999*1 + 19*2 + 599*1)= 4636
// 需求 2:找出销量 top 3 的产品
const topProducts = new DataPipeline(orders)
.flatMap(order => order.items)
.reduce((acc, item) => {
const existing = acc.find(p => p.product === item.product);
if (existing) {
existing.qty += item.qty;
existing.revenue += item.price * item.qty;
} else {
acc.push({
product: item.product,
qty: item.qty,
revenue: item.price * item.qty,
});
}
return acc;
}, [])
.sort((a, b) => b.qty - a.qty)
.slice(0, 3);
console.log('销量 TOP 3:', topProducts);
// 销量 TOP 3: [
// { product: '手机', qty: 3, revenue: 11997 },
// { product: '保护膜', qty: 3, revenue: 29.7 },
// { product: '手机壳', qty: 2, revenue: 38 },
// ]
底层原理
1. ES 规范中的数组方法实现逻辑
ECMAScript 规范中定义了每个数组方法的”迭代语义”,核心是 CreateIterResultObject 和 HasProperty 操作。
以 Array.prototype.map 的规范流程为例(ES2025 规范):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. Let O = ? ToObject(this value)
2. Let len = ? LengthOfArrayLike(O)
3. If IsCallable(callbackfn) is false, throw TypeError
4. If thisArg is present, set T to thisArg; else set T to undefined
5. Let A = ? ArraySpeciesCreate(O, len) // 创建结果数组(保持类型)
6. Let k = 0
7. Repeat, while k < len
a. Let Pk = ! ToString(𝔽(k))
b. Let kPresent = ? HasProperty(O, Pk)
c. If kPresent is true, then
i. Let kValue = ? Get(O, Pk)
ii. Let mappedValue = ? Call(callbackfn, T, « kValue, 𝔽(k), O »)
iii. Perform ? CreateDataPropertyOrThrow(A, Pk, mappedValue)
d. Set k = k + 1
8. Return A
重点:
- 步骤 7b:
HasProperty(O, Pk)— 这就是跳过空洞的原因 - 步骤 7c: 只有在
kPresent为 true 时才会执行 callback - 步骤 5:
ArraySpeciesCreate— 如果数组有自定义构造函数(如子类),会使用该构造函数创建结果数组
2. 稀疏数组的内存结构(V8 视角)
V8 中,数组内部表示有三种模式:
1
2
3
4
5
6
7
8
9
10
11
12
// 模式 1:Fast Elements(快数组)
// 对于密集数组,使用连续内存的 FixedArray。
// 访问 O(1),无空洞
const dense = [1, 2, 3];
// 模式 2:Dictionary Elements(字典数组)
// 对于非常稀疏的数组,使用哈希表存储。
// 访问 O(log n),有大量空洞
const sparse = [1, , , , , , , , 100];
// 模式 3:Slow Elements(慢数组)
// 当数组的 prototype 被修改或有自定义 setter 时退化到慢模式
对 map 的影响:
- 快数组上的
map直接遍历 FixedArray,速度极快 - 字典数组上的
map需要哈希表查找,速度较慢 for (let i = 0; i < length; i++)结合in操作符,在字典模式下需要检查每个索引
3. reduce 的尾调用优化
理论上 reduce 可以应用尾调用优化(TCO),但实际 V8 的实现并未进行 TCO(只有 Safari 的 JSC 实现了 TCO):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 如果用递归实现 reduce(不能利用 TCO):
function reduceRecursive(arr, fn, acc, index = 0) {
if (index >= arr.length) return acc;
return reduceRecursive(arr, fn, fn(acc, arr[index], index, arr), index + 1);
// 这个递归调用在严格模式下本应被 TCO,
// 但 V8 实际上没有实现 TCO (已被取消)
}
// 实际上 V8 的 reduce 是循环实现:
function reduceIterative(arr, fn, acc, index = 0) {
for (let i = index; i < arr.length; i++) {
acc = fn(acc, arr[i], i, arr);
}
return acc;
}
高频面试题解析
面试题 1:用 reduce 实现一个 map 函数,要求支持 thisArg,且正确处理稀疏数组。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Array.prototype.myMapViaReduce = function (callback, thisArg) {
if (this == null) throw new TypeError();
if (typeof callback !== 'function') throw new TypeError();
const array = Object(this);
const length = array.length >>> 0;
return this.reduce((acc, item, index, arr) => {
// reduce 已自动跳过稀疏数组的空洞
// 但我们需要在原数组的对应位置插入值
if (index in array) {
acc[index] = callback.call(thisArg, item, index, arr);
}
return acc;
}, new Array(length));
};
面试题 2:实现一个 flat 方法,支持通过 depth = Infinity 完全展平。要求:不使用原生 flat,不使用 while 循环递归嵌套太深导致栈溢出。
解答——使用队列广度优先展开:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
Array.prototype.myFlat_Infinity = function (depth = 1) {
if (this == null) throw new TypeError();
const result = [];
const queue = [{ value: this, depth: depth, index: 0 }];
while (queue.length > 0) {
const current = queue.shift(); // 用 shift 实现 BFS
if (!Array.isArray(current.value) || current.depth < 0) {
result.push(current.value);
continue;
}
// 将数组展开为单个任务
for (let i = 0; i < current.value.length; i++) {
if (i in current.value) {
queue.push({
value: current.value[i],
depth: current.depth - 1,
index: i,
});
}
}
}
return result;
};
// 实际上,更高效的是用栈(DFS)
Array.prototype.myFlat_Safe = function (depth = 1) {
if (this == null) throw new TypeError();
const result = [];
const stack = [];
// 将原始数组逆序入栈
for (let i = this.length - 1; i >= 0; i--) {
if (i in this) {
stack.push({ value: this[i], depth });
}
}
while (stack.length > 0) {
const { value, depth } = stack.pop();
if (Array.isArray(value) && depth > 0) {
// 将数组元素逆序入栈
for (let i = value.length - 1; i >= 0; i--) {
if (i in value) {
stack.push({ value: value[i], depth: depth - 1 });
}
}
} else {
result.push(value);
}
}
return result;
};
// 测试
const deeplyNested = [1, [2, [3, [4, [5]]]]];
console.log(deeplyNested.myFlat_Safe(Infinity));
// [1, 2, 3, 4, 5]
// 即使嵌套很深也不会栈溢出
面试题 3:实现一个自定义的 groupBy 方法,类似于 SQL 的 GROUP BY,但基于数组。API 设计:[].groupBy(keySelector, valueSelector?)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
Array.prototype.myGroupBy = function (keySelector, valueSelector) {
if (this == null) throw new TypeError();
if (typeof keySelector !== 'function') throw new TypeError();
const map = new Map();
valueSelector = valueSelector || (value => value);
for (let i = 0; i < this.length; i++) {
if (i in this) {
const item = this[i];
const key = keySelector(item, i, this);
const value = valueSelector(item, i, this);
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(value);
}
}
return map;
};
// 使用示例
const students = [
{ name: 'Alice', grade: 'A', class: '一班' },
{ name: 'Bob', grade: 'B', class: '一班' },
{ name: 'Charlie', grade: 'A', class: '二班' },
{ name: 'David', grade: 'C', class: '一班' },
{ name: 'Eve', grade: 'B', class: '二班' },
];
// 按班级分组
const byClass = students.myGroupBy(s => s.class, s => s.name);
console.log(byClass);
// Map {
// '一班' => ['Alice', 'Bob', 'David'],
// '二班' => ['Charlie', 'Eve']
// }
// 按成绩分组
const byGrade = students.myGroupBy(s => s.grade, s => ({ name: s.name, class: s.class }));
console.log(byGrade);
// Map {
// 'A' => [{ name: 'Alice', class: '一班' }, { name: 'Charlie', class: '二班' }],
// 'B' => [{ name: 'Bob', class: '一班' }, { name: 'Eve', class: '二班' }],
// 'C' => [{ name: 'David', class: '一班' }]
// }
// 回到最初的问题:为什么 [1,2,3].map(parseInt) 结果是 [1, NaN, NaN]?
// parseInt 接收两个参数:(value, radix)
// map 传给 callback 的是 (item, index, array)
// 所以:
// parseInt(1, 0, [1,2,3]) → radix=0 → 按 10 进制解析 → 1
// parseInt(2, 1, [1,2,3]) → radix=1 → 无效进制 → NaN
// parseInt(3, 2, [1,2,3]) → radix=2 → 二进制不能有 3 → NaN
// 解决方案:
[1, 2, 3].map(x => parseInt(x)); // [1, 2, 3]
总结与扩展
手写数组方法表面上是一道”考核 API 熟悉度”的题目,但深入后会触及 JavaScript 的规范细节([[HasProperty]]、ArraySpeciesCreate)、V8 内部表示(fast/dictionary elements)、以及函数式编程的高阶函数思想。
值得进一步探索的方向:
- Immutable 数组操作:使用
toSorted()(ES2023)、toSpliced()、with()等新方法实现无副作用的数组操作 - Transducer 思想:将所有数组操作组合为单个迭代转换,避免创建中间数组
1
2
3
4
5
6
7
8
9
10
11
12
13
// Transducer 式组合
function compose(...fns) {
return fns.reduceRight((f, g) => (...args) => f(g(...args)));
}
const processNumbers = compose(
arr => arr.filter(n => n > 10),
arr => arr.map(n => n * 2),
arr => arr.slice(0, 5),
);
processNumbers([5, 12, 8, 20, 15, 30, 3]);
// [24, 40, 30, 60] — 但每次操作都创建了新数组
TypedArray 上的数组方法:
Int32Array.prototype.map等也有类似逻辑但返回的是 TypedArrayLazy Evaluation:使用
Lazy.js或RxJS做惰性数组操作,只在最终取值时才执行迭代
理解这些数组方法的内部机制后,你会发现所谓的”简单手写题”里藏着 JavaScript 语言设计的很多决定——这些决定在你日常使用它们时是被隐藏的,但手写它们时才真正显现出来。