ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JS数组方法实战指南:遍历、查找、去重与性能优化

JS数组方法实战指南:遍历、查找、去重与性能优化 数组可能是日常开发里接触最频繁、也最容易被“用早用顺手了就不深究”的一块内容。我经常在带新人或者做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 按返回值类型划分另一个分类维度更贴近实际调用的直觉返回新数组的map、filter、slice、concat、flat、flatMap返回单个元素/值的find、reduce、pop、shift、at返回索引的findIndex、indexOf、lastIndexOf返回布尔值的includes、some、every返回字符串的join、toString返回原数组的链式调用基础forEach、sort、reverse这几个不返回新数组其中sort和reverse返回的是排序后的原数组引用所以可以链式调用但要意识到链式调用后原数组也被改了。把这两个维度叠在一起数组方法的大体脉络就清晰了。后面每一节我都会围绕具体的需求场景去展开给出用法和踩坑经验。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返回第一个满足回调条件的元素如果没有找到返回undefined。findIndex返回第一个满足条件的索引没有就返回-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遇到第一个满足项就返回 trueevery遇到第一个不满足项就返回 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与poppush在末尾追加一个或多个元素返回新数组长度。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与unshiftshift删除并返回第一个元素unshift在头部加一个或多个元素。这俩具体对应队列的先进先出和倒插头。性能上由于数组是连续存储的头部操作需要移动所有现有元素的索引shift和unshift的时间复杂度是 O(n)。如果频繁在头部操作且数据量很大建议改用真正的队列结构如链表结构的数据结构或用reverse后操作尾部绕一下但大多数业务场景数据量远没到需要优化的程度可以不用过度担心。const queue [1, 2, 3]; const first queue.shift(); // 1queue 变为 [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安全截取的默认选择splice和slice长得像但作用完全不同。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 的初始值直接从第二项开始遍历如果数组为空且没传 initialValuereduce 会抛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 判断要紧凑很多也更容易保持一致的数据结构。另外reduce和flatMap在处理“展开和映射”时经常可以互相替代。flatMap是map加flat(1)的合成适合每个输入项映射成 0 个、1 个或多个输出项的场景const sentences [hello world, foo bar]; const words sentences.flatMap(s s.split( )); // [hello, world, foo, bar]6. 去重、扁平化与字符串转换实用度最高的三个补充场景体系化的方法总结已经覆盖了大部分需求但还有三个高频场景值得单独拎出来说数组去重、数组扁平化、数组与字符串互转。这三个场景单独看都很简单真正动手时会发现边界情况五花八门。6.1 数组去重从 Set 到复杂对象去重基础类型去重最简单的方法是利用 Setconst arr [1, 2, 2, 3, 3, 4]; const unique [...new Set(arr)]; // [1, 2, 3, 4]这个写法既简洁又高效。但如果数组里是对象Set 只能去重引用相同的项两个内容相同但引用不同的对象去不掉。实际开发里更常见的需求是“按某个字段去重”典型做法是结合 Mapconst 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。如果不知道嵌套层数可以用Infinityconst 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-20join不传参数时默认用逗号连接toString()方法效果相同。反过来字符串拆分数组用splitconst csvLine apple,banana,orange; const items csvLine.split(,); // [apple, banana, orange]前端在处理 CSV 数据、解析 URL 参数或偏好设置时经常用到这对方法。值得提醒的是split和join都不是真正意义上的“深拷贝”工具只是简单的类型转换。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 的map、filter和 JS 基本同构配合列表推导式还能写得更简洁。Java 的 Stream API 更像是 JS 链式调用的严格版stream().filter().map().collect()的理念完全一致。C 的 STL 里更多是std::vector配合算法库的std::find_if、std::accumulate、std::transform思路同样是遍历加回调函数。语言之间唯一不太一样的是对“是否改变原数组”的默认态度的差异。JS 里sort、reverse都是原地操作的默认就比较“危险”Python 里sorted()默认返回新列表list.sort()才是原地排序Java 的Collections.sort是原地修改。正因为这些默认行为不统一跨语言写代码时更要养成先确认方法返回值文档的习惯。数组方法总结这件事本身不是背 API而是建立一个“需求到方法”的映射目录。场景想清楚了方法的记忆就自然形成了。日常编码中我建议保持一个习惯写任何数组处理逻辑之前先停下来问自己三个问题——会不会修改原数组返回值是什么有没有更短路的替代方案这三个问题想明白你的数组方法就不会用得跑偏。
返回列表