先记住这个答案
map 和 filter 都会返回新数组,sort 不会,它在调用它的数组上原地排序,然后返回同一个数组。所以 arr.map(f).filter(g).sort(h) 新建了两个数组,不是三个,而原数组 arr 不受影响。除了外层数组,回调里新建的对象、字符串也是分配,而且常常比外层数组更多。如果映射和过滤都是纯函数,可以合并成一个循环直接写出最终结果,少一个中间数组;但这样会改变回调的执行顺序,有副作用时结果可能不一样。所以先确认结果等价,再在真实数据上测,确定是热点才改。
map、filter返回新数组,sort原地修改- 直接对源数组
sort会把源数据改掉 - 合并成一个循环能省中间数组,但回调顺序会变
reduce里写[...acc, x]是O(n²)的分配
数一数到底创建了几个数组
用 === 比较每一步的返回值和上一步,就能看出哪一步新建了数组。
const src = [3, 1, 2, 4]
const a = src.map((x) => x * 10) // 新数组
const b = a.filter((x) => x > 10) // 新数组
const c = b.sort((x, y) => x - y) // 原地排序,返回 b 本身
console.log(a === src, b === a, c === b)
console.log(JSON.stringify(src), JSON.stringify(c))查看输出与解释
false false true
[3,1,2,4] [20,30,40]c === b 是 true,说明 sort 没有新建数组。这里原数组 src 没被改,是因为 sort 作用在 filter 产生的新数组上。如果链的第一步就是 sort,比如 list.sort(...).map(...),被排序的就是 list 本身,React 状态、props、接口缓存都可能被悄悄改掉。需要不修改原数组的排序,用 ES2023 的 toSorted,它会返回一个排好序的新数组。
合并成一个循环会改变什么
把 map 和 filter 合并成一个循环,常被叫作循环融合。它省掉了中间数组,但执行顺序从「全部映射完再全部过滤」变成了「每一项映射完马上过滤」。
const nums = [1, 2, 3]
const log = []
nums.map((x) => { log.push('map' + x); return x * 2 })
.filter((y) => { log.push('filter' + y); return y > 2 })
console.log(log.join(' '))
const log2 = []
const out = []
for (const x of nums) {
log2.push('map' + x); const y = x * 2
log2.push('filter' + y); if (y > 2) out.push(y)
}
console.log(log2.join(' '), JSON.stringify(out))查看输出与解释
map1 map2 map3 filter2 filter4 filter6
map1 filter2 map2 filter4 map3 filter6 [4,6]两种写法最终结果相同,但回调的交错顺序完全不同。回调是纯函数时这无所谓;如果回调里有计数、打点、读写外部状态,融合后结果就可能变了。另外,map 和 filter 会跳过空位、会给回调传第三个参数(原数组),手写循环要把这些也考虑进去,否则就不是等价改写。
- 什么时候值得这样做
- 几百几千条数据的列表,中间数组的开销基本感知不到,保持链式写法更易读。值得优化的通常是这几种情况:每帧都要执行的逻辑,比如动画、滚动、拖拽时的计算;数据量到了几十万条;或者性能面板里确实能看到这段代码的垃圾回收占比很高。先用 Performance 面板或 Memory 面板确认,再动手。
容易答错的地方
- 以为链式调用每一步都会复制一份
sort、reverse是原地操作,不新建数组。反过来,它们会修改调用它们的数组,这是比多分配一个数组更常见的 bug 来源。- 在
reduce里展开累加器 arr.reduce((acc, x) => [...acc, x * 2], [])每一步都新建一个数组并把之前的元素全部复制过去,数组长度为 n 时总共复制约n²/2次,是O(n²)的。数据量一大,它会比map慢几个数量级。在reduce里应该直接acc.push(x)再return acc。- 只盯着外层数组
users.map((u) => ({ ...u, label: u.name + u.id }))每项都新建了一个对象和一个字符串。一万个用户就是一万个对象,外层数组只多了一个。优化内存时要先看回调里在造什么。
面试官还会怎么问?
toSorted 和 sort 怎么选?
需要保留原数组时用 toSorted,比如排序 React 状态或者 props;数组本来就是你自己新建的临时数组,用 sort 原地排序就行,少一次复制。toReversed、toSpliced、with 也是同一批加入的不修改原数组的版本。
有没有办法保持链式写法,又不产生中间数组?
ES2025 加入了迭代器辅助方法,可以写 arr.values().map(f).filter(g).toArray()。迭代器是惰性的,每一项依次经过 map 和 filter,只在最后 toArray 时生成一个数组。要注意目标环境是否支持,老环境需要 polyfill。
怎么确认这段代码真的有分配问题?
在 Chrome DevTools 的 Memory 面板用 Allocation sampling 录一段操作,看分配最多的函数;或者在 Performance 面板里看 Minor GC 是否频繁。改完后用同样的数据再测一次对比,没有明显变化就不值得牺牲可读性。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。