数组高阶方法:map/filter/reduce 详解
map/filter/reduce 不只是“更优雅的 for 循环”。小明带你从回调签名、稀疏数组、可变更副作用、性能与可读性权衡讲到工程实践:什么时候用、怎么写才不踩坑,以及面试如何讲出深度。
数组高阶方法:map/filter/reduce 详解
很多人学 map/filter/reduce 的路径是这样的:
- 看教程:哦,这就是把
for写得更短。 - 写项目:全用
map/filter/reduce,感觉自己像函数式高手。 - 上线后:某天线上数据量一大,页面卡成 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 开始
这会导致两个重要差异:
- 空数组 + 无 initialValue 会直接报错
- 类型很容易被你“意外改变”
强烈建议:
工程里写 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 不难,难的是知道什么时候该写,什么时候该停。