JavaScript数组遍历方法详解:从for到map、filter、reduce的选型指南
写 JavaScript 的人几乎没有一天不和数组打交道。对数组做遍历、筛选、映射、统计基本都是靠循环这一套东西撑着。可一旦你打开文档就会发现除了最基础的 for还有 forEach、map、filter、for...in、for...of、every、some、includes、reduce、find 一大堆兄弟。很多人刚开始学起来是懵的到底该用哪个它们之间到底有什么区别为什么有时候我明明用了 map结果原数组却完全没变化这篇文章就是来把这些事情一次说清楚的。我不打算只贴 API 文档而是从一个实际写业务代码的视角把每个方法的语法、返回值、副作用、性能特征和适用场景全部拆开讲顺便把我这些年踩过的坑、总结的选型习惯一并分享出来。不管你是刚接触 JavaScript 的新手还是写了好几年但一直靠“复制粘贴”混过去的老手这篇内容应该都能帮你建立起一套清晰的遍历选型思路。1. 先想清楚遍历这件事核心思路与选型逻辑1.1 为什么数组遍历有这么多玩法JavaScript 是一门“双风格”语言它既能用命令式的方式写代码也就是一步一步告诉机器“先做什么再做什么”也能用函数式的方式写代码把“某个操作”作为参数传给另一个函数。数组方法正好是这两种风格的碰撞点。传统的 for 循环是命令式风格的代表你怎么走、走几步、走到哪停全部由你控制。而 forEach、map 这些方法则更像函数式风格的产物你只管告诉它“对每个元素做什么”循环过程本身被封装在底层。这种设计的好处是代码语义更清晰你一看到filter就知道这是在做“筛选”而不是从头把循环体读一遍才能猜出意图。另一个原因是不同方法解决的是不同层面的问题map关注“转换”filter关注“筛选”reduce关注“聚合”find关注“查找”。如果所有需求都只靠一个 for 循环那代码也不是不能写但可读性和可维护性会差很多。团队协作时方法名本身就是文档。1.2 方法分工总览一张表看懂该用谁在实际编码之前先把这些方法按照“干什么用”归个类对选型会有很大帮助。方法核心作用返回值是否中断是否改变原数组推荐场景for通用循环完全手动控制无由你决定可以break/return视操作而定复杂步骤控制、高性能遍历、需要提前结束for...in遍历对象可枚举属性无可以break/return一般不修改遍历对象的键名非数组for...of遍历可迭代对象的值无可以break/return一般不修改遍历数组、Set、Map、字符串等forEach对每个元素执行回调无返回值undefined不可中断视回调操作而定简单遍历、副作用操作map将每个元素映射为新值返回新数组新数组长度不变不可中断不修改原数组数据格式转换、提取字段filter筛选满足条件的元素新数组长度可能变短不可中断不修改原数组过滤满足特定条件的子集every判断是否所有元素满足条件布尔值遇到 false 短路不修改全量校验some判断是否存在元素满足条件布尔值遇到 true 短路不修改存在性判断includes判断数组是否包含某个值布尔值命中后停止不修改简单值包含判断find返回第一个满足条件的元素值元素本身或undefined找到后停止不修改从数组里找一个对象/元素findIndex返回第一个满足条件的元素索引索引号或 -1找到后停止不修改需要索引的场景reduce通过累计器将数组归约为任意值归约结果任意类型不可直接中断不修改求和、分组、扁平化、构建新对象有了这张表打底后面再逐一看细节就不会迷路。注意中间几个方法虽然写法像但语义完全不同用错地方很容易产生隐蔽 bug。2. 基础循环家族for、for...in、for...of 的完整剖析2.1 传统 for 循环什么时候还能派上用场传统 for 循环是三段式的初始化、条件判断、步进更新。它最大的特点是控制力极强你可以精确控制从第几个元素开始、到第几个元素结束、每次跳几步。这种灵活性在某些场景下不可替代。const fruits [apple, banana, orange, grape]; for (let i 0; i fruits.length; i) { console.log(fruits[i]); } // 倒序遍历 for (let i fruits.length - 1; i 0; i--) { console.log(fruits[i]); } // 每隔一个取一个 for (let i 0; i fruits.length; i 2) { console.log(fruits[i]); }我自己的经验是传统 for 真正的价值在于“需要手动控制步进”和“需要提前终止”的场景。比如轮播图切换到末尾要回到开头或者遍历一个大体积日志数组时命中关键字就停这时候forEach是做不到的而传统 for 配合break就能很自然地解决。还有一个容易被忽略的细节不要在循环体里去获取数组长度。如果循环过程中数组会动态变化for (let i 0; i arr.length; i)的arr.length每次都会被重新读取结果可能不符合预期。固定长度时应该先把长度缓存到一个变量里。const length arr.length; for (let i 0; i length; i) { // 稳定遍历 }不过老实说如果不是对性能或控制流有特殊要求我日常用传统 for 的频率已经不高了。真正让我离不开的是下面这两个“for 家族”成员。2.2 for...in遍历对象键名的正确姿势与坑for...in的设计初衷是遍历对象的可枚举属性。很多人看到“遍历”两个字就什么都往里塞但用for...in遍历数组其实是个典型反模式。const person { name: Alice, age: 30, city: Beijing }; for (const key in person) { console.log(key, person[key]); } // name Alice // age 30 // city Beijing这样用没什么问题但要注意三个坑。第一for...in遍历的顺序在 ES 规范里不保证完全按插入顺序虽然大多数现代浏览器对字符串键都按插入顺序输出但数字键会优先按升序排。第二它不仅遍历对象自身的属性还会把原型链上可枚举的属性带出来所以经常需要加上hasOwnProperty判断。for (const key in person) { if (Object.hasOwn(person, key)) { console.log(key, person[key]); } }第三如果拿它去遍历数组拿到的 key 是字符串形式的索引不是数字而且同样会把数组原型上扩展的方法带出来。数组遍历这个任务还是交给for...of或者数组方法更安全。现代开发中我更推荐用Object.keys()、Object.values()、Object.entries()来遍历对象因为返回的是数组可以直接搭配数组方法链式操作语义也更明确。但for...in也不是完全没用在处理一些动态属性名、或者需要一次性遍历所有可枚举属性时它依然是最省事的方案。2.3 for...of支持迭代协议的统一遍历方式for...of是 ES6 带来的遍历语法它针对的是“可迭代对象”。数组、字符串、Set、Map、arguments、NodeList、Generator 都实现了迭代协议所以都可以用for...of直接遍历。const nums [10, 20, 30]; for (const num of nums) { console.log(num); // 10 20 30 } for (const char of hello) { console.log(char); // h e l l o } const set new Set([a, b, c]); for (const item of set) { console.log(item); } const map new Map([[name, Alice], [age, 30]]); for (const [key, value] of map) { console.log(key, value); }它在数组上拿到的直接是“元素值”而不是索引这一点和for...in完全不同。因为语法简洁、可中断、不返回多余属性它是我最常用的通用遍历方案。这里要特别提一个异步场景for...of可以配合await实现“逐项等待”的效果。async function processItems(items) { for (const item of items) { await handle(item); // 串行处理一个个来 } }这个能力在后面的异步循环章节会详细展开。总之for...of是基础循环里的“万金油”除了对象不能直接遍历以外大多数场景都能扛。2.4 三者对比与选择标准对比项传统 forfor...infor...of遍历对象需配合 Object.keys 等可以直接遍历可枚举属性不能直接遍历普通对象遍历数组可以拿到索引不建议拿到字符串索引可以拿到元素值可中断可以可以可以异步支持可配合 await可配合 await可配合 await推荐使用复杂度高低低一句话总结普通数组遍历默认选for...of需要索引或条件控制时选传统for对象键名遍历用for...in或Object.keys。不要用for...in遍历数组这基本是各团队的代码规范禁忌了。3. 数组迭代方法详解forEach、map、filter3.1 forEach没有返回值的“纯执行”遍历forEach的语法非常简单arr.forEach(callback(currentValue, index, array), thisArg)。它的作用就是对每个元素执行一次回调然后不管了返回undefined。const numbers [1, 2, 3, 4, 5]; numbers.forEach((num, index) { console.log(第 ${index} 个元素是 ${num}); });forEach最典型的应用场景是“副作用”操作给每个元素打印日志、把数据渲染到 DOM、往数组里 push 一项记录、调用外部接口等。这些操作不需要产出新的数组只是“对每个元素做一件事”。它有几个使用限制必须记住。第一不能中断。break是语法错误return只是退出当前这一次回调并不会终止整个循环。如果遍历到一半想停forEach做不到换成for...of或some更实际。第二回调里第一个参数是元素值第二个是索引第三个是原数组。很多新手把参数顺序记反在需要索引时用了第一个参数导致逻辑出错。实在记不住就按“值、索引、数组”这个顺序背。第三forEach对空数组不会执行回调这是好事但要注意它遍历的是“当前迭代那一刻”的数组如果在回调里往数组 push 新元素新元素也会被继续遍历可能出现无限循环。const arr [1, 2, 3]; arr.forEach((item) { arr.push(item * 10); // 会一直加下去最终导致内存问题 });这个行为我在早年排查线上问题时遇到过后来就养成了“在 forEach 回调里坚决不修改原数组长度”的习惯。3.2 map数据映射转换的标准答案map的核心语义是“映射”输入一个数组对每个元素做一次转换返回一个长度相同的新数组。它永远不修改原数组也不会留下undefined占位符——除非你的回调本身返回了undefined。const prices [100, 200, 300]; const withTax prices.map(price price * 1.1); // [110, 220, 330]原数组不变因为返回新数组map最常见的两个用处是从对象数组里抽取某个字段以及把数据格式统一转换。const users [ { id: 1, name: Alice }, { id: 2, name: Bob } ]; const names users.map(user user.name); // [Alice, Bob] const ids users.map(user user.id).map(id user-${id}); // [user-1, user-2]这里必须强调一个高频错误map回调里写了逻辑却忘记return。一旦没有 return回调实际上返回undefined结果就会得到一堆undefined还经常被误认为 map 有 bug。const wrong prices.map(price { price * 1.1; // 漏了 return }); // [undefined, undefined, undefined]另外一个容易忽略的点是map和forEach的选择。如果你不需要返回新数组就别用map。用map做纯循环其实是在制造一个没用的数组既浪费内存也会让看代码的人误以为你要用返回值。3.3 filter筛选出符合条件的子集filter的作用是“筛选”遍历数组把回调返回真值的元素保留下来组成一个新数组返回。回调返回假值的元素会被剔除。它是“只读”的不会修改原数组。const scores [88, 42, 95, 67, 51]; const passed scores.filter(score score 60); // [88, 95, 67]filter在处理接口数据时极其常用。比如从商品列表里筛出库存大于 0 的商品或者从日志里筛出错误级别为 error 的记录。const products [ { name: 鼠标, stock: 0 }, { name: 键盘, stock: 12 }, { name: 显示器, stock: 5 } ]; const available products.filter(product product.stock 0); // [{ name: 键盘, stock: 12 }, { name: 显示器, stock: 5 }]和map一样filter也有“忘了 return”的坑。不过我见过更隐蔽的问题是把条件写反明明是筛选“符合条件”的回调里却返回了不符合条件的结果导致数据被反向过滤。建议写 filter 时先在心里念一遍我要“留下”哪些元素回调就返回“真”。还需要特别说明一点filter返回的是数组哪怕结果只有一个元素也是数组。如果只想拿到“第一个符合条件”的元素应该用下一章要讲的find而不是filter()[0]。3.4 三个方法的选型标准在业务代码里这三个方法经常被放在一起比较。我的选型思路很简单只想让每个元素做点事不需要返回值forEach。想把一种数据格式转成另一种数据格式map。想从一堆数据里抽出一个子集filter。又想筛选又想转换先filter再map或者直接用reduce。举个例子把用户列表里成年人的名字收集起来最直观的写法是filter加map链式调用。const adultsNames users .filter(user user.age 18) .map(user user.name);这段代码的可读性很好第一行“筛出成年人”第二行“提取名字”。如果数据量不是几十万级别链式写法完全没问题。数据量极大时再考虑合并成一次循环后续性能章节会提到这一点。4. 判断与统计类遍历every、some、includes、find、reduce4.1 every 与 some全真判断与存在性判断every和some都返回布尔值是用来做“条件判断”的不是用来收集数据的。every要求所有元素都满足某个条件才返回true。只要有一个元素不满足立刻短路返回false。const ages [18, 22, 35, 41]; const allAdults ages.every(age age 18); // true const prices [100, -5, 200]; const allPositive prices.every(price price 0); // false遇到 -5 就停了some则相反只要有一个元素满足条件就返回true。找不到满足条件的就返回false。const words [apple, banana, cherry]; const hasLongWord words.some(word word.length 6); // true const numbers [1, 2, 3]; const hasTen numbers.some(num num 10); // false这两个方法最适合做“表单校验”“权限判断”“开关状态检查”。比如一个表格里只要有任何一行的校验状态是 false就不能提交这种逻辑用some一行就能写清楚。需要注意空数组的行为[].every(callback)返回true[].some(callback)返回false。前者在数学上叫“空真”刚开始可能觉得反直觉但规范就是这样定义的。如果业务上对空数组有特殊要求要先判断长度。4.2 includes最简单直接的包含判断includes不接收回调函数它只判断数组里是否存在某个值使用严格相等进行比较返回布尔值。const fruits [苹果, 香蕉, 橘子]; fruits.includes(香蕉); // true fruits.includes(西瓜); // false它和indexOf的区别有两个值得注意的点。第一includes能正确识别NaN而indexOf找不到NaN。const arr [1, NaN, 3]; arr.includes(NaN); // true arr.indexOf(NaN); // -1第二includes的语义更清晰。判断“在不在”就用includes需要拿到具体位置才用indexOf。但includes不能处理“对象”的判断。两个内容完全相同的对象在内存里是两份includes比较的是引用而不是结构所以通常只能用find或some去按对象属性判断。const obj { id: 5 }; const arr [{ id: 5 }]; arr.includes(obj); // false arr.some(item item.id 5); // true4.3 find 与 findIndex找到就停天然短路find返回数组中第一个满足回调条件的元素值找不到返回undefined。findIndex返回第一个满足条件的索引找不到返回-1。const users [ { id: 1, name: Alice }, { id: 2, name: Bob }, { id: 3, name: Carol } ]; const bob users.find(user user.id 2); // { id: 2, name: Bob } const bobIndex users.findIndex(user user.id 2); // 1这两个方法的共同特点是“短路”找到第一个满足条件的元素后就停止遍历不会把整个数组走完。这在一开始就能命中的场景里是隐形的性能优化也比filter更合适“只拿第一个”的需求。使用find时要注意如果数组里有元素的值本身就是undefined那么“找到”和“没找到”的返回值都是undefined无法区分。这种情况建议改用findIndex用索引是否大于等于 0 来判断。const arr [1, undefined, 2]; const result arr.find(item item undefined); console.log(result); // undefined但这其实是找到了 const index arr.findIndex(item item undefined); console.log(index); // 1明确知道找到了4.4 reduce一个能把数组变成任何东西的方法reduce是数组方法里功能最强大也是最有门槛的一个。它把数组“归约”成一个单一结果这个结果可以是数字、字符串、对象甚至另一个数组。语法是arr.reduce(callback(accumulator, currentValue, index, array), initialValue)。最简单的例子是求和const nums [1, 2, 3, 4, 5]; const sum nums.reduce((acc, cur) acc cur, 0); // 15acc是上一次回调返回的结果cur是当前元素。如果不传初始值第一次迭代时acc会默认取数组第一个元素cur从第二个元素开始。但空数组不传初始值会直接报错所以保险起见我通常都显式给初始值。除了求和reduce还能做很多事。比如把二维数组展开成一维const matrix [[1, 2], [3, 4], [5, 6]]; const flat matrix.reduce((acc, cur) acc.concat(cur), []); // [1, 2, 3, 4, 5, 6]按某个字段分组const people [ { name: Alice, dept: 前端 }, { name: Bob, dept: 后端 }, { name: Carol, dept: 前端 } ]; const grouped people.reduce((acc, person) { (acc[person.dept] acc[person.dept] || []).push(person); return acc; }, {}); // { 前端: [{ name: Alice }, { name: Carol }], 后端: [{ name: Bob }] }统计元素出现次数const items [a, b, a, c, b, a]; const count items.reduce((acc, item) { acc[item] (acc[item] || 0) 1; return acc; }, {}); // { a: 3, b: 2, c: 1 }很多人觉得reduce难主要是回调里“上一轮结果”这个概念比较绕。我的经验是把acc想象成一个“背包”每次循环往背包里放点东西最后背着包出来。只要记得每一轮都要把背包 return 出去基本就不会断链。但我也要提醒一句reduce不是万能的也不是越用越显高级。如果一段reduce代码阅读起来需要半分钟才能反应过来那不如拆成普通的for...of循环加几个变量可读性永远比炫技重要。5. 组合实战、异步处理与性能避坑5.1 链式调用的典型场景与中间数组问题前面提到filter加map的组合这是数组方法链式调用最典型的例子。链式调用的好处是每一步逻辑都很清楚符合“数据流”的思维习惯。const orders [ { product: 键盘, price: 200, count: 2 }, { product: 鼠标, price: 80, count: 5 }, { product: 显示器, price: 1500, count: 1 } ]; const total orders .filter(order order.price * order.count 300) .map(order order.price * order.count) .reduce((acc, value) acc value, 0);这段代码先筛掉小单再把每单金额取出最后汇总。三行代码表达了三步业务逻辑读起来很顺畅。但要意识到每次filter、map都会生成一个全新的中间数组数据量小的时候完全不用在意数据量级达到几十万条甚至更多时就要考虑是否合并成一次遍历。如果需要性能极致优化可以改用reduce一次搞定筛选、转换和汇总const total orders.reduce((acc, order) { const amount order.price * order.count; return amount 300 ? acc amount : acc; }, 0);这样只遍历一次也不会产生中间数组但可读性稍差。我的建议是默认写链式性能瓶颈真的出现时再优化不要过早优化。5.2 forEach 与 for...of 在异步遍历上的巨大差异这是一个很容易踩大坑的地方。很多人写异步任务时自然地在forEach回调里加上await以为会一个个等完再继续。实际上forEach不会等待异步回调完成它会同步地把所有回调都丢出去然后立刻结束导致后面的代码提前执行。// 错误示范不会串行等待 const ids [1, 2, 3]; ids.forEach(async (id) { await fetchData(id); }); console.log(这行代码会先执行);如果你需要“等一个完成再处理下一个”用for...of配合await才是对的async function processSequentially(ids) { const results []; for (const id of ids) { const data await fetchData(id); results.push(data); } return results; }如果想并行处理所有任务并且等所有任务都完成可以用Promise.all配合mapasync function processInParallel(ids) { const results await Promise.all(ids.map(id fetchData(id))); return results; }这里的关键是理解“并发”和“串行”的区别map回调返回的是 Promise 数组Promise.all并发执行for...of则天然串行。具体用哪一种取决于你的接口设计如果每个请求相互独立、没有依赖并行通常更快如果要按顺序请求、后一个结果依赖前一个就必须串行。5.3 性能差异到底有多大这是老生常谈的话题了。早年确实有很多性能测试表明传统for循环比forEach、map快不少因为数组方法有回调函数的调用开销。但那是在旧引擎和几十万、上百万级数据的测试条件下得出的结论。现代 V8 引擎对常见数组方法做了大量优化差距已经缩小很多。实际开发中真正影响性能的往往不是循环方式而是循环体里做了什么。比如触发了重排重绘、发起了网络请求、做了复杂计算这些操作才是瓶颈。与其纠结用for还是forEach不如先分析循环体内有没有可以避免的高开销操作。如果你想自己做测试可以用performance.now()计时const arr Array.from({ length: 1000000 }, (_, i) i); console.time(for); let sum1 0; for (let i 0; i arr.length; i) { sum1 arr[i]; } console.timeEnd(for); console.time(reduce); const sum2 arr.reduce((acc, cur) acc cur, 0); console.timeEnd(reduce);我实测下来不同浏览器和不同数据规模下结果差异很大。百万级纯数值计算里for通常略快但差距只在个位数毫秒级别如果是更贴近业务的复杂逻辑可读性的优先级应该远高于这点性能差距。5.4 常见问题与避坑速查表下面这些坑是我在写代码和帮别人 review 代码时最常遇到的整理成速查表贴在这里。问题现象原因解决办法map后得到一堆undefined回调里忘记return检查回调确保有返回值forEach里用break报语法错误break只能用在循环语句中改用for...of或some实现提前停止for...in遍历数组顺序不固定它遍历的是属性键不是索引语义数组遍历用for...of或数组方法reduce对空数组报错没传初始值始终提供初始值或在 reduce 前判空includes查不到对象对象比较的是引用而不是结构用find或some按属性判断filter只想要第一个结果返回数组语义用错方法改用find/findIndexforEach里改了原数组遍历次数失控遍历过程中数组长度变化避免在回调里 push/delete 当前数组every对空数组返回true业务不对空数组的数学语义如此先判断length 0再决定业务逻辑reduce回调不返回acc结果变成undefined忘记 return 累计结果每个分支都要把acc返回出去多个异步任务并发执行顺序不可控Promise.all是并发不是串行需要顺序时改用for...of配合await这些坑有的看过一遍就能记住有的要踩一次才能体会。我个人觉得最值得警惕的是“语义错位”某个方法看起来能完成需求但它的设计目标其实不是这个短期能用长期一定埋隐患。回到开头那个问题到底该用哪个方法我现在的习惯是看到一个遍历需求先问自己三个问题我要返回值吗返回的是数组还是单个值中途需要停吗这三个问题一问完答案基本就浮出水面了。数组方法看起来多本质上就是围绕“遍历、转换、筛选、查找、判断、归约”这六类需求设计的用熟悉了之后代码会越写越短但表达的意思反而越来越清楚。如果你刚刚接触这些方法别急着背每个方法的细节先拿一张小数组把map、filter、reduce各写一遍感受一下“输入数组”和“输出结果”的关系比看十遍文档都有用。等你哪一天写链式调用不再卡壳说明你对数据流的理解已经过关了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →