JS数组遍历方法怎么选?for、forEach、map、filter、reduce全解析
关于 for 循环、forEach、map、filter、reduce 这些 JS 里的遍历方式我见过太多人只是会语法、不会选型。之前面试过一位候选人把 forEach 和 map 区别背得滚瓜烂熟一问到“数组里有 10 万条数据你用什么方式遍历不卡”就沉默了。遍历这件事表面看是把每个元素过一遍实际上一旦涉及中断、返回值、性能、边界条件不同方法之间的差别非常大。这篇文章就把从最传统的 for到 forEach、map、filter、for...in、for...of、every、some、includes、reduce、find 一次讲透重点是“什么场景该用哪一个”。这篇文章适合的读者不是刚学语法的新手而是那些已经写过不少业务代码、但在选型上还处于“同事怎么写我就怎么写”阶段的人。看完你会拿到一套比较稳的选择逻辑什么时候用 for什么时候用 for...of什么场景闭眼选 reduce以及为什么某些场景千万别用 forEach。全文尽量用能直接跑起来的代码示例说明。1. 遍历这件事为什么值得专门梳理一次1.1 从“循环控制”到“数组方法”的演进先看一个很常见的历史包袱。以前写 C 或者 Java 的时候遍历数组基本就是用索引循环JS 早期也是这么干的。后来 ES5 加了 forEach、map、filter 这一批数组方法ES6 又补了 for...of、find、includes 等。现在很多团队代码里老代码全是for (let i 0; i arr.length; i)新代码又全换成了 forEach两种风格混在一起读起来非常割裂。搞懂这些遍历方式核心不是背 API而是理解两个维度第一个维度是“循环控制”就是你想自己掌控索引、退出条件、步长。传统 for、while、do...while 属于这一类。第二个维度是“宣告意图”就是你不是想“循环”而是想“筛选”“变换”“找元素”“判断是否所有满足”。forEach、map、filter、some、every、find、reduce 属于这一类。这两个维度的区别想通了很多问题就迎刃而解。比如“为什么 forEach 里 break 不了”——因为 forEach 的定位是“对每个元素做一件事”它不负责短路和提前退出这是设计意图不是缺陷。1.2 各方法解决的是哪一类需求我不太喜欢把 API 一个个罗列因为文档随时能查。我更想告诉你当你面对一个需求时大脑里应该怎么反应想要新数组且新旧数组长度一致每个元素做变换 → map想要新数组但长度可能变短只留符合条件 → filter只是要把每个元素都碰一遍不需要返回值不打算中断 → forEach需要找第一个符合条件的元素而不是全部 → find需要判断“有没有” → some / includes需要判断“是不是全都满足” → every需要对整个数组做归并比如求和、分组、拍平 → reduce需要同时拿到值和下标并要自己控制跳出 → 传统 for / for...of 配合 break这个清单覆盖了绝大多数业务场景。剩下的就是具体语法和躲坑细节。2. 经典循环家族for、while、do...while2.1 传统 for 循环的三段式结构传统 for 看起来笨但它是最底层的遍历方式也是性能参照物。它的三段式初始化、条件、步进给了你最大控制力可以i 2跳着遍历可以i--倒序可以在中间条件里写复杂判断可以随时 break。const arr [10, 20, 30, 40]; for (let i 0; i arr.length; i) { console.log(arr[i]); }很多人的习惯是把arr.length写在条件里每次循环都会读取一次数组长度。对普通数组来说影响通常可以忽略但如果 arr 是个很大的类数组对象或者每次访问 length 还有副作用那就把长度缓存出来for (let i 0, len arr.length; i len; i) { // ... }还有一个反向遍历的小技巧逆序遍历时把条件写成i 0如果是从尾部开始删除元素或做倒序处理就不容易出越界或漏项的错。尤其是数组中间删元素倒序删除最稳正序删除会因为下标前移而漏掉元素。2.2 while 与 do...while 的适用边界while 和 for 的本质区别是for 适合“我知道要遍历多少次”的场景while 适合“循环次数不确定但有一个继续条件”的场景。举例消费一个队列读一个流直到没有数据let n 0; while (n 3) { console.log(n); n; }do...while 平时用得很少但有一个非常关键的语义先执行一次循环体再判断条件。比如你要先让用户输入一次、再判断是否重新输入这种“至少执行一次”的场景就适合 do...while。业务代码里很少用到但面试题经常考“至少执行一次”这个特性。2.3 什么时候别迷信数组方法该回归经典循环数组方法不是万能的。我总结过几个必须用经典循环或用 for...of 的场景。第一需要在遍历过程中跳过多个元素、动态改变步长。举个例子跳跃游戏这类算法题你需要在循环里每次都更新i i nums[i]这时候 forEach 根本没有办法实现。第二需要提前终止。forEach 没有 break。虽然可以用抛出异常、或者用 every/some 伪造提前退出但那是 hack不是常态。第三性能极端敏感的场景。虽然现在 V8 对数组方法的优化已经很好了但 for 循环在基准测试里通常还是略快一点。如果你在做 Canvas 像素处理、大数组高频计算用经典循环更稳。提示不要为了“看起来高级”强行用数组方法。代码首先要能正确表达逻辑其次才是风格。3. for...in 和 for...of一对容易混淆的兄弟3.1 for...in 的“可枚举属性”陷阱for...in 很多人以为它是用来遍历数组的但它其实是为遍历对象属性设计的。它会遍历对象上所有可枚举的字符串键属性包括原型链上继承来的——正是因为这一点历史上出现过无数 bug。举一个典型例子Array.prototype.demo function () {}; const arr [1, 2, 3]; for (const key in arr) { console.log(key, arr[key]); } // 0 1 // 1 2 // 2 3 // demo function () {}明明只想遍历数组元素结果原型上扩展的一个方法也被枚举出来了。所以技术社区里有个共识不要用 for...in 遍历数组。如果真要遍历对象也要配合hasOwnProperty判断const obj { name: 张三, age: 30 }; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { console.log(key, obj[key]); } }for...in 的另一个特点是遍历顺序整数键会按数字升序字符串键按插入顺序。这个顺序规则跟Reflect.ownKeys又有区别容易踩一些莫名其妙的坑。总之遍历数组时绕开它是新手最快避雷的方法。3.2 for...of 与可迭代协议for...of 是 ES6 引入的它解决的是“可迭代对象”的统一遍历问题。数组、字符串、Set、Map、arguments、Generator 等只要实现了迭代器协议都能用 for...of。写起来比传统 for 清爽const arr [1, 2, 3]; for (const item of arr) { console.log(item); } // 1 2 3它跟 forEach 最大的区别它支持 break、continue、return可以跳出。所以当你需要遍历数组但又可能有提前退出逻辑的时候for...of 通常比 forEach 更合适。想同时拿 index 的话用entries()for (const [index, item] of arr.entries()) { console.log(index, item); }我特别喜欢拿 for...of 遍历 Set 和 Map这是传统 for 和数组方法都没法优雅做到的事const set new Set([a, b, c]); for (const value of set) { console.log(value); }3.3 怎么选才不容易翻车我的个人经验给团队定的约定是这样的遍历数组如果不需要 index优先用 for...of如果需要在循环中退出、continue用 for...of如果需要 index 加 break用传统 for遍历对象属性用Object.keys/Object.entries配合 for...of而不是 for...infor...in 尽量只在需要遍历“对象自身属性”并且清楚原型链不会捣乱时才用这套约定帮我省了很多 review 口水。因为一旦大家按同一套规则写代码风格会很统一不需要每次讨论“这里为什么不用 xx”。4. forEach / map / filter三个高频数组方法4.1 forEach默认的回调式遍历forEach 覆盖了“把每个元素处理一遍”这个最常见需求。它的回调能拿到三个参数当前值、当前下标、原数组第二个参数还可以指定 callback 的 this 值。很多人只写一个参数其实下标在很多场景很有用。const arr [a, b, c]; arr.forEach((item, index, array) { console.log(${index}: ${item}); });一个典型的坑forEach 的回调里写 return只是返回了当次回调不会中断循环。如果需要按条件中止请换 for...of。如果需要“跳过这次”可以用 return 模拟 continue但没法模拟 break。还有forEach 不会遍历空槽位。如果数组是稀疏数组比如[1, , 3]中间那个空位会被跳过。这一点跟 map 的行为类似空位会被跳过但 map 返回的新数组会保留空位而 filter 会把空位跳过回调根本不会执行。这种差异平时不常见但真到底层数据处理时会很迷惑后面单独说。4.2 map变换并返回新数组map 的语义是把数组里每个元素映射成一个新元素最终返回一个同样长度的新数组。它不会修改原数组前提是回调里不主动修改对象属性。对基本类型来说原数组完全不受影响。写个业务里最常见的场景把 id 数组转成对象数组const ids [1, 2, 3]; const users ids.map((id) ({ id, name: 用户${id} }));两个容易忽略的点第一map 后是否会产生空值。如果你 map 之后还要filter(Boolean)之类的操作可以考虑直接用flatMap能省一次遍历。const arr [1, 2, 3]; // 想把偶数变成 [x, x*10]奇数不要 const flatMapped arr.flatMap((num) num % 2 0 ? [num, num * 10] : [] ); // [2, 20]第二map 里如果回调有副作用——比如打印日志、改外部变量——严格来说就有点违背 map 的“纯变换”定位。代码评审时我会建议拆出来用 forEach。4.3 filter筛选出符合条件的元素filter 返回一个新数组只包含回调返回 true 的元素。它最经典的用法就是筛选列表数据const tasks [ { title: 写周报, done: true }, { title: 修 bug, done: false }, ]; const undone tasks.filter((task) !task.done);filter 的返回值永远是一个数组即使没有任何符合条件的元素也是空数组不是 undefined。这个特性很适合链式调用不会因为空值把后面的代码炸掉。要注意一个新手陷阱filter 判断布尔值。如果写filter(item item.status)status 为 0 或者空字符串时会被过滤掉但这可能不是你想要的。该写严格判断时就写严格判断。const items [{ status: 0 }, { status: 1 }, { status: 2 }]; // 想保留 status 为 1 的不能只写 Boolean const result items.filter((item) item.status 1);4.4 三兄弟怎么选一张表说清楚方法返回值是否改变原数组核心用途能否中断forEachundefined一般不改变每个元素执行副作用不能map新数组等长不改变元素变换不能filter新数组长度可能更短不改变筛选元素不能如果只需要“遍历”不关心返回值用 forEach 或者 for...of如果要把数据“变形”用 map如果要把数据“瘦身”用 filter。三者的共同点是都不应该主动干扰原数组这是数组方法的设计倾向。5. every / some / includes判断与短路5.1 every所有元素都满足才返回 trueevery 的作用是检测数组里是否每个元素都符合条件。它的特点是可以在遍历过程中提前返回 false因此性能上比“先 filter 再判断长度”要好。常见业务场景表单必填项检查。const form [true, true, false]; const allValid form.every(Boolean); // false轻量写法里的Boolean是直接把每个元素转成布尔值。注意空数组调用 every 会返回 true这在数学上叫“vacuously true”但业务上容易造成误解。比如某个数组意外为空every 却返回通过。所以如果数组空值会让业务出错先判空再用 every。5.2 some只要有满足条件的就返回 truesome 和 every 是一对它是短路在第一个 true 上的。这通常比全部遍历性能更好。示例判断一组权限里是否包含某个权限。const permissions [read, write, delete]; const canEdit permissions.some((p) p write);some 与 includes 的使用场景很接近区别是 includes 只判断值是不是相等语义更直接。some 的 callback 可以做更复杂的判断比如模糊匹配、区间判断。const prices [10, 25, 30]; const hasExpensive prices.some((price) price 20); // true5.3 includes最直观的“是否存在”includes 是在 ES2016 加入的。它的语法非常简单const arr [苹果, 香蕉, 橘子]; console.log(arr.includes(香蕉)); // true console.log(arr.includes(葡萄)); // false要注意 includes 用的是 SameValueZero 比较所以[NaN].includes(NaN)返回 true而indexOf(NaN)返回 -1。这个细节在数据清洗时经常用到。另外 includes 可以传第二个参数作为起始 index支持负数。例如arr.includes(2, -2)表示从倒数第二个位置开始找。有些场景用来做“排除第一个元素后的查找”很方便。6. find / reduce查找与归并的进阶武器6.1 find返回第一个符合条件的元素find 返回数组中第一个满足条件的元素如果没找到返回 undefined。它不像 filter 返回数组所以从一堆对象里取某个特定项非常顺手const users [ { id: 1, name: 张三 }, { id: 2, name: 李四 }, ]; const target users.find((u) u.id 2); console.log(target.name); // 李四隐含的坑是如果业务上有“找不到就报错”的需求判断target undefined要专门处理否则后续target.name会直接抛 TypeError。findIndex是配套的返回下标而不是元素。如果只需要知道“有没有”更推荐 some需要“找到并取出”用 find。6.2 reduce数组归并的通用能力reduce 是所有数组方法里最难啃的一个但掌握了它很多问题都能在一步内解决。它的签名是arr.reduce((accumulator, currentValue, currentIndex, array) { // 返回新的 accumulator }, initialValue);最经典的用法是求和const nums [1, 2, 3, 4]; const sum nums.reduce((acc, cur) acc cur, 0); // 10注意一个细节如果不传初始值reduce 会把数组第一项当作 accumulator然后从第二项开始遍历。这对求和可能没问题但如果数组是空的直接调用 reduce 会抛错。所以业务代码里尽量显式传初始值至少能避免一些边界异常。6.3 reduce 的高级用法分组、拍平、统计reduce 并不神秘它就是手动维护一个结果每次遍历都更新这个结果。所以很多东西都能做比如用对象统计出现次数const words [apple, banana, apple, orange, banana, apple]; const count words.reduce((acc, word) { acc[word] (acc[word] || 0) 1; return acc; }, {}); // { apple: 3, banana: 2, orange: 1 }再比如把一个嵌套数组拍平一层const nested [[1, 2], [3, 4], [5]]; const flat nested.reduce((acc, cur) acc.concat(cur), []); // [1, 2, 3, 4, 5]这种需求现在可以直接用flat()但 reduce 版本能帮你理解归并逻辑。写 reduce 时最容易犯的错回调里忘了 return结果 accumulator 就变成了 undefined第二次迭代直接崩溃或者得到怪结果。我也翻过几次车排查起来又慢又气人。7. 组合实战与选择逻辑7.1 链式调用filter map reduce 的常规搭配在实际项目里很少单独只用一种方法常见的是链式组合。比如从订单列表里取出已完成订单的金额总和const orders [ { status: done, amount: 100 }, { status: pending, amount: 50 }, { status: done, amount: 200 }, ]; const total orders .filter((order) order.status done) .map((order) order.amount) .reduce((acc, amount) acc amount, 0); // 300链式调用的好处是每一步意图都非常明确先筛选再取字段再求和。缺点是如果数据量极大会产生多个中间数组内存开销可能高一些。数据量小的时候完全不用焦虑团队可读性优先。如果数据量大到万级以上你可以考虑能否用一次 reduce 完成所有的事const total orders.reduce((acc, order) { if (order.status done) { return acc order.amount; } return acc; }, 0);逻辑上等价但一次遍历无中间数组。两种写法各有取舍我的建议是后期运行里有性能瓶颈再优化平时先写清晰的第一版。7.2 用长度、性能和可读性来选遍历方式总结我日常的选择逻辑可以归纳成这张表场景推荐方式需要提前退出循环传统 for / for...of break需要中途跳过并继续for...of continue 或 for 循环 continue把所有元素处理一遍但不管返回值forEach 或 for...of把所有元素做变换并生成新数组map筛选符合条件的子集filter找第一个满足的元素find判断是否存在某个值includes / some判断所有元素都满足every累计计算、分组、拍平等reduce这个表格不是教条但在给刚入职的同事做 code review 时按这个标准检查大部分写法都能很快统一。8. 常见问题与排查技巧实录8.1 forEach 里想中断循环怎么办这是出现频率最高的问题。想提前退出遍历却写了 break结果得到Illegal break statement。前面说过forEach 压根不支持 break。解决方案是改用 for...of或者用 some/every 的短路行为。如果非要保留 forEach 写法可以抛异常然后 catch但不推荐代码会变得很丑。// 推荐for...of for (const item of arr) { if (item target) break; // 处理逻辑 }8.2 forEach 里使用 async/await发现没生效这是个很隐蔽的坑。forEach 不会等待 Promise因为它的回调是同步调用的。你写arr.forEach(async (item) { await save(item); }); console.log(完成); // 这行会先执行想按顺序处理异步任务用 for...of 加 awaitfor (const item of arr) { await save(item); } console.log(完成);这种差异在并发请求、批量入库时特别容易引发线上问题。8.3 reduce 忘写 return数据直接变 undefined多数 reduce 相关的疑难杂症最后都发现是回调里没有 return。记住 reduce 的每一轮迭代都必须返回新的 accumulator否则下一轮 acc 就是 undefined。调试时可以打印 acc能快速定位。const nums [1, 2, 3]; const sum nums.reduce((acc, cur) { acc cur; // 忘了 return }, 0); // undefined8.4 空数组上调用不同方法结果完全不同reduce 不传初始值在空数组上会抛 TypeErrorevery 在空数组上返回 truesome 在空数组上返回 falsefind 在空数组上返回 undefinedincludes 在空数组上返回 false这一组行为差异非常容易坑到人。写通用工具函数时我喜欢先处理空数组边界再调遍历方法避免上游数据异常导致不可预期的结果。8.5 稀疏数组和空槽位导致的诡异行为前面提过 forEach 和 map 会跳过空槽但 for...of 和 Array.from 会把空槽当作 undefined。同样是遍历结果完全不同。避免这种问题的根本手段不要在代码里手工创建稀疏数组。如果一定要创建固定长度的数组并填充用Array.from({ length: 5 }, (_, i) i 1)或者new Array(5).fill(0)结果都是连续数组。9. 一些我不建议的写法9.1 用 map 代替 forEach 触发副作用有人为了“一行流”用 map 来触发副作用返回值又不用这浪费了新数组的开销也模糊了代码意图。要遍历就 forEach 或 for...of要变换才 map。9.2 把 reduce 写成超长回调reduce 虽然强大但一个回调里塞了十几行逻辑读起来比普通 for 循环还难懂。真到了这一步我会先建议拆成普通循环加辅助函数可读性优先。没有什么写法是必须用 reduce 的它只是工具。9.3 用 for...in 遍历数组并拿 index这种写法性能、语义都不占优势还容易踩原型链的坑。直接 for...of 或传统 for 就好。for...in 的正确使用场景就是普通对象的可枚举属性遍历而且通常还要配合hasOwnProperty。9.4 遍历 Set 或 Map 时以为能用 map/filter这些方法不是所有可迭代对象都有Set 没有 map 方法。Set 要变数组得先[...set]或Array.from(set)再操作。这点经常被忽略。10. 写在最后我的选择习惯作为一个写业务代码写了很多年的人我不会吹任何一种方法“最牛”。for 循环性能好for...of 可中断map/filter 语义清晰reduce 功能强大每种都有不可替代的位置。真要给建议就是先想清楚你要表达“我要做什么事”而不是“我用哪个语法”。很多场景下filter().map().reduce()链式写出来别人一眼就能看懂你要干什么比一长串手写 for 更好维护。最后再分享一个很小但很实用的习惯凡是涉及遍历的代码先想一下边界情况——空数组、只有一个元素、全是重复元素。在这些边界上forEach/map/reduce 的表现差异最容易暴露。提前处理边界能省掉不少线上问题。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →