JS数组方法实战指南:遍历、查找、去重与性能优化
2026/9/15 2:57:03 网站建设 项目流程

数组可能是日常开发里接触最频繁、也最容易被“用早用顺手了就不深究”的一块内容。我经常在带新人或者做Code Review时发现:很多朋友写循环还在用最原始的 for 加 if,明明一个 filter 就能解决的问题,硬是写了七八行;也有人把 map 当 forEach 用,返回值根本不需要却创建了一堆临时数组。数组方法本身不难,但由于方法多、命名相近、作用域边界不清晰,最容易出“看起来能跑,细看全是效率隐患”的代码。这篇文章我打算换个思路来总结数组常用方法,不按文档顺序罗列,而是按实际开发里的需求场景来讲——遍历、查找、增删改、排序、聚合、去重。搞清楚每个方法的输入输出以及是否改变原数组,才能真正把数组方法用对、用好。

1. 先建立数组方法的分类逻辑,别死记API

很多教程一上来就列 API,结果就是今天学明天忘。数组方法虽然多,但记忆线索其实很清晰。我从两个维度来分类:一个维度是“这个方法是否改变原数组”,另一个维度是“这个方法返回什么”。

1.1 按是否改变原数组划分

在开发中,是否修改原数组影响的是代码的可预期性。简单说,操作数组之前,你得知道你手上的数组还会不会被别处引用。比如从接口拿到的数据,很可能多个组件共用,你随手sort()一下,其他依赖这个数组顺序的地方就全乱了。

会改变原数组的方法push()pop()shift()unshift()splice()reverse()sort()fill()copyWithin()

不会改变原数组的方法map()filter()concat()slice()reduce()forEach()find()findIndex()some()every()includes()indexOf()join()flat()等。

这里有一个容易踩的坑,稍微提醒一下:map()本身不改变原数组,但如果数组里面装的是对象,map回调里直接改对象属性,那新数组和原数组的引用指向同一个对象,外层引用依然会受影响,这在状态管理(比如 React 的 setState)里非常危险。以后用map处理对象数组时,优先考虑构造新对象。

1.2 按返回值类型划分

另一个分类维度更贴近实际调用的直觉:

  • 返回新数组的:mapfiltersliceconcatflatflatMap
  • 返回单个元素/值的:findreducepopshiftat
  • 返回索引的:findIndexindexOflastIndexOf
  • 返回布尔值的:includessomeevery
  • 返回字符串的:jointoString
  • 返回原数组的(链式调用基础):forEachsortreverse这几个不返回新数组,其中sortreverse返回的是排序后的原数组引用,所以可以链式调用,但要意识到链式调用后原数组也被改了。

把这两个维度叠在一起,数组方法的大体脉络就清晰了。后面每一节我都会围绕具体的需求场景去展开,给出用法和踩坑经验。

2. 遍历与数据改造:forEach、map、filter如何选

遍历是数组操作里最频繁的场景。很多初学者分不清 forEach 和 map,其实判断标准非常直接:你要不要用返回值?要,就选 map;不要,就是用 forEach 去“执行副作用”。

2.1 forEach:执行操作,不关心返回结果

forEach是最朴素的遍历方法,没有返回值,或者说它返回的本来就是undefined。它适合的场景是:对数组里的每一项做一些操作,比如打印日志、更新非数组变量、调用某个外部函数。

const ids = [1, 2, 3]; ids.forEach((id, index) => { console.log(`第${index}个ID是:${id}`); });

这里有个细节:forEach回调里接收的参数是 (当前值, 当前索引, 数组本身)。但在 forEach 中你是不能直接 break 的,想提前结束循环的话,要么用some/every变通,要么改回 for 循环。网上有个老办法是“抛异常中断”,但我个人不建议在生产代码里这么干,太绕,而且异常处理机制被滥用反而影响可读性。

2.2 map:一对一映射,生成新数组

map的核心逻辑是“不改原数组,把一个数组变成另一个同等长度的新数组”,映射规则在回调里自由定义。

const prices = [100, 200, 300]; const pricesWithTax = prices.map(price => Math.floor(price * 1.13)); // 原数组 prices 保持 [100, 200, 300],新数组约 [113, 226, 339]

map非常适用于数据清洗阶段,比如把后端返回的字符串数字统一转成 number,把时间戳格式化,把接口字段名重映射等等。但请注意,当回调逻辑很复杂时,尽量不要在回调里写入和“映射”无关的副作用代码,因为 map 的语义就是纯转换,混入console.log、外部变量修改会让 Review 代码的人头大。

2.3 filter:按条件筛出子集

filter的意思更直白——把满足条件的项收集起来,形成一个新的、长度小于等于原数组的数组。回调返回 true 就保留,false 就丢弃。

const orders = [ { id: 1, status: 'paid', amount: 300 }, { id: 2, status: 'pending', amount: 100 }, { id: 3, status: 'paid', amount: 50 }, ]; const paidOrders = orders.filter(item => item.status === 'paid'); // 注意:这里返回的 paidOrders 里的对象仍指向原对象,浅拷贝层面上的“复制”

filter最常见的误用场景是:先 filter 出满足条件的项,再用 map 提取某一个字段。其实这时候用flatMap或者reduce可以一步解决,效率也更高。不过如果只是少量数据,分两步写更清晰,问题也不大。真正需要避免的是“用 filter 实现查找单个元素”的情况——那会让数组遍历完整轮,且还要处理空数组;查找单个元素应该用后面的find

2.4 场景速查表

场景推荐方法说明
对每一项执行某个操作forEach不需要返回值
每一项转换为新值map返回等长新数组
筛选满足条件的项filter返回新数组,长度可变
在遍历中判断是否满足条件some / every返回布尔,可提前停止
查找第一个符合条件的元素find找到即返回,不继续遍历

3. 查找与判断:find、findIndex、includes、some、every的微妙差异

说到查找,很多人的第一反应是indexOf或者includes,但这俩只能用于“拿值本身去对比”,如果数组里装的是对象,就无能为力了。这时候需要按条件查找的方法。

3.1 find和findIndex:对象数组查找的首选

find返回第一个满足回调条件的元素,如果没有找到,返回undefinedfindIndex返回第一个满足条件的索引,没有就返回-1。两者在语法结构上完全一致,只是结果维度不同。

const users = [ { name: '张三', age: 20 }, { name: '李四', age: 24 }, { name: '王五', age: 18 }, ]; const target = users.find(user => user.age >= 22); console.log(target); // { name: '李四', age: 24 } const targetIndex = users.findIndex(user => user.age >= 22); console.log(targetIndex); // 1

需要注意的是,find找到第一个满足条件的元素后就会停止遍历,这个短路的特性在数据量大时非常有用。另一个细节:find返回 undefined 时,你无法区分“没找到”和“找到了一个值为 undefined 的元素”,所以在处理可能包含 undefined 的数组时,建议用findIndex判断索引是否大于等于 0 来替代。

3.2 includes、indexOf与lastIndexOf:基础值查找

includes===严格相等比较,可以判断一个基础值是否在数组中,返回布尔值。indexOf则是返回首个匹配的索引,不存在返回 -1。lastIndexOf是从右往左找,返回最后一个匹配的索引。

const fruits = ['apple', 'banana', 'orange', 'apple']; console.log(fruits.includes('banana')); // true console.log(fruits.indexOf('apple')); // 0 console.log(fruits.lastIndexOf('apple')); // 3

有一种比较隐蔽的写法问题:有人习惯用indexOf(item) >= 0来判断是否存在,这在数值和小字符串场景下没问题,但一旦数组里是 NaN 就翻车了——indexOf内部使用严格相等比较,而NaN !== NaN成立,所以indexOf(NaN)永远返回 -1。ES7 的includes用了 SameValueZero 算法,可以正确识别 NaN,这也是我推荐优先用includes的原因。

3.3 some与every:条件判断的短路逻辑

some是“至少有一个满足”,every是“全部满足”。两者都是条件判断,都是短路运算:some遇到第一个满足项就返回 true,every遇到第一个不满足项就返回 false。

const scores = [78, 90, 85, 42]; const hasFail = scores.some(score => score < 60); // true const allPass = scores.every(score => score >= 60); // false

这里有个容易忽略的边界:空数组的every返回 true,空数组的some返回 false。原因是逻辑运算的“空真”规则——全称命题对空集恒真。刚接触这个行为的人会觉得很反直觉,但记住就好,很多权限校验场景里可以巧妙利用这一点。

数组的查找和判断类方法我都建议在数据量大时优先使用能够短路的版本。比如只想确认是否存在某个条件的对象,用some而不是filter().length > 0,前者最多遍历到满足项即停止,后者会把整个数组滤完。

4. 增删改与合并拆分:push、pop、shift、unshift、splice、concat、slice

数组之所以被大量当作“队列”或“栈”使用,就是因为尾部和头部的增删方法足够方便。但平时写代码,总能看到有人把简单的事做复杂,或者不小心改变了原数组导致隐性问题。

4.1 栈操作:push与pop

push在末尾追加一个或多个元素,返回新数组长度。pop删除并返回末尾元素。这俩组合可以很自然地模拟一个栈(后进先出)。另一个容易被忽略的点是,push支持一次追加多个值,所以合并数组时可以这样:

const arr = [1, 2]; arr.push(3, 4); // arr -> [1, 2, 3, 4]

说到合并,旧代码里经常出现arr.concat(otherArr)concat不改变原数组,返回合并后的新数组。ES6 之后我用得更顺手的是展开运算符:

const arr1 = [1, 2]; const arr2 = [3, 4]; const merged = [...arr1, ...arr2]; // [1, 2, 3, 4]

concat相比,展开运算符的优势在于还可以把多个数组和普通元素混在一起,语义也直观。但注意,两者都是浅拷贝,数组里如果是对象的话,对象引用仍然共享。

4.2 队列操作:shift与unshift

shift删除并返回第一个元素,unshift在头部加一个或多个元素。这俩具体对应队列的先进先出和倒插头。性能上,由于数组是连续存储的,头部操作需要移动所有现有元素的索引,shiftunshift的时间复杂度是 O(n)。如果频繁在头部操作且数据量很大,建议改用真正的队列结构(如链表结构的数据结构)或用reverse后操作尾部绕一下,但大多数业务场景数据量远没到需要优化的程度,可以不用过度担心。

const queue = [1, 2, 3]; const first = queue.shift(); // 1,queue 变为 [2, 3] queue.unshift(0); // queue 变为 [0, 2, 3]

4.3 splice:最强大的增删改工具

splice干三件事:删除、替换、插入。函数签名是splice(start, deleteCount, ...items)。它会直接修改原数组,并返回被删除元素组成的数组。

const arr = ['a', 'b', 'c', 'd']; // 删除:从索引1开始删2个 const removed = arr.splice(1, 2); // arr -> ['a', 'd'],removed -> ['b', 'c'] // 插入:从索引1开始,删除0个,插入两个字符串 arr.splice(1, 0, 'x', 'y'); // arr -> ['a', 'x', 'y', 'd'] // 替换:从索引1开始删1个,换上新值 arr.splice(1, 1, 'm'); // arr -> ['a', 'm', 'y', 'd']

splice是个必须谨慎使用的方法,因为它直接改动原数组结构,一旦用在共享数据上,很容易在不知不觉间污染别人的引用。我在组件化的前端项目里,经常看到有人把从 Redux 或 Vuex 里拿到的数组直接 splice,这在 StrictMode 下尤其容易出问题。需求场景里“删除某个满足条件的元素”应该优先用filter把新数组重新赋值给状态,而不是在原数组上 splice。

4.4 slice:安全截取的默认选择

spliceslice长得像,但作用完全不同。slice(start, end)不修改原数组,返回截取的新片段,end是开区间不包含。它是安全地对数组做浅拷贝和截取的首选:

const arr = [1, 2, 3, 4, 5]; const copy = arr.slice(); // [1, 2, 3, 4, 5],浅拷贝 const sub = arr.slice(1, 3); // [2, 3] const tail = arr.slice(-2); // [4, 5],负数从末尾算

拷贝一份数组再操作,是规避“误改原数组”的性价比最高的办法。比如要对一个数组排序但保留原顺序,我会这么写:

const sorted = [...arr].sort((a, b) => a - b);

一行代码就绕开了sort改变原数组的坑。

5. 排序、反转与聚合汇总:sort的坑与reduce的高级用法

排序和聚合往往放在业务处理的最后阶段。sort是使用频率高但最容易踩坑的方法,reduce则是功能强大但新手不太容易上手的聚合利器。

5.1 sort排序:注意默认行为和稳定性

sort()默认把所有元素先转为字符串再按字典序排序,所以直接对数值数组执行sort()会得到莫名其妙的结果:

const arr = [10, 9, 80, 2]; arr.sort(); // [10, 2, 80, 9],这不是数值排序

正确做法是传入比较函数:

arr.sort((a, b) => a - b); // 升序 arr.sort((a, b) => b - a); // 降序

比较函数的返回值逻辑是:返回负数表示 a 应排在 b 前面,返回正数表示 a 应排在 b 后面,返回 0 则保持相对位置。写习惯之后你会发现,(a, b) => a - b几乎成了升序的肌肉记忆,但有个细节值得说明:如果数组元素是浮点数或者大整数,用差值做比较有溢出风险,更严谨的写法是:

arr.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));

还有一个稳定性的问题,ES2019 之后,sort被要求是稳定的,也就是说当比较函数返回 0 时,元素相对顺序与原始数组保持一致。这意味着你可以用多次排序实现“按主次关键字排序”的需求,比如先按年龄排序,再按姓名排序,稳定的算法会保证结果先满足姓名排序,同时年龄相同的人里仍保持年龄排序的相对顺序。

5.2 reverse反转数组

reverse会把数组原地反转,返回原数组引用。同样注意它会改变原数组。

const arr = [1, 2, 3]; arr.reverse(); // arr -> [3, 2, 1]

如果要保留原数组,可以先slice()再反转,或者用[...arr].reverse()

5.3 reduce:从累加到树构建

reduce可能是数组方法里最需要时间消化的。它会把回调累计执行,最终得到一个“归约结果”。函数签名是reduce(callback(accumulator, currentValue, index, array), initialValue)

最基础的应用场景是求和:

const nums = [1, 2, 3, 4]; const sum = nums.reduce((acc, val) => acc + val, 0); // 10

这里有一个非常常见的疑问:initialValue 到底要不要传?我的建议是,当数组可能为空时,一定要传。不传 initialValue 时,reduce 会把第一项当作 accumulator 的初始值,直接从第二项开始遍历;如果数组为空且没传 initialValue,reduce 会抛TypeError: Reduce of empty array with no initial value。在很多接口返回数据的场景中,数组可能是空的,一旦忘记传初始值,线上直接白屏,这种事我遇到过不止一次。

reduce不只能做数值累加,它还可以做数组转对象、数据分组、扁平化、统计频次等操作。举个例子,把订单数组按商品类型分组:

const orders = [ { type: 'fruit', name: 'apple' }, { type: 'drink', name: 'cola' }, { type: 'fruit', name: 'banana' }, ]; const grouped = orders.reduce((acc, order) => { (acc[order.type] ||= []).push(order.name); return acc; }, {}); // grouped -> { fruit: ['apple', 'banana'], drink: ['cola'] }

这种分组写法比 for 循环加 if 判断要紧凑很多,也更容易保持一致的数据结构。

另外,reduceflatMap在处理“展开和映射”时经常可以互相替代。flatMapmapflat(1)的合成,适合每个输入项映射成 0 个、1 个或多个输出项的场景:

const sentences = ['hello world', 'foo bar']; const words = sentences.flatMap(s => s.split(' ')); // ['hello', 'world', 'foo', 'bar']

6. 去重、扁平化与字符串转换:实用度最高的三个补充场景

体系化的方法总结已经覆盖了大部分需求,但还有三个高频场景值得单独拎出来说:数组去重、数组扁平化、数组与字符串互转。这三个场景单独看都很简单,真正动手时会发现边界情况五花八门。

6.1 数组去重:从 Set 到复杂对象去重

基础类型去重最简单的方法是利用 Set:

const arr = [1, 2, 2, 3, 3, 4]; const unique = [...new Set(arr)]; // [1, 2, 3, 4]

这个写法既简洁又高效。但如果数组里是对象,Set 只能去重引用相同的项,两个内容相同但引用不同的对象去不掉。实际开发里更常见的需求是“按某个字段去重”,典型做法是结合 Map:

const list = [ { id: 1, name: 'a' }, { id: 2, name: 'b' }, { id: 1, name: 'a-copy' }, ]; const uniqueById = [...new Map(list.map(item => [item.id, item])).values()]; // [{ id: 1, name: 'a' }, { id: 2, name: 'b' }]

这里list.map(item => [item.id, item])先把每个人转换成[key, value]对,然后用Map来保持唯一的 key,重复的 id 后面的覆盖前面的,最后.values()取出值列表。这个写法是网上流传比较广的“一行去重”模式,用熟了会觉得比遍历加标记更利落。

6.2 扁平化:多级嵌套数组的降维

flat(depth)可以把嵌套数组扁平化,depth默认为 1。如果不知道嵌套层数,可以用Infinity

const nested = [1, [2, [3, [4]]]]; const flatArr = nested.flat(Infinity); // [1, 2, 3, 4]

flat(Infinity)虽然方便,但对超大且极深嵌套的数组,一次性扁平化可能造成性能压力,实际开发中多层嵌套的配置数据并不常见,一般flat(1)就够。需要降维并同时做映射时,flatMap一步到位,比如把多个分类下的商品取出来:

const categories = [ { name: 'fruit', products: ['apple', 'banana'] }, { name: 'drink', products: ['cola', 'juice'] }, ]; const allProducts = categories.flatMap(c => c.products); // ['apple', 'banana', 'cola', 'juice']

6.3 数组与字符串:join和split是一组操作

数组转字符串第一个想到的方法是join(separator)

const arr = ['2025', '05', '20']; const dateStr = arr.join('-'); // '2025-05-20'

join不传参数时默认用逗号连接,toString()方法效果相同。反过来,字符串拆分数组用split

const csvLine = 'apple,banana,orange'; const items = csvLine.split(','); // ['apple', 'banana', 'orange']

前端在处理 CSV 数据、解析 URL 参数或偏好设置时经常用到这对方法。值得提醒的是,splitjoin都不是真正意义上的“深拷贝”工具,只是简单的类型转换。

6.4 还有一个 “at” 方法

ES2022 引入的at(index)可以同时支持正索引和负索引,返回对应位置的元素。负数索引从末尾数起,arr.at(-1)就是取最后一个元素,比arr[arr.length - 1]舒服太多,尤其是处理嵌套数组或需要反向读取时。

const arr = [10, 20, 30, 40]; console.log(arr.at(-1)); // 40 console.log(arr.at(-2)); // 30

它不改变原数组,也不会因为越界返回 undefined 让你误以为元素存在。因为写起来比arr[arr.length - 1]干净,我现在几乎所有取数组尾部元素的场景都用at

7. 从性能角度重新审视数组方法的链式调用

一堆方法都掌握之后,很容易出现“一个数据处理流程恨不得全用链式调用”的冲动:

const result = list .filter(item => item.active) .map(item => item.price * 2) .filter(price => price > 100) .sort((a, b) => a - b) .reduce((acc, price) => acc + price, 0);

这段代码语义很清晰,我平时也这么写,因为它可读性好。但它其实会创建多个中间临时数组,每一个 filter 和 map 都会遍历一次数组并分配新内存。如果数据量只有几十、几百条,完全不用在意;但如果列表有十万级数据且处于高频热路径,这种链式写法就可能成为性能瓶颈。

这种情况下我会考虑用reduce做一次遍历同时完成过滤、映射和聚合:

const result = list.reduce((acc, item) => { if (!item.active) return acc; const doublePrice = item.price * 2; if (doublePrice <= 100) return acc; return acc + doublePrice; }, 0);

同样的逻辑,一次遍历搞定,也没有中间数组。可读性相比链式版本下降了一些,但如果数据量大,这个优化效果是实打实的。我的经验是:先写出高可读性的链式代码,用性能分析工具确认瓶颈之后再去优化,不要一开始就为了省那么几次遍历写出满篇 reduce 回调。

另外还有一个细节值得放在这里提醒:不要在map回调里依赖太重的逻辑,比如 DOM 操作、接口请求、大计算量任务。数组方法是同步执行的,回调里的异步操作不会自动变成并发,它只会把 Promise 对象放进数组里。很多人误以为map加 async 函数就能并发执行操作,其实那只会返回一个 Promise 数组,后续要等它们全部完成,应该用Promise.all(list.map(async item => ...))。这个坑在批量请求场景里特别常见。

8. 不同语言里的数组方法对照感悟

我平时写 JavaScript 比较多,但也用 Python、Java、C++ 处理算法和数据处理。不同语言里的数组方法论其实有强烈的相似性,掌握了 JS 的这些思路后,切换到别的语言会轻松很多。

Python 的mapfilter和 JS 基本同构,配合列表推导式还能写得更简洁。Java 的 Stream API 更像是 JS 链式调用的严格版,stream().filter().map().collect()的理念完全一致。C++ 的 STL 里更多是std::vector配合算法库的std::find_ifstd::accumulatestd::transform,思路同样是遍历加回调函数。

语言之间唯一不太一样的,是对“是否改变原数组”的默认态度的差异。JS 里sortreverse都是原地操作的,默认就比较“危险”;Python 里sorted()默认返回新列表,list.sort()才是原地排序;Java 的Collections.sort是原地修改。正因为这些默认行为不统一,跨语言写代码时更要养成先确认方法返回值文档的习惯。

数组方法总结这件事,本身不是背 API,而是建立一个“需求到方法”的映射目录。场景想清楚了,方法的记忆就自然形成了。日常编码中我建议保持一个习惯:写任何数组处理逻辑之前,先停下来问自己三个问题——会不会修改原数组?返回值是什么?有没有更短路的替代方案?这三个问题想明白,你的数组方法就不会用得跑偏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询