数组高阶方法:map/filter/reduce 详解

map/filter/reduce 不只是“更优雅的 for 循环”。小明带你从回调签名、稀疏数组、可变更副作用、性能与可读性权衡讲到工程实践:什么时候用、怎么写才不踩坑,以及面试如何讲出深度。

16 分钟阅读
小明

数组高阶方法:map/filter/reduce 详解

很多人学 map/filter/reduce 的路径是这样的:

  1. 看教程:哦,这就是把 for 写得更短。
  2. 写项目:全用 map/filter/reduce,感觉自己像函数式高手。
  3. 上线后:某天线上数据量一大,页面卡成 PPT;或者同事看不懂你的 reduce 链,开始骂人。

所以今天我们不讲“语法怎么写”(那太浅),我们要讲:

  • 它们到底在语义上承诺了什么
  • 哪些细节会让你写对/写错
  • 什么时候用它们是“优雅”,什么时候是“装逼写法”?

读完你应该能做到两件事:

  • 写出可读、可维护的数组处理代码
  • 面试里不只背结论,而是能讲出机制和权衡

1. 先统一一个底层视角:它们都是“对数组做一次遍历”

map/filter/reduce 的共同点:

  • 都会从左到右遍历数组(按索引递增)
  • 都会对每个元素执行回调(callback)
  • 都不会“提前退出”(不像 some/every/find

它们最大的不同不在“遍历”,而在:

  • map一进一出(每个元素对应一个新元素)
  • filter筛选(元素可能被丢掉)
  • reduce折叠/聚合(把很多元素合成一个结果)

2. 回调签名(signature):90% 的坑从这里开始

这三个方法的回调签名非常统一:

(element, index, array) => { /* ... */ }
  • element:当前元素
  • index:当前索引
  • array:原数组(同一个引用)

以及它们都支持一个可选参数 thisArg(回调内部的 this):

arr.map(function (x) {
  return this.multiplier * x
}, { multiplier: 2 })

工程建议:

  • 新代码尽量用箭头函数(避免 this 语义复杂)
  • 但你要知道 thisArg 存在,因为老代码/某些库会用它

3. map:不是“循环”,是“映射关系”

3.1 map 的语义:长度不变、位置对应

map 适合“把每一项变成另一项”。它承诺:

  • 输出数组长度与输入相同
  • 索引 i 的结果只来自索引 i 的元素(理想情况下,不写副作用)
const prices = [10, 20, 30]
const withTax = prices.map(p => p * 1.06)
// [10.6, 21.2, 31.8]

3.2 map 的经典反例:你只是想做副作用

users.map(u => console.log(u.name))

这段代码“能跑”,但语义是错的:你创建了一个没用的新数组。

更推荐:

  • 做副作用:forEach
  • 需要输出:map

3.3 map 的工程坑:异步 map 不是你想的那样

很多人写过:

const results = ids.map(async (id) => {
  const res = await fetch(`/api/users/${id}`)
  return res.json()
})

console.log(results) // [Promise, Promise, ...]

map 不会帮你 await。

正确姿势:

const results = await Promise.all(
  ids.map(async (id) => {
    const res = await fetch(`/api/users/${id}`)
    return res.json()
  })
)

这是一个很好的“面试加分点”:

  • 你理解 map 的返回值
  • 你知道如何处理 Promise 数组

4. filter:不是“返回 boolean 就行”,而是“保留哪些元素”

4.1 filter 的语义:保留原始元素,不改变元素本身

const nums = [1, 2, 3, 4, 5]
const evens = nums.filter(n => n % 2 === 0)
// [2, 4]

注意:filter 返回的是“原元素引用”(如果元素是对象),不是拷贝。

const arr = [{ x: 1 }, { x: 2 }]
const gt1 = arr.filter(o => o.x > 1)

gt1[0].x = 999
console.log(arr[1].x) // 999(同一个对象)

4.2 filter 的坑:你以为在删元素,其实只是生成新数组

filter 不会修改原数组(它是非变更方法)。

如果你要在原数组上“删除”,要么:

  • splice
  • 要么用不可变策略:arr = arr.filter(...)

4.3 filter 与 find:一个要“全部”,一个要“第一个”

  • filter:永远遍历完整个数组(没有提前退出)
  • find:找到第一个就停(更高效)

所以当你只想找一个元素时,别用 filter()[0]


5. reduce:最强也最危险,像瑞士军刀

reduce 的定义很抽象,但一句话就够:

用一个累加器(accumulator),把数组从左到右“折叠”成一个结果。

它的回调签名是:

(acc, cur, index, array) => nextAcc

5.1 reduce 的两种形态:有无 initialValue

  • initialValue:acc 从 initialValue 开始,cur 从索引 0 开始
  • 没有 initialValue:acc 从 arr0 开始,cur 从索引 1 开始

这会导致两个重要差异:

  1. 空数组 + 无 initialValue 会直接报错
  2. 类型很容易被你“意外改变”

强烈建议:

工程里写 reduce,尽量总是传 initialValue

5.2 用 reduce 做求和(正确示范)

const nums = [1, 2, 3]
const sum = nums.reduce((acc, cur) => acc + cur, 0)
// 6

5.3 用 reduce 做分组(非常实用)

const people = [
  { name: '小明', city: '上海' },
  { name: '小红', city: '北京' },
  { name: '小刚', city: '上海' }
]

const groupByCity = people.reduce((acc, p) => {
  if (!acc[p.city]) acc[p.city] = []
  acc[p.city].push(p)
  return acc
}, {})

// { 上海: [...], 北京: [...] }

这里的 acc 是对象(哈希表),reduce 负责把“很多元素”聚合成“一个索引结构”。

这段写法的深度在于:

  • 你把 reduce 的本质(折叠)和哈希表结合了
  • 它是高频的工程模式

5.4 reduce 的危险:滥用会让代码变成“读心术”

如果你的 reduce 里面:

  • 同时在做映射、筛选、排序、格式化
  • 还写了很多 if/else

那它通常不叫“函数式”,叫“把复杂度折叠进一行”。

建议:

  • reduce 只做一个清晰的聚合目标
  • 复杂流程拆成小步骤(可读性 > 一行炫技)

6. 稀疏数组(holes):这三个方法不会“补洞”

JavaScript 里有一种很容易被忽略的数组:稀疏数组。

const arr = [1, , 3] // 中间是一个洞,不是 undefined

在稀疏数组上:

  • map/filter/reduce 会跳过不存在的索引(hole)

这会导致一些反直觉的结果:

const arr = [1, , 3]
console.log(arr.map(x => x * 2))
// [2, <1 empty item>, 6]

工程建议:

  • 不要主动制造稀疏数组
  • 如果你拿到的数据可能稀疏,先标准化(比如用 Array.from 或填充)

7. 性能与内存:链式调用很爽,但不是免费的

map/filter/reduce 都会创建新数组/新对象(reduce 视实现而定)。

比如:

const result = arr
  .filter(x => x > 0)
  .map(x => x * 2)

这会至少创建一个中间数组(filter 的结果),再创建一个最终数组(map 的结果)。

工程取舍:

  • 数据量小(UI 列表、配置处理):链式写法可读性更重要
  • 数据量大(10w+)、性能敏感:考虑
    • 用一次循环合并逻辑
    • 或者用 reduce 做单次遍历聚合(但要保持可读)

不要迷信“函数式一定更慢”或“for 一定更快”。关键在:

  • 你是否创建了大量中间数据
  • 你是否在热路径(频繁执行)上这么写

8. 面试回答模板:如何讲出“不是背的”

你可以这样回答:

  • map:把数组映射成同长度的新数组,适合纯转换;不应做副作用。
  • filter:按 predicate 保留元素,适合筛选;找单个用 find 更合适。
  • reduce:用累加器聚合成一个结果;工程上建议总传 initialValue。
  • 深度点:稀疏数组会被跳过;异步 map 要配合 Promise.all;链式调用有中间数组成本。

这套回答一说出来,基本就不是“背 API”水平了。


总结

  • map 做转换,filter 做筛选,reduce 做聚合。
  • 回调签名与 initialValue 是坑点高发区。
  • 异步 map 记得 Promise.all
  • 链式调用可读性高,但会创建中间数组;性能敏感场景要权衡。

小明金句收尾:

会写 map/filter/reduce 不难,难的是知道什么时候该写,什么时候该停。