
做前端这几年我面试过不少人也看过不少新人写的代码。有个现象特别有意思很多写了两三年 JavaScript 的人遇到数组遍历要么只会用 for 循环死磕要么无脑用 map 或者 forEach压根没搞清楚每个方法返回什么、什么时候该用哪个。今天就把 JavaScript 数组遍历里最常用的七个方法讲透forEach、map、filter、reduce、every、some、find。这些方法看起来都只是“循环遍历数组”但背后的设计思路完全不同有的用来执行副作用有的用来做数据映射有的用来做筛选有的用来做聚合还有的能提前终止遍历。这篇文章会用实际场景拆解它们的差异、底层机制和藏在细节里的坑无论你是刚入门的新手还是写了几年业务代码但没系统梳理过的老手都能有所收获。1. 遍历之前先搞清楚你要什么数组遍历的本质就是对数组里的每个元素执行一次回调函数。回调函数统一接收三个参数当前元素item、当前索引index、数组本身array。这个签名在 forEach、map、filter、reduce 里基本一致只有 reduce 例外它额外多一个“累计值”参数。但“遍历”两个字掩盖了方法之间的巨大差异。同样是走一遍数组编译器的意图可以是只是想对每个元素做点事比如打印、赋值想根据每个元素生成一个新元素最终得到等长的新数组想把不满足条件的元素剔除保留子集想找到第一个符合条件的元素想确认是否存在至少一个/所有元素满足条件想把整个数组合并成一个值。如果你没想清楚自己要什么就去选方法代码大概率会写得别扭。比如有人用 map 做遍历打印有人用 forEach 收集结果还要额外定义外部变量还有人为了找一个元素把整个数组 filter 一遍再取[0]。这些都是没有按照“返回值”来选方法导致的。先用一张速览表把这些方法的关键差异列出来后面再逐个拆。方法返回值是否生成新数组能否提前停止典型用途forEachundefined否不能执行副作用打印、写入map新数组长度与原数组相同是不能数据映射、转换filter新数组长度为满足条件的元素个数是不能会遍历全部筛选子集find第一个满足条件的元素值没有则 undefined否找到后可以停止查找单个元素some布尔值否遇到 true 停止是否存在满足条件every布尔值否遇到 false 停止是否全部满足reduce累计后的任意类型值否不能除非手动 throw求和、分组、扁平化这张表你得牢记一点方法名带“返回什么”就决定了它适合什么场景。比如 map 核心是“映射”不是“遍历”filter 核心是“筛选”筛选必然产生新数组some/every 是“判断”结果必须是布尔值。想通这一点你选方法时就不会犹豫。2. 逐个拆解七个方法的核心用法与原理2.1 forEach只做事不返回forEach 的语法长这样arr.forEach(callback(currentValue, index, array) { // 你的操作 }, thisArg);它非常简单拿到数组每个元素依次执行回调没有返回值返回值是 undefined。这意味着你用const res arr.forEach(...)拿到的永远是 undefined。forEach 适合什么场景“副作用”场景也就是你想对数组里的每个元素执行一些操作但操作的结果不需要再组合成一个新数组。比如const users [{ name: 张三 }, { name: 李四 }]; users.forEach((user, index) { console.log(第 ${index 1} 个用户是${user.name}); }); // 输出 // 第 1 个用户是张三 // 第 2 个用户是李四还有常见的场景是把表单数据逐条提交、记录埋点、给每个 DOM 节点绑定事件。这些操作的特点是“做完就完了”不需要把结果收集回来。很多人刚学写代码时喜欢这样const result []; arr.forEach(item { if (item.age 18) { result.push(item); } });这种写法能用但属于“用 forEach 的副作用去模拟 filter map 的组合”。本质问题是你用外部数组result作为容器违背了函数式编程中“不变性”的原则。更好的做法是直接arr.filter(item item.age 18)后面会讲。一个容易忽略的点forEach 不会修改原数组但如果数组里的元素是对象回调中修改对象的属性是会生效的。因为对象是引用传递你改的是对象本身。比如const arr [{ count: 1 }]; arr.forEach(item { item.count 1; }); console.log(arr[0].count); // 2这不算 bug但要意识到 forEach 的“不改变原数组”只是说你不能通过回调的返回值去组合一个新数组或者对原始数组进行结构层面的增删。2.2 map映射不是遍历map 是“映射”的缩写。它的定义是对数组每一项执行回调把回调的返回值收集起来构成一个等长的新数组。const newArr arr.map(callback(currentValue, index, array) { // 必须 return否则新数组对应位置是 undefined return transformedValue; });举个最常见的例子取对象数组中某个字段。const users [ { name: 张三, age: 20 }, { name: 李四, age: 25 }, ]; const names users.map(user user.name); console.log(names); // [张三, 李四]再比如把数字都翻倍const numbers [1, 2, 3]; const doubled numbers.map(n n * 2); console.log(doubled); // [2, 4, 6]map 最大的坑是回调忘了写 return。新手经常写出这样的代码const arr [1, 2, 3]; const doubled arr.map(item { item * 2; // 没有 return }); console.log(doubled); // [undefined, undefined, undefined]因为花括号{}把函数体包起来了必须显式return。所以要么写单行表达式arr.map(item item * 2)要么在函数体里加上return。另一个容易被忽略的点是map 出来的新数组长度与原数组相同但元素的值由回调决定。如果你不需要产生新的数组内容只是想遍历用 forEach 更合适——用 map 做循环会产生一个没人要的临时数组浪费内存且语义混乱。2.3 filter筛选出符合条件的新数组filter 的作用是“过滤”。它会对原数组每一项执行回调回调返回值为true或真值时把这一项保留下来最终返回一个新数组。const newArr arr.filter(callback(currentValue, index, array) { return booleanValue; // 如果为 true保留当前元素 });经典场景筛选出年龄大于 18 的用户。const users [ { name: 张三, age: 17 }, { name: 李四, age: 21 }, { name: 王五, age: 18 }, ]; const adults users.filter(user user.age 18); console.log(adults); // [{ name: 李四, age: 21 }, { name: 王五, age: 18 }]filter 不会修改原数组总是返回一个新数组。如果没有任何元素满足条件返回的是空数组[]不是 undefined。这里有个常见的误解有人以为 filter 会“删除”原数组中的元素。实际上原数组保持不变。如果你要做删除操作比如从某个数组中移除 id 为 3 的元素正确姿势是const newArr arr.filter(item item.id ! 3);然后把newArr作为新状态使用而不是去 splice 原数组。这在 React、Redux 这类强调不可变数据的环境中几乎是标配操作。filter 的性能也值得说一句它会完整遍历数组所以如果你只想找一个满足条件的元素用 filter 会做很多无用功这时应该用find。2.4 find只找第一个找到就停find 返回第一个满足回调条件的元素值如果没有返回undefined。const foundItem arr.find(callback(currentValue, index, array) { return booleanValue; });它和 filter 最大的区别是filter 返回包含所有满足条件的数组find 只返回第一个命中对象。比如const users [ { id: 1, name: 张三 }, { id: 2, name: 李四 }, { id: 3, name: 王五 }, ]; const target users.find(user user.id 2); console.log(target); // { id: 2, name: 李四 }如果找不到返回 undefined。对应到实际业务里非常常见根据 ID 找一条订单、根据路由路径匹配一个配置项、在字典数组里查某个状态对应的文本。find 还有一个隐藏优势它会在找到第一个满足条件的元素后停止遍历不再继续检查剩余元素。对应到长列表场景如果满足条件的元素靠前效率会比 filter 高不少。虽然数组遍历本身开销不大但养成正确的习惯还是很有必要的。有一个小知识点与 find 配套的findIndex同样很好用它返回的是索引而不是元素本身。当你只需要索引时不要arr.find(...)再indexOf直接用findIndex。2.5 some 和 every满足一次还是满足所有把 some 和 every 放一起因为它们是“逻辑判断”的一对。它们的返回值都是布尔值。// some只要有一个元素满足条件就返回 true否则 false const hasAdult arr.some(item item.age 18); // every所有元素都满足条件才返回 true否则 false const allAdults arr.every(item item.age 18);这两个方法在语义上分别等同于逻辑或||和逻辑与在数组上的展开。比如const numbers [1, 2, 3, 4, 5]; // 有没有偶数 console.log(numbers.some(n n % 2 0)); // true // 是否全部都是正数 console.log(numbers.every(n n 0)); // true // 是否全部都是偶数 console.log(numbers.every(n n % 2 0)); // false这里有一个值得利用的特性some 只要回调返回 true 就会立刻停止遍历every 遇到 false 会立刻停止遍历。这是它们和 forEach/map/filter 不一样的地方你可以利用这个特性实现提前退出。举个例子判断一个数组中是否包含某个值传统做法用indexOf或者 includes但如果是“是否包含某个满足复杂条件的对象”some 是最好的选择const users [ { name: 张三, role: admin }, { name: 李四, role: user }, ]; const hasAdmin users.some(user user.role admin); console.log(hasAdmin); // true同理如果需要校验“所有输入都不为空”可以const values [abc, def, ]; const allValid values.every(value value.trim() ! ); console.log(allValid); // falseevery 和 some 同样不会改变原数组。它们适合放在if条件里做校验而不是用来收集数据。2.6 reduce所有方法的底层能力都在这了reduce 是这七个方法里最强大、也最容易让人懵圈的一个。它的回调签名和前面几个不同有四个参数arr.reduce(callback(accumulator, currentValue, currentIndex, array) { // accumulator 是上一次回调的返回值 // 返回的结果会作为下一次调用的 accumulator return newAccumulator; }, initialValue);第一次执行时accumulator的取值分两种情况如果你提供了initialValue第一次的accumulator就是initialValue第一次的currentValue是数组第一个元素。如果你没提供初始值第一次的accumulator是数组第一个元素currentValue是数组第二个元素。也就是说reduce 会从第二个元素开始遍历。最经典的求和const numbers [1, 2, 3, 4]; const sum numbers.reduce((acc, cur) acc cur, 0); console.log(sum); // 10这行代码等价于let sum 0; for (const cur of numbers) { sum cur; }注意如果不传初始值写成const sum [1, 2, 3, 4].reduce((acc, cur) acc cur);也能得到 10因为第一次 acc 是 1cur 是 2累加后 acc 变成 3然后 cur 是 3以此类推。但有一个致命问题数组为空时不传初始值会直接抛错。所以除非你确定数组至少有一个元素否则最好始终传入初始值。reduce 不只能做加法它能做任何“把一个数组归并成单个值”的操作。后面我会专门讲几个进阶案例比如分组、统计词频、把数组转对象等。3. 进阶用法与组合实战3.1 链式调用一出手就是清晰的数据管道这几个方法最爽的用法是串联起来形成一个“数据管道”。每一个方法返回的值能被下一个方法接收中间不会产生冗余的中间变量。假设你的业务是从一堆订单中筛出已付款订单把它们金额打 9 折再算出总金额。const orders [ { id: 1, paid: true, amount: 100 }, { id: 2, paid: false, amount: 50 }, { id: 3, paid: true, amount: 200 }, ]; const total orders .filter(order order.paid) // 1. 筛选已付款 .map(order order.amount * 0.9) // 2. 计算折扣价 .reduce((sum, price) sum price, 0); // 3. 求和 console.log(total); // 270这不是炫技。你想想如果用循环怎么写得写三层先建数组存过滤结果再建数组存打折金额最后累计。链式调用每一步只做一件事变量名都不需要额外定义可读性反而更好。不过要注意链别太长。一旦超过四五个方法别人阅读时就需要在脑子里同时维护每一步的数据状态反而降低了可读性。如果中间处理逻辑很复杂建议拆成带语义的变量或者用命名函数const paidOrders orders.filter(order order.paid); const discountedPrices paidOrders.map(order order.amount * 0.9); const total discountedPrices.reduce((sum, price) sum price, 0);拆开写更容易打断点调试。我自己一般是先写成链式等改出 bug 再拆开打 log最终提交前看阅读难度决定是否保留。3.2 用 reduce 做分组和统计说实话reduce 最实用的进阶场景不是求和而是“把数组变成对象”。比如把一堆用户按照角色分组你会怎么做有人会手写 for 循环维护一个空对象然后判断 key 是否存在。用 reduce 一行搞定const users [ { name: 张三, role: admin }, { name: 李四, role: user }, { name: 王五, role: admin }, ]; const groupByRole users.reduce((result, user) { // 如果当前角色还没有分组先初始化一个空数组 if (!result[user.role]) { result[user.role] []; } result[user.role].push(user); return result; }, {}); console.log(groupByRole); // { // admin: [{ name: 张三, role: admin }, { name: 王五, role: admin }], // user: [{ name: 李四, role: user }] // }关键点在于reduce 的回调必须返回result。很多初学者在这里忘记 return导致第二次回调时result变成 undefined然后if (!result[user.role])直接报错。如果你也踩过这个坑记住一句话reduce 回调的返回值会成为下一次回调的第一个参数不 return 就断链。同样统计数组中每个单词出现的次数const words [apple, banana, apple, orange, banana, apple]; const frequency words.reduce((counter, word) { counter[word] (counter[word] || 0) 1; return counter; }, {}); console.log(frequency); // { apple: 3, banana: 2, orange: 1 }顺便一提像这种“数组转对象”“按 key 分组”的逻辑在很多业务代码里反复出现。ECMAScript 新规范里也提供了Object.groupBy之类的 API但浏览器兼容性还不够全面完全可以用 reduce 自己实现不依赖任何库。3.3 在 forEach 里如何“中断”三种替代思路很久以前我也困惑过为什么forEach里的break会直接报语法错误这个得从设计意图说起。forEach 的设计目标是“对每个元素无差别地执行回调”它是一个声明式 API不承诺你可以在中途退出。换句话说它的底层实现是一个只会走到底的循环不像for...of那样受break控制。那如果业务上确实需要在遍历到某个元素后提前结束呢有三种常见做法。第一种用some()替代 forEach 实现“找到即停止”。如果你是在“找有没有满足条件的元素”那 some 本身就是为这个场景设计的。回调返回 true 时some 立刻返回 true后面不再遍历。const arr [1, 2, 3, 4, 5]; const found arr.some(item { console.log(当前值, item); if (item 3) { return true; // 返回 true 后some 立刻停止 } return false; }); // 只会打印到 3第二种用find()替代找到后直接拿结果。如果目标是“找到元素本身”find 天然支持提前停止前面已经讲过。第三种改用for...of循环用 break 退出。如果你必须在遍历过程中根据条件跳出且逻辑并不是简单的“查找”那就老老实实用最原始的循环不要硬套数组方法const arr [1, 2, 3, 4, 5]; for (const item of arr) { if (item 3) break; console.log(item); }有人可能想用“抛异常”来中断 forEach这确实能让遍历停下来但异常本身的成本不低而且会让代码陡增复杂度。除非你写的是一个 do-while 风格的复杂遍历否则我强烈不建议为“中断”小心翼翼去 try-catch。3.4 异步遍历的坑forEach 不等人这是实战里翻车率最高的一个问题。如果你在forEach里写await你会发现根本不等你async function handleList(items) { items.forEach(async (item) { await saveToServer(item); // 并不会按顺序等待 }); console.log(全部完成); // 这行会先执行 }原因很简单forEach不会理会回调返回的 Promise。它只是把每个回调丢出去执行了然后在继续遍历下一个元素。await 只作用在回调函数内部没有作用到 forEach 层面。所以如果你需要串行地一个个等异步操作完成有两个方案。方案一用for...of这是最简单的串行写法。async function handleList(items) { for (const item of items) { await saveToServer(item); // 每循环一次等下一次完成 } console.log(全部完成); }方案二如果异步操作之间互不依赖希望并发执行用Promise.all加上 mapasync function handleList(items) { await Promise.all(items.map(item saveToServer(item))); console.log(全部完成); }这里用map而不是forEach是因为 map 会收集每个回调返回的 Promise。如果你用 forEach回调的返回值被丢弃Promise.all就无法拿到这些 Promise。这个差别是“返回值”设计带来的关键影响也是我反复强调“方法返回值决定了它的用途”的原因。4. 常见问题与排查实录4.1 记不住回调参数顺序怎么办这是新手最常问的问题。所有遍历方法的回调都是(item, index, array)的顺序也就是第一个参数是元素本身第二个参数是下标第三个参数是正在遍历的原数组。只有 reduce 例外它的回调是(accumulator, item, index, array)也就是累计值要放在第一个参数。你可以通过一个小技巧记忆先判断“这个东西是不是每次遍历都有的东西”如果是元素就排在前面。reduce 多了累计值累计值又是最“特殊”的所以它排在第一位。4.2 map 和 forEach 到底选哪个这是面试必问题。一句话版答案需要生成一个与输入数组一一对应的新数组时用 map不需要返回值只是要执行操作时用 forEach。细节版map 的返回值是一个新数组而且长度和原数组完全相同。如果你用 map 却不要它的返回值等于浪费了一部分内存还降低了代码可读性。比如// 不推荐为了遍历而用 map产生的数组丢了 [1, 2, 3].map(item { console.log(item); }); // 推荐forEach 就是为这种场景设计的 [1, 2, 3].forEach(item { console.log(item); });反之如果你要得到一个新数组也不要用 forEach 加外部数组。比如// 不推荐手动 push 掩盖了“映射”语义 const result []; [1, 2, 3].forEach(item { result.push(item * 2); }); // 推荐map 本身就是做映射 const result [1, 2, 3].map(item item * 2);4.3 filter 和 find 使用场景混淆一句话版要所有满足条件的元素用 filter只要第一条满足条件的记录用 find。我见过不少代码这样写const user users.filter(user user.id userId)[0];如果确定 id 唯一这里更合适的应该是users.find(user user.id userId)。filter 会遍历完整个数组把所有相同 id 的都筛出来虽然只有一条再通过下标取第一个。find 会在找到第一条后立刻返回语义也更直接。当然如果你的目的是“得到所有匹配项”那就该用 filter这没悬念。这里还有一个小的知识点filter 即使没有任何匹配项返回的也是空数组所以可以用filter(...).length 0来判断是否存在。但既然有some和find直接用它们做判断和取元素会更好也不用担心返回 undefined 的问题。4.4 遍历过程中修改原数组引发的诡异问题很多人不知道 JS 数组遍历方法不推荐在回调中直接对数组进行 push、pop、splice 等操作。比如const arr [1, 2, 3, 4, 5]; arr.forEach(item { if (item 2) { arr.push(999); } console.log(item); }); // 打印 1 2 3 4 5这里的 999 并不会被遍历到因为 forEach 在遍历前会把数组长度缓存下来。也就是说你在过程中新增的元素不会出现在本次遍历中。但反过来如果你用splice删除了元素又会导致后续索引错乱甚至跳过元素const arr [1, 2, 3, 4, 5]; arr.forEach((item, index) { if (item 3) { arr.splice(index, 1); // 删除当前元素 } }); console.log(arr); // [1, 2, 4, 5]看起来变化不大但如果下一个元素的索引是 3原来索引为 5 的元素会移动到索引 4而 forEach 不会重新读取数组它继续访问索引 4就会拿到移动过来的元素。这种索引混乱特别不容易察觉尤其是遍历过程中连续删除多个元素时。我的建议是不要在遍历时修改数组本身。如果你要删除一批元素用 filter 生成一个新数组再整体替换如果你要在遍历过程中对数组做增删改成 while 循环并手动维护索引逻辑会清楚很多。4.5 稀疏数组map 会保留空洞forEach 会跳过JavaScript 的数组允许有“洞”比如const arr [1, , 3]; // 中间有一项是空位注意这里的空位是undefined但又不完全是undefined值它是数组上的“缺口”。不同方法的处理方式不同forEach、filter、some、every会跳过空洞map会保留空洞返回的数组中对应位置仍然是空洞find会把空洞当成undefined进行处理。我们验证一下const arr [1, , 3]; arr.forEach((item) { console.log(item); // 输出 1 和 3第二个元素被跳过 }); const mapped arr.map(item item * 2); console.log(mapped); // [2, 空洞, 6]空洞还在 const filtered arr.filter(item true); console.log(filtered); // [1, 3]空洞被过滤掉了如果数据源是后端返回的 JSON 数组通常不会有空洞但如果你自己创建数组时用了new Array(3)里面就是三个空洞。所以处理这种数组时如果需要保证不出现未知的 undefined可以用Array.from或者展开运算符把空洞转成 undefinedconst arr Array.from(new Array(3)); // [undefined, undefined, undefined]4.6 reduce 忘了传初始值空数组直接报错刚才提过一句这里展开说const arr []; const sum arr.reduce((acc, cur) acc cur); // TypeError: Reduce of empty array with no initial value因为没传初始值时reduce 会把第一项当成初始值。数组为空它连“第一项”都没有只能抛错。所以凡是可能为空数组的统计必须带上初始值const sum arr.reduce((acc, cur) acc cur, 0);还有一个细节即使数组非空如果回调里返回了undefined下一次的acc就变成 undefined后续加减乘除很容易变成 NaN。所以 reduce 的回调一定要设置明确的返回值。另外如果你用 reduce 统计属性数量和初始值类型决定了返回类型。比如统计数组中有多少个有效值如果初始值是 0结果一定是数字如果你想返回一个对象初始值就必须是{}。5. 选型速查与我的实操总结5.1 一张表助你直接抄作业需求使用的方法示例给每个元素执行操作打印、埋点forEacharr.forEach(item { sendLog(item) })数组元素逐个转换maparr.map(item item.price * 2)筛掉不满足条件的元素filterarr.filter(item item.isValid)只需要第一个满足条件的元素findarr.find(item item.id 5)判断是否存在匹配项somearr.some(item item.score 60)判断所有项都符合条件everyarr.every(item item.score 60)把数组聚合为单个值reducearr.reduce((sum, item) sum item.count, 0)如果你拿不准就按顺序问自己我要不要新数组要长度相等的用 map要子集用 filter要不要布尔值用 some/every要不要单个元素用 find要不要执行副作用用 forEach要不要合并成一个值用 reduce。5.2 我在项目里的实际体会最后分享几个我个人的经验不算标准答案但实战中真的有用。第一不要迷信函数式写法。数组方法确实能写出很优雅的代码但如果不分场景全用链式debug 时你会很痛苦。我见过最夸张的一段代码连续调用六个方法最后返回结果不对只能一行行拆开加 console.log。这种时候我就会想与其写出零注释的“高密度管道”不如用一两个中间变量把每步结果存下来。第二在 React 生态里尽量用 map 不可变更新少用 forEach 改外部变量。React 和 Redux 的核心理念是“状态不可变”你放一个外部数组在 forEach 里 push 再 setState很容易产生被共享的引用导致状态偷偷被修改。用map、filter生成新数组再赋值才是符合框架心智模型的做法。第三性能差异在绝大多数业务场景里可以忽略。有些工程师为了一个几千长度的数组把 filter 改成手动 for 循环说“更快”。实际上V8 引擎对这几个原生方法都有非常深的优化多数场景下你这点性能差距远小于网络请求的消耗。真正影响性能的反而是滥用链式产生多个临时数组。如果数组极大十万、百万级别或者需要按索引随机访问和提前中断我会直接回到 for 或 while。但业务开发中for 循环应该只作为“最后手段”出现。第四写代码前先想清楚方法的返回值。这个我反复强调因为它真的是根因。只要你想清楚“forEach 返回 undefined、map 返回新数组、filter 返回子集、find 返回元素、some/every 返回布尔、reduce 返回累计值”你在选方法时完全不用查文档也不用试错。所谓“数组遍历方法详解”说到底就是对你“意图”的确认——你想要的到底是过程、结果还是状态这些方法用熟了以后你会发现 JavaScript 里很多逻辑都可以用“数据管道”的方式写出来。少一些临时变量少一些副作用代码会更好维护。希望这篇拆解能让你对数组遍历这件事建立起自己的判断框架至少下次在代码 review 时能一眼看出来哪里用了错误的遍历方法。