数组这块内容,我前后翻过不下五遍文档,每次都有新发现。最早写业务代码的时候,我的习惯很粗暴:要遍历就forEach,要过滤就filter,要拼数据就reduce一把梭,能跑就行。直到有一次排查一个线上列表错乱的问题,最后定位到sort()没传比较函数,把数字当成字符串排了序,我才意识到 Array 数组相关 API 这东西,光"知道有"和"用得对"之间隔着一整条鸿沟。这篇文章想做的事很具体:把日常高频用到的数组 API 按"会不会改原数组""什么时候该用哪个""哪里最容易翻车"三条线重新串一遍,把参数含义、边界行为、性能取舍讲透。不管你是刚接触 JS 不久、还在靠for循环撑场面,还是已经写了几年业务、想把手上的方法用得更精准,下面这些内容应该都能直接拿去对照自己的代码。
1. 先搞清楚这张方法地图,比死记 API 名有用
1.1 数组 API 的真正分类维度
很多人学数组方法的方式是按字母顺序或者按 ES 版本背,背完发现一用就乱。我的经验是,分类维度只有一个真正有用:这个方法会不会修改原数组。这一条决定了你在 React、Vue 这类依赖引用比较的框架里会不会莫名其妙不更新视图,也决定了你在函数式风格代码里会不会意外污染上游数据。
按这个维度切,日常用的方法大概长这样:
| 类别 | 会改原数组 | 不改原数组 |
|---|---|---|
| 增删 | pushpopshiftunshiftsplicefillcopyWithin | sliceconcattoSplicedtoReversed |
| 排序 | sortreverse | toSortedtoReversed |
| 遍历 | forEach(无返回值) | mapflatMapfilterreducereduceRight |
| 查找 | — | findfindIndexfindLastfindLastIndexindexOflastIndexOfincludes |
| 判定 | — | someevery |
| 转换 | — | jointoStringflat |
| 生成 | — | Array.fromArray.ofArray.isArray |
这张表我建议你手写一遍,不是因为要背诵,而是写的过程中你会自然产生一个问题:"为什么sort是改原数组的?"这个问题会带你去看sort的实现逻辑,然后你就会理解它为什么需要比较函数、为什么默认行为那么反直觉。
1.2 为什么变异方法在框架里容易出事
举个最常见的场景。你拿到一个列表数据,想在渲染前按某个字段排序:
const sorted = list.sort((a, b) => a.score - b.score)这行代码看起来毫无问题,sorted也确实是对的。但list本身已经被改掉了。如果list是某个状态对象里的一部分,或者在别的地方还被引用着,那你就在一个完全不相关的位置制造了一个隐蔽的副作用。我见过最典型的情况是,同一个数组被两个组件同时持有,A 组件排了个序,B 组件展示顺序就莫名其妙变了,排查半天找不到原因。
所以我现在写代码有个硬规矩:凡是接收外部数组作为参数的函数,内部第一件事就是把它复制一份。复制方式后面会细讲,但这一步能挡掉八成以上的"数据莫名被改"问题。
1.3 从 ES5 到 ES2023,哪些新增是真提升效率的
新增方法里,我觉得真正改变写法的只有三个半。
flatMap算一个,它把"先 map 再 flat"的两步压成一步,写树形结构展开特别顺手。findLast算一个,以前要从后往前找只能reverse再find,或者写个倒序for循环,现在一行搞定。toSorted/toReversed/toSpliced/with这一组不可变方法算一个半,它们的价值不在于语法糖,而在于让你在函数式链条里不用担心中途改坏原数据。
至于at(),很多人觉得它只是个支持负索引的语法糖,实际用下来在取"最后一个元素"的场景确实清爽,arr[arr.length - 1]这种写法可以退休了。但要注意,at()在旧环境里不一定有,如果你要兼容比较老的运行环境,该做的降级还是得做。
提示:新增方法不等于必须用。判断标准很简单——它是否让你的代码更短且更难出错。如果只是看起来新潮,那就先放着。
2. 遍历三兄弟:forEach、map、flatMap 到底怎么选
2.1 forEach 的定位被严重误解了
forEach最常见的误用是拿它来"生成新数组":
const result = [] list.forEach(item => { result.push(transform(item)) })这段代码能跑,但它表达的意思很含糊:你既在遍历,又在做转换,还在维护一个外部变量。三个人干一件事,谁都说不清责任。这种场景map一行就够了,而且意图明确——"我要一个等长的新数组"。
那forEach该用在哪?我的答案是:纯粹为了执行副作用。比如批量绑定事件、批量写日志、批量调用某个不返回值的接口。判断标准很清晰:如果你在forEach外面声明了一个变量往里塞东西,那基本就是选错了方法。
还有一个细节值得说:forEach会跳过空位(sparse array 里的 hole),但不会跳过undefined。这两者在数组里是两回事:
const arr = [1, , 3] // 中间是空位,length 是 3 arr.forEach(v => console.log(v)) // 输出 1、3,中间那次不执行用Array(3).fill(0)生成的是[0, 0, 0],但new Array(3)生成的是三个空位。这个差异在初始化二维数组时特别容易踩,后面问题排查那节会再提。
2.2 map 的边界:等长转换,仅此而已
map的语义非常窄:长度不变,一一映射。一旦你的转换逻辑里出现"某些元素要丢掉",那就该换成filter加map,或者用flatMap绕过去。
map有几个容易忽略的点。第一,回调函数的第三个参数是原数组本身,很多人不知道,偶尔会用上:
const arr = [1, 2, 3] const result = arr.map((v, i, whole) => v + whole.length) // [4, 5, 6]第二,map不会跳过空位,它会保留空位,回调不执行,结果数组对应位置还是空位。第三,map里return一个数组不会自动展开,想要扁平结果得用flatMap。
我踩过的一个坑是在map里做异步:
const results = ids.map(async id => await fetchDetail(id)) // results 是一个 Promise 数组,不是数据数组要拿到真实结果得Promise.all。这个错误在刚开始写异步代码时几乎人人都会犯一次,写多了自然就记住了。
2.3 flatMap 一个方法解决两层循环
flatMap等价于先map再flat(1),但性能上只遍历一次。它最适合的场景是"一个元素对应多个结果":
const orders = [ { id: 1, items: ['A', 'B'] }, { id: 2, items: ['C'] } ] const allItems = orders.flatMap(o => o.items) // ['A', 'B', 'C']如果用map加flat,代码会变成orders.map(o => o.items).flat(),多一次中间数组创建。数据量大的时候这个差距是能测出来的。
flatMap有个限制:只能展开一层。如果你的回调返回的是嵌套两层的结构,flatMap之后还是嵌套的。这时候要么链式再.flat()一次,要么改用手写循环。别硬凑,读起来费劲的代码不值得。
2.4 关于遍历性能的一点实测体会
网上关于"哪个遍历方法最快"的文章很多,结论五花八门。我自己的实测结论是:在十万级以内的数组上,for、forEach、map的差距在毫秒级,业务代码里基本感知不到。真正有差距的是百万级以上的数组,而且差距主要来自内存分配——map要新建一个等长数组,内存压力比原地遍历大。
所以我的实际做法是:优先选语义最准确的方法,性能问题等压测数据出来再说。如果真到了需要抠遍历性能的地步,那首先要考虑的是数据结构选得对不对,而不是纠结forEach还是for。
3. 筛选与查找:filter、find、some、every 的语义边界
3.1 四个方法的返回值和短路行为对照
这四个方法经常被混用,我做了一张对照表,写业务代码时扫一眼能省不少犹豫:
| 方法 | 返回值 | 是否短路 | 典型场景 |
|---|---|---|---|
filter | 新数组(可能为空) | 否,全量遍历 | 拿到所有符合条件的项 |
find | 第一个匹配的元素或undefined | 是,找到即停 | 拿到单个目标对象 |
some | 布尔值 | 是,找到即停 | 判断"是否存在至少一个" |
every | 布尔值 | 是,失败即停 | 判断"是否全部满足" |
这里的"短路"是关键区别。filter无论如何都会跑完整个数组,而find、some、every在条件满足后立刻停。如果你的数组很大又只需要判断存在性,用some比filter(...).length > 0快得多,而且后者还会创建一个完全用不上的中间数组。
我见过不少代码写成这样:
if (list.filter(item => item.id === targetId).length) { // ... }改成list.some(item => item.id === targetId)之后,语义更准,性能更好,代码还短了。这种小重构积累起来,代码质量提升是很明显的。
3.2 find 返回 undefined 带来的类型判断问题
find找不到时返回undefined,这在 JS 里会引发一串连锁问题。最典型的是拿到结果直接取属性:
const user = users.find(u => u.id === id) console.log(user.name) // 找不到时直接抛错严谨一点要加判断:
const user = users.find(u => u.id === id) if (!user) { // 处理找不到的分支 return }如果你的项目用 TypeScript,find的返回类型是T | undefined,编译器会强制你处理。这其实是 TS 相对 JS 的一个实际收益——它不是让你少写代码,而是让你不能偷懒跳过那些"看起来不会发生"的分支。
3.3 includes 和 indexOf 在特殊值上的差异
判断数组里有没有某个值,includes和indexOf看起来一样,但对NaN的处理完全不同:
const arr = [1, NaN, 3] arr.indexOf(NaN) // -1 arr.includes(NaN) // true原因是indexOf用的是严格相等===,而NaN === NaN是false;includes用的是 SameValueZero 算法,把NaN视为等于自身。
同样的差异也影响-0和0:includes认为它们相等,indexOf也是。所以现在我的习惯是,判断存在性一律用includes,除非我确实需要索引值。
还有一点,includes对对象是按引用比较的:
const arr = [{ id: 1 }] arr.includes({ id: 1 }) // false,不是同一个对象要用对象属性查找,只能回到some或find。这一点新手特别容易困惑,因为直觉上"内容一样应该就算找到"。
3.4 filter 链式调用的性能隐患
链式filter写起来舒服,但要知道它的代价:
const result = list .filter(item => item.status === 'active') .filter(item => item.score > 60) .filter(item => item.tags.length > 0)三次遍历,三个中间数组。数据量小无所谓,但在渲染前对几千条数据做这种处理,是能看到掉帧的。合并成一个filter就好:
const result = list.filter(item => item.status === 'active' && item.score > 60 && item.tags.length > 0 )合并之后遍历一次,中间数组零个。可读性反而更好,因为三个条件并排写在一起,一眼能看全。
注意:合并条件的前提是这些条件互相独立。如果后一个条件依赖前一个过滤后的结果(比如要判断"过滤后的数量是否超过阈值"),那就不能简单合并,得换思路。
4. reduce:用对了是利器,用错了是灾难
4.1 把 reduce 的参数一次讲透
reduce让人望而生畏,主要是参数太多。拆开看其实很清楚:
array.reduce((accumulator, currentValue, currentIndex, array) => { return nextAccumulator }, initialValue)四个参数:累积值、当前值、当前索引、原数组。真正天天用到的只有前两个。关键在第二个参数initialValue——它决定了累积值的初始类型,也决定了空数组时会不会报错。
不传初始值时,第一次回调的accumulator是数组第一个元素,currentValue是第二个,索引从 1 开始。空数组不传初始值会直接抛TypeError。所以我现在的习惯是:几乎总是传初始值,哪怕是0或者[]。显式写出来,读代码的人不用猜你的意图。
4.2 五个我实际用过的 reduce 场景
第一个,求和求积这种基础聚合,没什么好说的。第二个,按字段分组:
const grouped = list.reduce((acc, item) => { const key = item.category if (!acc[key]) acc[key] = [] acc[key].push(item) return acc }, {})这个写法以前很常见,现在有了Object.groupBy之后可以简化,但如果你要兼容老环境,reduce版本仍然是必备技能。
第三个,数组转对象(按 id 建索引):
const byId = list.reduce((acc, item) => { acc[item.id] = item return acc }, {})这个在需要频繁按 id 查询时非常有价值,把 O(n) 的find变成 O(1) 的属性访问。
第四个,去重。用Set更简单,但如果要去重的是对象数组且按某个字段去重,reduce就很顺手:
const unique = list.reduce((acc, item) => { if (!acc.some(x => x.id === item.id)) acc.push(item) return acc }, [])第五个,把嵌套结构拍平成一维。这个场景在对接接口返回的树形数据时特别常见。
4.3 reduce 的反模式:为了用而用
reduce最大的问题是被滥用。我见过用reduce做过滤的:
const result = list.reduce((acc, item) => { if (item.active) acc.push(item) return acc }, [])这和filter干的是同一件事,但可读性差了一大截。判断标准很简单:如果一个方法名能直接表达你的意图,就别用reduce。reduce应该留给那些"确实需要把一堆东西压缩成一个东西"的场景。
还有一个坑是累积值类型变化。比如初始值是数字0,中途return了一个字符串,后面就会变成字符串拼接。这种隐式类型漂移在长链条里极难排查。我的做法是,只要reduce超过五行,就一定把return的类型在注释里写清楚,或者干脆拆成具名函数。
5. 排序和原地修改:sort 与 splice 的经典陷阱
5.1 sort 默认按字符串比较这件事
这是 JS 里最著名的坑之一,但每年还是有新的人踩:
[10, 9, 1, 100].sort() // [1, 10, 100, 9]原因是sort默认把元素转成字符串再按 UTF-16 码元比较。数字数组不传比较函数,结果一定是错的。正确写法:
[10, 9, 1, 100].sort((a, b) => a - b) // [1, 9, 10, 100]这个错误之所以顽固,是因为小数组测试时经常碰巧是对的。比如[3, 1, 2]按字符串排也是[1, 2, 3],你测不出问题。等你上线了遇到两位数才炸。
5.2 比较函数的返回值规则
比较函数返回负数表示 a 排前面,正数表示 b 排前面,返回 0 表示两者顺序不变(在稳定排序的前提下)。很多人写a - b > 0这种返回布尔值的写法,虽然能跑,但语义上是错的——true会被转成1,false转成0,永远不会返回-1,所以升序降序的行为会变得很微妙。
正确写法就是直接return a - b或return b - a。如果比较的是字符串,用localeCompare:
list.sort((a, b) => a.name.localeCompare(b.name, 'zh-Hans-CN'))localeCompare会按中文拼音排序,比直接比字符串的码元顺序符合直觉得多。
5.3 splice 的参数和返回值
splice的参数是(起始索引, 删除个数, ...要插入的元素),返回值是被删除的元素组成的新数组。这几个点单看都懂,组合起来就容易混:
const arr = [1, 2, 3, 4, 5] const removed = arr.splice(1, 2, 'a', 'b') // arr 变成 [1, 'a', 'b', 4, 5] // removed 是 [2, 3]注意splice是原地修改。在不可变数据流里,应该用toSpliced:
const arr = [1, 2, 3, 4, 5] const next = arr.toSpliced(1, 2, 'a', 'b') // arr 不变,next 是 [1, 'a', 'b', 4, 5]还有一个常见错误是把slice和splice记混。记法:slice是"切片",不改原数组,第二个参数是结束索引;splice是"拼接",改原数组,第二个参数是删除个数。我自己的记忆方式是——splice多个p,代表"破坏性(p 开头)"。
5.4 排序稳定性带来的实际影响
从 ES2019 开始,sort明确要求稳定排序。这意味着相等元素的相对顺序会保持。这个特性在做多级排序时很有用:
// 先按分数排,分数相同的保持原有顺序 list.sort((a, b) => b.score - a.score)如果你的数据本身已经按某个次要字段排好序了,一次稳定排序就能实现"主字段优先、次字段保序"的效果,不用写复杂的比较函数。这个技巧在表格排序需求里特别好用。
6. 现代新增方法:不可变家族与分组
6.1 toSorted、toReversed、toSpliced、with 的定位
这四个方法是一组,共同点是返回新数组,不动原数组。它们的出现就是为了解决"想用变异方法的便利,又不想承担副作用"这个矛盾。
const arr = [3, 1, 2] const sorted = arr.toSorted((a, b) => a - b) // arr 还是 [3, 1, 2],sorted 是 [1, 2, 3] const updated = arr.with(0, 99) // arr 还是 [3, 1, 2],updated 是 [99, 1, 2]with特别值得单独说,它是"替换指定索引的值"的不可变版本。以前要改数组某一项,标准写法是:
const next = [...arr.slice(0, i), newValue, ...arr.slice(i + 1)]现在一行arr.with(i, newValue)就够了。而且with支持负索引,arr.with(-1, x)改的是最后一个。
提示:这些方法在运行时支持上比较新。如果项目需要兼容较老的环境,用之前先确认构建配置里有没有对应的 polyfill。
6.2 Object.groupBy 让分组代码短一半
以前按字段分组要写一段reduce,现在:
const list = [ { name: 'a', type: 'x' }, { name: 'b', type: 'y' }, { name: 'c', type: 'x' } ] const grouped = Object.groupBy(list, item => item.type) // { x: [{name:'a',...}, {name:'c',...}], y: [{name:'b',...}] }注意它返回的是普通对象,键会被转成字符串。如果要保留原始类型的键(比如对象或数字作为键),应该用Map.groupBy。这个区别在做"按对象分组"时很关键,用错会导致键被转成"[object Object]",所有数据挤到一起。
6.3 at 与负索引的实用价值
at()的价值不只是短一点,更重要的是它让负索引这件事变得统一。以前slice(-1)[0]和arr[arr.length - 1]两种写法混着用,代码风格不统一。现在统一成arr.at(-1),读起来一目了然。
不过at()只接受一个参数,不能像slice那样取范围。取最后三个元素还是得写arr.slice(-3)。这两个方法不冲突,各管一段。
7. 浅拷贝这件事,值得单独拎出来说
7.1 三种复制方式的差异
复制数组看着简单,实际上有好几种写法,行为不完全一样:
| 写法 | 特点 | 需要注意 |
|---|---|---|
arr.slice() | 经典写法,兼容性好 | 对空位会保留 |
[...arr] | 现代写法,简洁 | 会把空位转成undefined |
Array.from(arr) | 明确表达"生成新数组" | 支持类数组对象 |
arr.concat() | 少用 | 有元素时等价于复制 |
这三种都是浅拷贝。里面的对象元素还是同一个引用:
const a = [{ x: 1 }] const b = a.slice() b[0].x = 99 console.log(a[0].x) // 99,改的是同一个对象要做深拷贝,structuredClone是现在比较可靠的选择,能处理嵌套对象、日期、Map、Set 等类型。但它不能克隆函数,也会丢掉原型链。如果是纯数据场景,用它基本没问题。
7.2 二维数组初始化的经典错误
const matrix = new Array(3).fill(new Array(3).fill(0)) matrix[0][0] = 1 // 三行的第一个元素全变成 1原因很清楚:fill填进去的是同一个数组引用,三行指向同一个对象。正确写法是用Array.from的映射能力:
const matrix = Array.from({ length: 3 }, () => new Array(3).fill(0))每次回调都创建新数组,三行互相独立。这个坑我至少踩过两次,第二次还是因为复制粘贴了旧代码。
8. 常见问题与排查实录
8.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 数组排序结果不符合预期 | 未传比较函数,按字符串排序 | 检查sort是否带参数 |
| 原数据被意外修改 | 用了sortsplicepush等变异方法 | 全局搜这几个方法名 |
| 视图不更新 | 直接修改了数组元素或长度 | 改用不可变方法或整体替换引用 |
forEach里return不生效 | forEach没有返回值,return只是跳过当前次 | 改用map或some |
| 数组里有空位导致遍历次数不对 | 用new Array(n)初始化 | 改用Array.from或fill |
| 去重后对象还是重复的 | 用的是includes或Set,按引用比较 | 改用按字段判断 |
8.2 我踩过的三个真实坑
第一个是sort排数字没传比较函数,前面说过,不重复了。但我要补充一点:这个错误在代码评审时极难被发现,因为看代码的人脑子里会自动"脑补"成正确排序。所以我现在在团队里推的做法是,只要看到sort后面没有括号参数,一律标记为待确认。
第二个是filter之后误以为拿到了新对象。filter确实返回新数组,但里面的元素还是原引用。如果后续代码修改了元素属性,原数组里的对象也会被改。这个坑在"筛选后编辑"的场景里特别隐蔽,通常表现为"取消编辑后数据还是变了"。
第三个是concat和展开运算符在处理"数组的数组"时的行为差异。arr.concat([1, 2])会把1, 2展开后追加,而arr.concat([[1, 2]])才是追加一个数组。展开运算符[...arr, [1, 2]]和后者等价。这几个组合我建议你实际敲一遍看结果,光看文字描述很容易记混。
8.3 几条我自己遵守的编码约定
第一条,能不修改原数组就不修改。除了性能敏感的极端场景,我基本不用sort、splice、push去改传入的数组,宁可多一次复制。
第二条,链式调用不超过四层。超过四层就拆成具名中间变量,每个变量名说明这一步在做什么。链式调用的可读性在三四层的时候是上升的,到五六层就开始下降,因为读的人得在脑子里维护整条链的状态。
第三条,判断存在性优先用some,判断所有元素用every,不要用filter加长度。这条前面讲过,但它值得重复,因为这是最常见的低效写法。
第四条,方法名读不通就换个方法。如果一段代码你用reduce写完之后自己也说不清"这是在归约什么",那大概率应该换个写法。
上面这些内容,本质上都是在回答同一个问题:这道题在 JS 里已经有现成的解法了,为什么还要自己手写循环。我的体会是,方法本身不难,难的是在合适的场景想到它、并且清楚它的边界行为。我自己的习惯是每隔一段时间翻一遍数组方法的完整列表,不为了学新东西,就为了提醒自己"还有这个方法可以用,别又写成三层嵌套循环"。翻多了你会发现,很多以前觉得必须手写的逻辑,其实一个方法就解决了,而且顺手把边界情况也处理掉了。