ARTICLE DETAIL

资讯详情

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

JavaScript数组遍历怎么选?forEach、map、filter、reduce实战指南

JavaScript数组遍历怎么选?forEach、map、filter、reduce实战指南 上周团队做Code Review时有同事指着一行list.forEach(item { result.push(transform(item)) })问我这里为什么不用map我当时愣了一下因为如果只是把每个元素处理一下放进新数组这个需求map确实才是更合适的表达。但对方接着又说其实自己也不太清楚forEach和for...of、filter、reduce这些到底应该怎么选平时看到哪个顺手就写哪个。这不是个例。我见过不少工作两三年的前端能把每种遍历方式的语法背得滚瓜烂熟但真到写代码时要么满屏forEach要么干脆统一用for循环。问题在于——JS中循环遍历数组的常用方式远不止能跑这么简单选择哪种方式本质上是在告诉读代码的人这段代码想干什么。如果你不能快速区分变形、过滤、聚合、副作用、短路这几类需求对应的最合适方法那么代码质量和维护成本会悄悄打折扣。这篇文章不会只列API清单我会把每种方式放在真实业务场景里讲清楚它们的差异在哪儿、踩过哪些坑、性能到底差多少、什么时候该用哪种。内容比较长但都是实际项目中会反复用到的判断逻辑。1. 从需求反推遍历方式先搞清楚你要对数组做什么很多人纠结选哪种遍历方式其实从一开始就问错了问题。遍历数组只是手段你要对这个数组做什么才是核心。如果不先想清楚目的就会陷入用forEach实现一切或者只有for最稳的误区里。1.1 五类常见需求模型与对应的方法我在实际开发中会把数组遍历的需求拆成五种类型。只要你能判断眼前的需求属于哪一类该用什么方法几乎是一目了然的。需求类型典型场景对应方法返回值纯副作用执行打印日志、修改外部变量、发送埋点forEach、for...of、for无数据变形把每个元素映射成新值得到等长新数组map、flatMap新数组过滤筛选从原数组中挑出符合条件的子集filter新数组聚合归约求总和、分组、扁平化、拼字符串reduce任意值条件验证判断是否有/是否全部满足某条件some、every、find、findIndex布尔值或单条数据举个很简单的例子假设有一批订单数据要算出所有订单的总金额。这是个聚合需求很多人会用forEach累加let total 0; orders.forEach(order { total order.amount; });这段代码没错但用reduce表达更准确const total orders.reduce((sum, order) sum order.amount, 0);两者区别不仅在于谁更简洁而在于**reduce从一开始就声明了我不会修改外部变量传入一个数组聚合成一个值**。读代码的人不需要关心total在别的地方有没有被改过这大大降低了心智负担。1.2 一个关键问题这段遍历有没有返回值我判断遍历方式时第一句话会问自己我需要这个遍历给我返回什么东西吗如果不需要返回值只是想让数组每个元素都执行某个副作用比如打印、缓存、写入外部存储那forEach或者for...of都行。如果需要拿到一个新数组比如数据清洗后给下一层用那就要考虑map或filter。如果需要拿到一个布尔值比如这个数组里有没有订单超时了那some/every是最贴切的。如果需要拿到其中一个元素find比filter()[0]更高效且更语义化。这个判断看似简单但是能解决80%的选择困难。我见过不少人习惯性用map做遍历却不接返回值等于创建了一个没人用的新数组平白浪费内存还误导同事。1.3 为什么说统一用for循环其实是个坏主意有些团队为了一致性规定所有遍历都用for循环。这在一定规模下可行但会把代码变成彻头彻尾的过程式说明书。同样的逻辑用for写一遍是20行用高阶方法写一遍是5行而且后者能更清楚地表达意图。换个角度想遍历方式就像做饭时的厨具。你可以一把菜刀切所有食材但用削皮刀处理土豆、用厨剪处理鸡骨头效率和效果都会更好。遍历方式的核心价值是语义清晰减少出错概率而不仅仅是能不能跑。2. 六种常用遍历方式的真实差异与选择逻辑这一节我会把实际项目里最常见的几种方式过一遍不只说语法重点讲清楚它们各自的边界和适配场景。每种方式的完整差异可以先看这张表遍历方式可拿索引可中断可顺序await返回新数组稀疏数组空槽处for是是是否读出undefinedforEach是回调第二参数否否否跳过回调for...of配合entries()是是否读出undefinedmap是回调第二参数否否是保留空槽filter是回调第二参数否否是跳过空槽reduce是回调第二/三参数否否否跳过空槽2.1 for循环的定位灵活性最大代价是啰嗦经典for循环最大的优势是灵活你完全掌控索引的起点、终点、步长、方向想怎么遍历就怎么遍历。尤其是在需要从中间某一位开始、按步长跳着读、或者倒序删除元素的时候它几乎无可替代。// 倒序遍历数组并删除偶数 const arr [1, 2, 3, 4, 5]; for (let i arr.length - 1; i 0; i--) { if (arr[i] % 2 0) { arr.splice(i, 1); } }这段代码如果改成正序遍历加splice会出现索引错位的问题——splice删掉一个元素后后面的元素会往前移动一位当前索引已经指向下一个元素直接导致跳过某些项。这是一个非常经典的坑我后面会详细展开。for循环的缺点是表达力弱。看到for (let i 0; i arr.length; i)你不能立刻知道这段代码是在变形、过滤、还是找什么。所以我的建议是当你是为了精确控制遍历过程而用for而不是我懒得想其他方法而用for它才是合适的。2.2 forEach适合副作用执行但别忘了它的限制forEach是业务代码中出现频率最高的遍历方式因为它的回调语法非常好读arr.forEach((item, index) { ... })。它天然为每个元素执行一段代码而生。但它有三个容易被忽略的限制第一不能中断。forEach回调里写return只能跳过当前这一项不能跳出整个循环。要实现找到就停的需求它做不到必须用for...of、some等替代。第二不能正确配合await。这一点非常关键很多人在这儿吃过亏。forEach不会等待回调里的Promise所有异步回调会瞬间一起执行导致顺序错乱、并发量不可控。async function uploadAll(items) { items.forEach(async (item) { await upload(item); // 不会等上一个完成 }); console.log(所有上传已完成); // 这里几乎是立即执行的 }第三它天然是为副作用设计的但返回值是undefined。如果你在一个forEach里不断push新数组这本质上是在绕过map——逻辑没错但代码在不断累积外部状态。所以我对forEach的态度是只在明确我不关心返回值、不需要中断、不涉及顺序异步的场景下用它比如打印日志、发送埋点、把元素写入某个已有对象等。2.3 map、filter、reduce用函数式方法表达意图这三兄弟是我在业务代码里用得最多的。map把一个数组变成另一个等长数组。回调返回什么新数组对应位置就是什么。它的核心价值是变形且不会修改原数组。比如把用户列表里的姓名单独取出来const names users.map(user user.name);需要注意的是map的回调必须有返回值否则新数组对应位置就是undefined。很多人刚学时容易犯这个错把它当forEach用最后拿到一个全是undefined的数组。filter从数组里筛出一个子集。回调返回true就保留false就剔除。它和map的差别在于filter的结果长度不一定等于原数组。比如筛选出所有金额大于100的订单const bigOrders orders.filter(order order.amount 100);reduce最强大也最容易被误解的方法。它把数组归约成任意一个值——数字、对象、数组都可以。一个非常典型的业务场景是分组const users [ { name: 张三, dept: 前端组 }, { name: 李四, dept: 后端组 }, { name: 王五, dept: 前端组 }, ]; const grouped users.reduce((acc, user) { if (!acc[user.dept]) acc[user.dept] []; acc[user.dept].push(user); return acc; }, {}); // { 前端组: [{...}, {...}], 后端组: [{...}] }reduce的难点在于理解累加器这个参数——它是上一次回调的返回值也是最终结果的雏形。一旦理解了这个模型很多数组转对象二维数组扁平化按字段分组的需求都会变得非常顺手。2.4 for...of既能中断又能顺序await的现代答案for...of是ES6引入的遍历方式它会遍历可迭代对象数组、字符串、Set、Map、arguments等。相比forEach它最大的优势是可以用break、continue、return自由控制流程而且能正确配合await实现顺序异步。比如按顺序处理一个任务队列async function processQueue(tasks) { for (const task of tasks) { await run(task); // 上一个完成才执行下一个 } }这种写法既直观又可靠是forEach做不到的。for...of唯一的不便是不能直接拿到索引。解决办法是用entries()for (const [index, item] of list.entries()) { console.log(index, item); }需要注意的是这种写法会在每次迭代时创建一个[index, item]的数组虽然开销通常可以忽略但在超大数组的高频函数里如果确实要极致压性能经典for循环会更直接。2.5 顺带澄清for...in的定位for...in经常被和for...of混为一谈但它其实不是用来遍历数组的。for...in遍历的是对象的可枚举属性名用在数组上遍历到的是索引的字符串形式0、1、2而且还会把数组的自定义属性也遍历进去const arr [a, b]; arr.customProp hello; for (const key in arr) { console.log(key); // 0 1 customProp }这在绝大多数业务场景下都是要避免的。所以我的经验是遍历数组永远不要用for...in它是为遍历普通对象设计的。3. 性能怎么测才算数几个容易翻车的细节for循环性能最好所以能用for就用for——这句话在前端圈流传很广但真实情况远比这复杂。我不否认经典for在纯性能测试里往往占优但差距到底有多大、对你的业务影响有多深需要先搞清楚。3.1 用一组实测数据看看for与forEach的真实差距我在本地用Node.js跑过一段简单的求和测试对100万元素数组分别用for、forEach、for...of、reduce执行同样的累加操作取多轮的中位数。结果大致如下不同环境差异较大仅供参考遍历方式100万次累加耗时相对值for约1.0forEach约1.1~1.3for...of约1.2~1.5reduce约1.3~1.6也就是说在V8引擎下for确实最快但领先幅度通常在30%以内根本谈不上性能差几倍。而且一旦循环体里是真实的业务逻辑——比如字符串处理、正则匹配、对象属性访问——这个差距会迅速被淹没。所以我的结论是如果你的循环体只有简单的数值运算并且数组规模在十万级、百万级以上for有明显优势但如果是常规业务数组几十到几千条性能不应该成为你选择遍历方式的首要理由。3.2 测性能时的三个基本要求预热、多次、控制变量很多人在DevTools的Console里用console.time跑一次就得出结论这其实是错误示范。JS引擎有JIT即时编译机制函数会被反复编译优化第一次运行往往比后续运行慢很多。要得到可信的结果至少要满足三个条件预热先用同样的数据跑几遍让JIT完成优化再开始计时。多次取平均或中位数避免垃圾回收、系统调度等因素干扰单次结果。控制变量确保代码里除了被测试的遍历方式其他逻辑完全一致。正确的验证方式大概长这样const arr Array.from({ length: 1000000 }, (_, i) i); // 预热 for (let i 0; i 5; i) { let s 0; for (const v of arr) s v; } function testFor() { let s 0; for (let i 0; i arr.length; i) s arr[i]; return s; } function testForEach() { let s 0; arr.forEach(v { s v; }); return s; } let n 1000; let start performance.now(); while (n--) testFor(); let timeFor performance.now() - start; n 1000; start performance.now(); while (n--) testForEach(); let timeForEach performance.now() - start; console.log({ timeFor, timeForEach });这里还有个容易被忽视的问题如果循环计算的结果没被使用编译器可能直接优化掉整段循环。所以我在测试函数里都会return s并在外部统一接收一次避免假快。3.3 从性能回归来看真正的瓶颈在哪里做了这么多性能对比后你会发现一个扎心的现实数组中真正让页面变卡的不是遍历本身而是循环体里的操作。比如在forEach里同步请求接口、在循环里触发DOM重排、在大数组里嵌套另一个大数组的遍历——这些才是性能杀手。一个十万级的数组单纯遍历只要几毫秒但如果循环体里每次做一次document.querySelector或网络请求那可能就是几秒甚至更久。所以别把性能优化的精力全放在用for替代forEach上先看看循环体里的逻辑能不能精简。关于这一点经验法则是优先优化单元成本其次才是优化遍历框架。3.4 两个非常实用的循环优化小动作虽然我不建议为了性能无脑用for但这两个小优化在很多真实场景里确实有效第一如果必须用经典for提前缓存length。虽然现代引擎对arr.length的重复读取优化得很好但在老环境或极端热路径下缓存长度仍然更稳for (let i 0, len arr.length; i len; i) { // ... }第二尽量少在循环体内访问深层嵌套属性。比如list[i].user.profile.name这种链路每次迭代都要走多次查找如果属性是动态的开销会更明显。如果性能不达标可以先把需要访问的中间对象缓存到局部变量里。4. 实战中躲不开的坑异步、稀疏数组与中断如果说前面的内容偏向怎么选那么这一节就是选了之后怎么不翻车。以下四个坑我在自己的项目或者帮别人排查代码时都真实遇到过每一个都值得单独说。4.1 forEach async/await 的并发陷阱这个问题出现过很多次但我依然建议每个前端把它刻在脑子里。假设你写了一个批量上传功能要求按顺序逐张上传并统计成功数量。最容易踩坑的写法是let successCount 0; items.forEach(async (item) { try { await upload(item); successCount; } catch (e) { // 记录失败 } }); console.log(successCount); // 这里几乎一定是0原因很简单forEach不会等回调里的await。所有回调在第一次迭代时就全部被推入异步队列successCount根本不是顺序执行的。你得到的successCount是并发修改后的最终值表现极不稳定。更隐蔽的是异常捕获问题——回调里的try/catch只能抓住当前Promise的错误但外层想统一处理的try/catch根本接不住异步回调里的异常。正确的做法是改用for...ofasync function uploadAll(items) { let successCount 0; for (const item of items) { try { await upload(item); successCount; } catch (e) { // 记录失败 } } console.log(successCount); // 这才是最终值 return successCount; }在需要按顺序处理异步任务的场景里for...ofawait几乎是最稳的解法。如果确实想用数组方法也可以用reduce串起Promise链但可读性远不如for...of。4.2 稀疏数组的空槽forEach和for循环的表现居然不一样稀疏数组就是存在空槽的数组常见于new Array(3)或者delete arr[1]之后。麻烦的是不同遍历方式对空槽的处理方式完全不同。看这个例子const sparse [1, , 3]; // 中间是空槽 sparse.forEach((item, index) { console.log(index, item); }); // 只会打印 0 1 和 2 3中间的空槽根本不会触发回调 for (let i 0; i sparse.length; i) { console.log(i, sparse[i]); } // 会打印 0 1、1 undefined、2 3如果你用forEach处理一个含空槽的数组然后依据回调执行次数做统计结果会偏小。比如想给每个元素翻倍const doubled sparse.map(item item * 2); // [2, 空, 6] —— 空槽被原样保留这里有个经典面试坑new Array(3)配合map生成连续数字。新手很容易写出new Array(3).map((_, i) i); // 结果是 [空, 空, 空]根本不是 [0, 1, 2]因为map会跳过空槽。正确做法是先把空槽填满用Array.fromArray.from({ length: 3 }, (_, i) i); // [0, 1, 2]或者用扩展运算符先展开成undefined[...new Array(3)].map((_, i) i); // [0, 1, 2]说实话正常业务代码里遇到稀疏数组的概率不高JSON序列化不会产生空槽但一旦遇到这种回调次数不一致的问题排查起来会非常隐蔽值得提前知道。4.3 想中途退出forEach做不到但some/every/for...of可以遍历到某个条件满足就停止是一个非常常见的需求。比如判断一个数组里有没有非法字符有就不用往后查了。一次性遍历完虽然也能得到答案但效率上是浪费的。forEach不支持中途退出所以很多人会陷入只能用for循环加break的思维定势。其实数组方法里有两个天然支持短路的遍历器some回调返回true就停止整个some返回true如果所有回调返回false最终返回false。every回调返回false就停止整个every返回false如果所有回调返回true最终返回true。用它们来替代检查数组中是否/是否全部满足条件的for循环语义清晰且天然高效const hasInvalid items.some(item !isValid(item)); // 找到第一个无效项就停下不会遍历完整个数组如果需要找到那个满足条件的元素本身用find只需要索引用findIndex。它们也都会在找到后立刻停止遍历。这些方法本质上都是带短路的遍历你在写业务判断时应该优先想到它们而不是forbreak。4.4 遍历时删除元素导致索引错位倒序遍历的真实价值这是个很经典的场景但在实际代码里依然反复出现。假设要从数组里删掉所有偶数const arr [1, 2, 3, 4, 5]; // 正序遍历 splice 会出问题 for (let i 0; i arr.length; i) { if (arr[i] % 2 0) { arr.splice(i, 1); } } // 结果[1, 3, 5]不实际是 [1, 3, 5] —— 这次碰巧对了 // 换个数据试试[1, 2, 3, 4, 6, 5] // 结果是 [1, 3, 6, 5]6没被删掉问题根源在于splice删除元素后数组元素集体左移一位但循环里的i已经指向了下一个位置于是被漏掉了一个元素。解决方案有两个一是倒序遍历因为删除元素只影响后续索引不影响已经遍历过的位置二就是干脆别在原数组上删用filter生成新数组const filtered arr.filter(item item % 2 ! 0);我个人更推荐后者。不只是因为简短更因为它不改变原数组消除了某个函数悄悄修改了传入数组的副作用问题。如果你必须要修改原数组那就使用倒序for循环并在循环里做好索引变化的理解。5. 我的选择习惯一份非官方的决策参考前面把每种方式都拆开讲完了这一节我会直接告诉你在真实开发里我会怎么快速做决定。5.1 每次要遍历数组时按这个顺序问自己我在代码评审时会引导团队按这套顺序判断需要中途退出吗需要→用for...ofbreak或者改用some/every/find。需要对每个元素做串行异步操作吗需要→用for...ofawait。是要把数组变成另一个数组吗是→问一句是等长变形还是筛选子集等长用map筛选用filter。是要把所有元素聚合成一个值吗是→用reduce。只是执行一段副作用吗是→再用forEach就够了。这个顺序里forEach被我排在了最后。不是因为它不好而是因为它的定位就是通用副作用执行器用得太多往往说明代码里缺少对意图的表达。5.2 我个人偏好的默认选项和例外场景默认选项方面我会说处理数据流数组到数组优先map、filter、reduce。遍历可迭代对象且需要控制流的地方优先for...of。精确控制索引、倒序、跳步时再用经典for。forEach只在非常简单的副作用场景下用。例外场景主要是性能敏感、需要极致优化的大数组循环。在这种代码里我会使用经典for并缓存长度。但这类代码在整个项目里的占比应该非常低。如果有前端跟我说项目里到处都是几十万数据的数组我会先怀疑是数据加载和分页策略出了问题而不是遍历方式选错了。5.3 代码可读性优先遍历方式其实是在给你的同事写说明书这一点是我最想强调的。你和同事读代码时看到map就知道这里产生了一个新数组过程不会修改原数据看到filter就知道这里在挑选某些元素看到reduce就知道这里在汇总看到for...of就知道这里需要中途跳车或者做异步。反过来如果所有逻辑都用forEach或for实现读者就必须逐行看循环体才能推断这段代码想干什么。对于一份需要长期维护的代码库来说这种看懂的成本累积起来是很可怕的。所以在我看来选择遍历方式的第一标准从来不是性能而是语义是否一致。性能问题可以通过分析和基准测试解决但代码意图模糊带来的维护成本会日积月累。如果每次写遍历前都先想清楚我要得到什么大概半年之后你会发现代码不仅能跑而且很好读。这种习惯一旦养成受益的不只是你自己还有每一个接手你代码的人。
返回列表