文章

手写数组方法实现深度解析:从 map/filter/reduce 到 flat 的多路径实现

手写数组方法实现深度解析:从 map/filter/reduce 到 flat 的多路径实现

一句话概括

手写数组方法(map/filter/reduce/flat)是 JavaScript 函数式编程的基础试炼——每个方法都蕴含了”高阶函数 + 迭代器模式 + 内存管理”的底层逻辑,而 flat 的多层展开则涉及递归/栈/迭代器三种实现策略的博弈。

背景与意义

为什么面试爱考数组手写题

数组的 mapfilterreduceflat 是日常开发中使用频率最高的方法。但”会用”和”会实现”之间差了一个鸿沟:

  1. 理解高阶函数:这些方法都接收回调函数作为参数,手写它们需要对”函数作为一等公民”有切实体会
  2. 聚沙成塔reduce 可以模拟几乎所有其他数组方法(map/filter/some/every 等),手写 reduce 等于掌握了数组操作的”元方法”
  3. 边界条件sparse array(稀疏数组,如 [1, , 3])、thisArglength 变化的处理
  4. 多解思维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 规范中定义了每个数组方法的”迭代语义”,核心是 CreateIterResultObjectHasProperty 操作。

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 等也有类似逻辑但返回的是 TypedArray

  • Lazy Evaluation:使用 Lazy.jsRxJS 做惰性数组操作,只在最终取值时才执行迭代

理解这些数组方法的内部机制后,你会发现所谓的”简单手写题”里藏着 JavaScript 语言设计的很多决定——这些决定在你日常使用它们时是被隐藏的,但手写它们时才真正显现出来。

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量