JavaScript数组遍历全指南:从for循环到异步遍历的实践与避坑

发布时间:2026/9/30 1:28:51
JavaScript数组遍历全指南:从for循环到异步遍历的实践与避坑 数组遍历这事儿看着基础但每次写代码都躲不开。无论是把接口数据拉回来做渲染还是处理表格勾选、批量更新状态本质上都在和“把一个数组里的元素逐个过一遍”打交道。我见过不少朋友一上来就是for循环到底也见过有人把所有遍历都交给forEach结果遇到需要中断循环的需求时傻了眼。这篇东西我打算把JS里遍历数组的常用方式完整捋一遍顺带把中断、异步、性能、类数组这些容易踩坑的角落也翻出来说清楚希望对新手友好也让写过几年代码的朋友能查到点平时不太注意的细节。1. 数组遍历前必须搞懂的三件事很多人写了很久的JS但对数组本身的理解其实停留在“一个存了一堆东西的列表”这个层面。如果不把数组的几个底层特性搞清楚后面玩遍历的时候很容易出现“为什么索引不对”“为什么遍历到了undefined”“为什么改了没反应”这类灵异问题。1.1 数组本质上是对象length属性是活动的JS里的数组并不是像C语言那样在内存里开一段连续的固定空间它本质上是一种特殊的对象。键名是数字字符串索引值就是数组元素另外还有一组专属于数组的方法和属性。最关键的属性就是length它不是只读的你往里面塞元素、删除元素它都会跟着变。你甚至可以手动给length赋值来截断数组const arr [1, 2, 3, 4, 5]; arr.length 3; console.log(arr); // [1, 2, 3] arr.length 6; console.log(arr); // [1, 2, 3, 3 empty items]这个行为对遍历方式的选择有直接影响。比如用for循环遍历时循环条件写成i arr.length每次循环都会重新读一次length。如果在循环体里修改了数组长度很可能造成死循环或者跳过元素。提示如果数组规模可能很大通常建议把length缓存到变量里写成for (let i 0, len arr.length; i len; i)避免每次循环都访问属性带来的开销。不过在现代JS引擎的优化下这个差距已经非常小更多是习惯问题。1.2 稀疏数组有洞的数组并不罕见稍微冷门一点的知识是JS数组允许“稀疏”状态也就是数组中某些索引位置没有元素形成一个洞。比如new Array(3)并不会创建[undefined, undefined, undefined]而是创建一个长度为3但没有任何元素的数组三个洞。更常见的产生方式是直接给数组中间某个索引赋值或者用delete删掉某个元素const arr [1, 2, 3]; delete arr[1]; console.log(arr.length); // 3 console.log(1 in arr); // false索引1位置是个洞传统的for循环遇到这种洞会得到undefined而forEach这类方法会完全跳过空洞map也会保留洞但返回的数组里也是洞for...of则会为洞输出一次undefined。这些差异在你遍历“看起来正常”的数组时不会暴露但一旦数据是后端拼接出来的或者经过了某些删除操作就容易踩出怪异行为。1.3 类数组对象不是数组但经常要遍历日常开发中还会遇到一类“看着像数组但不是数组”的对象arguments就是最典型的例子DOM查询得到的NodeList老版本浏览器中也是类数组对象。它们有length和数字索引但没有forEach、map、filter这些数组方法。想要遍历它们通常需要先借用数组方法或者通过Array.from()、扩展运算符等方式转换成真正的数组。function foo() { // arguments 是类数组对象 const args Array.from(arguments); args.forEach(item console.log(item)); }理解了数组的本质、稀疏特性和类数组我们才能真正谈“遍历”。因为不同的遍历方式在这三种场景下的行为差异非常大选错了轻则输出不符合预期重则直接报错。把底子打好后面的部分才能看明白为什么我要在特定场景推荐某种写法。2. 几种主流遍历方式逐一拆解本节是重头戏。我会把从上古时代到ES6时代的所有常用数组遍历方式拿出来逐个说明每种方式会给出基本写法、返回值、能否中断、对稀疏数组的处理、以及性能特点。你不用一次性全记住但至少要知道工具箱里有哪些工具各有什么脾气。2.1 经典for循环最大众也最可控经典for循环是大多数开发者最早接触的遍历方式也是我在强调性能和可控性时最先想到的写法。它的自由度是所有遍历方式里最高的循环变量自己控制条件自己写步进自己定循环体里想干什么就干什么想break就break想continue就continue想修改数组元素也极为直接。const arr [10, 20, 30, 40]; for (let i 0; i arr.length; i) { if (arr[i] 30) { break; // 跳出循环 } console.log(arr[i]); }很多人不知道的是经典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); // 安全删除偶数 } } console.log(arr); // [1, 3, 5]for循环配合continue可以跳过某次迭代配合break可以整个退出这是forEach做不到的。如果追求性能最大化for循环通常也是首选因为它的实现机制最简单不涉及额外的函数调用栈。注意如果数组存在稀疏洞for循环不会跳过洞arr[i]的值是undefined。如果你需要完全跳过空洞这个特性需要自己判断。2.2 forEach语义清晰但限制多forEach是数组方法中专门用于遍历的一个。ES5时代被大量使用现在仍然是很多项目的首选。它的最大优点是语法简洁不需要管理索引变量代码读起来就像是在说“对数组里的每一项做这件事”。const arr [1, 2, 3]; arr.forEach((item, index, array) { console.log(item, index); });回调函数里接收三个参数当前元素、当前索引、原数组。这一点很常用尤其是当你在遍历过程中还需要拿到“这是第几个”的时候。第三个参数array有时你会想知道当前遍历的是哪个数组或者想在回调里拿到原数组做其他处理。但是forEach有三道紧箍咒不能中断break和continue在forEach里是语法错误return只能跳过当前这次回调相当于continue无法终止整个遍历。想提前结束没有官方机制。性能相对偏慢因为每次迭代都会产生一次函数调用在数据量极大比如几十万条以上的场景下跟for循环有肉眼可见的差异。“肉眼可见”对大多数业务来说不重要但知道差异能帮你做判断。对稀疏数组会跳过空洞[1, , 3].forEach(...)只会调用两次回调中间的空洞直接被忽略。这有时候是好事有时候会让人困惑。[1, , 3].forEach(item { console.log(item); // 输出 1 3 });如果你确实需要“中断”forEach业内有一个不太优雅但有效的打法借助every或者some方法。every在回调返回false时终止遍历some在回调返回true时终止遍历。这两个方法本来用于逻辑判断但反过来利用它的短路机制也能实现中断效果。const arr [1, 2, 3, 4, 5]; arr.every(item { console.log(item); // 1, 2, 3 if (item 3) { return false; // 相当于 break } return true; });这种写法在旧项目里能见到但从语义上不够直观。ES6有了for...of之后真正需要中断遍历的场景我建议优先用for...of。2.3 for...ofES6时代的中断利器for...of是ES6引入的遍历迭代器协议的新语法。它的写法非常干净像是“把数组里的每个值拿出来用”配合break和continue完全无障碍这也是我在需要中断时首选的遍历方式。const arr [a, b, c]; for (const value of arr) { if (value b) break; console.log(value); // a }for...of可以直接拿到数组元素的值想要索引的话需要借助entries()方法for (const [index, value] of arr.entries()) { console.log(index, value); }for...of不只是数组的专属语法它适用于所有实现了迭代器协议的对象包括字符串、Set、Map、NodeList等。这就带来了一个额外的好处兼容类数组对象。arguments虽然不能直接用map但可以被for...of正常遍历因为arguments也是可迭代对象。这在某些场景下省掉了Array.from()的转换步骤。不过要注意for...of对稀疏数组的行为和forEach不同。它会为空洞输出一次undefined因为迭代器协议会先检查长度再按索引取值洞的位置取值结果是undefined。const sparse [1, , 3]; for (const value of sparse) { console.log(value); // 1, undefined, 3 }这个特性很容易成为隐蔽的bug来源特别当你以为数据是连续紧凑的时候。每次遍历时先确认数据是否可能存在空洞如果你希望“跳过空缺”那for...of不是合适的选择如果你希望“补全为undefined”那for...of很合适。2.4 for...in用错了地方就是坑严格说for...in不是为数组设计的遍历工具。它是来遍历对象可枚举属性的也就是说它会遍历数组的所有可枚举属性名包括非数字键和在原型链上扩展的属性。Array.prototype.customProp hello; const arr [10, 20, 30]; arr.foo bar; for (const key in arr) { console.log(key); // 0, 1, 2, foo, customProp }输出结果里不光有索引0、1、2还有我手动添加的foo属性和挂到原型上的customProp。这就是为什么几乎所有的JavaScript风格指南都会明确“不要用for...in遍历数组”。它的顺序在大多数情况下是按数字索引升序但一旦出现非数字可枚举属性顺序就变得不可预测。唯一的适用场景是遍历对象时配合hasOwnProperty过滤掉原型链属性for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { // 安全遍历对象自身属性 } }数组遍历忘掉for...in这个结论可以直接记下来。2.5 map方法不只是遍历还要产出新数组map和forEach都是遍历数组的方法但map的核心语义是“映射”对原数组的每个元素做一次变换返回一个等长度、一一对应的新数组。原数组保持不变。这个特性让map特别适合数据加工场景比如把后端返回的用户对象数组映射成只需要id和name的轻量数组const users [ { id: 1, name: 张三, age: 28 }, { id: 2, name: 李四, age: 32 } ]; const summary users.map(user ({ id: user.id, name: user.name }));注意map返回的新数组长度一定等于原数组长度稀疏数组中的空洞会被保留为空洞。如果你在回调里写了分支判断没有返回值的位置会得到undefined这往往是bug的来源。map和forEach最大的区别就在于返回值。forEach用于“遍历并执行副作用”map用于“遍历并返回新数据”。一定要按语义来选择否则代码风格会很混乱。相关的衍生方法也值得顺便掌握filter负责筛选出符合条件的元素组成新数组reduce负责把数组归约为一个值可以是数字、对象、数组等任意类型some判断是否有至少一个元素满足条件every判断是否所有元素都满足条件。它们都是基于“遍历”这一基础能力扩展出来的工具本质上是把常见的遍历逻辑封装成了声明式API。const nums [1, 2, 3, 4, 5]; const even nums.filter(n n % 2 0); // [2, 4] const sum nums.reduce((acc, n) acc n, 0); // 15 const hasBig nums.some(n n 4); // true const allSmall nums.every(n n 10); // true这些方法几乎可以替代大部分的for循环逻辑且让代码读起来更接近自然语言。但同样有“不能中断”的限制所以不能把条件判断和累加逻辑无限写复杂否则建议回到for或for...of。3. 遍历中绕不过去的执行机制问题这一节聊三个所有写数组遍历的人几乎都会遇到的问题怎么在遍历中中断怎么处理异步任务到底哪种方式性能好这三个问题的答案会直接决定你在真实业务中怎么选型。3.1 break和return的各归各位先说结论需要在遍历中间退出用for循环配合break或者用for...of配合break。需要在一次迭代中提前跳到下一次用continuefor和for...of都支持。forEach不支持break、continue回调里的return相当于continue。非要在forEach里中断可以改用some/every的短路机制但不推荐作为常规操作。实际开发中需要中断的场景非常多。比如后端返回了一万个用户数据你要找到第一个符合“会员等级大于等于5且最近一周活跃”的用户找到了就不再往下遍历。用forEach的话你只能设一个flag变量来标记是否已经找到再在每次回调开头检查既不优雅又浪费计算。用for...of一行break搞定。let target null; const users getUsers(); for (const user of users) { if (user.level 5 user.active) { target user; break; } }这个例子非常经典建议所有初学者都记住。3.2 异步遍历串行和并行的取舍另一个高频问题是在循环里发异步请求。比如你要批量获取用户详情然后处理后端返回的数据。这里有两种典型需求一种是串行按顺序一个个请求第二个请求必须等第一个请求完成之后才能发出。这种场景用for...of加await非常合适async function processUsers(ids) { const results []; for (const id of ids) { const user await fetchUserById(id); results.push(user); } return results; }for...of天然支持await因为await在每次迭代中会等待Promise完成循环才会继续。这一点forEach做不到。如果你在forEach回调里写await由于回调不是async函数的话会直接报语法错误改成async函数的话forEach又不会等待Promise执行完循环会立刻跑完导致后面的代码拿到空数据或并发失控。// 错误示例 async function wrongProcess(ids) { const results []; ids.forEach(async id { const user await fetchUserById(id); results.push(user); }); return results; // 这里返回时请求很可能还没完成 }另一种是并行所有请求同时发出最后一起等结果。这种场景用map配合Promise.all最高效async function fetchAllUsers(ids) { const userPromises ids.map(id fetchUserById(id)); return await Promise.all(userPromises); }这里map把每个id映射成一个PromisePromise.all并行执行所有请求并等待全部完成。如果其中一个请求失败并且你需要整体失败直接抛出即可如果想单个捕获错误可以在map回调里先catch。选择串行还是并行取决于后端接口的承载能力和你对数据顺序的要求。串行安全但慢并行快但可能对服务端造成压力。3.3 性能对比数据量不同结论不同关于性能我要说一个很多人容易误解的点在现代浏览器引擎V8等的强优化下不同遍历方式在常规业务数据量几千条以内上的差距几乎可以忽略。真正拉开差距的场景是十万、百万级的大数组。我实测过的经验基于Chrome数据量一百万条纯数值数组耗时取多次平均值经典for循环最快耗时约为基础值for...of比for循环慢约5%~15%但差距在毫秒级forEach比for循环慢约10%~30%map比for循环慢约10%~25%因为它还要创建新数组for...in慢到离谱比for循环慢一个数量级不建议使用为什么会有这种差异for循环就是底层的索引访问加条件跳转没有额外函数调用forEach和map每次迭代都要创建一个函数调用栈开销自然更高for...of虽然也是迭代器协议但优化较好的引擎已经能把这个开销压到很低。关于是否要把arr.length缓存到变量里在小数组场景下没有任何必要在大数组场景下收益也微乎其微因为现代引擎会内联缓存属性读取。我不会建议你为了性能写出难以阅读的代码。性能优化永远要建立在真实瓶颈上而不是凭空猜测。提示如果你真的在遍历过程中发现性能问题首先要检查的是是否在循环体里做了重计算、有大对象复制、有DOM操作。这些远比“用for还是forEach”的影响大得多。优化应先从算法和副作用下手。4. 实战场景中的选型指南与踩坑记录前面说了很多机制和原理最后落到实际具体业务里到底该怎么选我整理了这些年踩过和见过别人踩过的坑做成一张速查表和一些典型问题记录方便你直接对照使用。4.1 选型速查表场景推荐方式理由简单数据转换得到新数组map返回新数组语义清晰需要筛选符合条件的元素filter专门干这事需要统计、聚合、累加reduce归约为任意值仅需要逐个执行副作用如打印、发通知forEach简洁直接需要中途跳出循环for...of或for支持break需要根据条件跳过部分迭代for...of或for支持continue需要倒序遍历for倒序循环可以安全删除元素需要索引和值同时使用for或for...of entries()灵活可控处理类数组对象如arguments、NodeListfor...of或先Array.from()省去类型转换的麻烦大量数据且性能敏感for最快可控性最强这张表不是绝对的但它覆盖了绝大多数日常场景。我特别想强调“语义”两个字代码不只是写给机器看的更是写给下一个接手你代码的人看的。你用map的地方读者会默认你要产出新数组你用forEach的地方读者知道你在执行副作用你用for...of的地方读者会寻找break或continue。按照语义写代码能让团队协作顺畅很多。4.2 常见问题与排查技巧实录4.2.1 遍历中修改原数组导致索引错乱在for循环里用splice删除元素最常遇到的bug就是“删一个跳一个”。原因很简单删除元素后后面的元素会往前移动一位但循环变量i已经递增了所以跳过了下一个元素。解决方案要么倒序遍历要么在删除后手动i--。我在前面倒序遍历的例子已经展示过一种做法这里再补一个正序遍历加手动修正索引的写法const arr [1, 2, 2, 3, 4]; for (let i 0; i arr.length; i) { if (arr[i] 2) { arr.splice(i, 1); i--; // 删除后修正索引 } } console.log(arr); // [1, 3, 4]4.2.2 误用for...of遍历对象for...of只能遍历可迭代对象。直接对一个普通对象使用会直接抛出TypeError: obj is not iterable。这个错误信息非常常见。如果你想遍历对象属性应该用Object.keys()、Object.values()或Object.entries()。const obj { a: 1, b: 2 }; for (const [key, value] of Object.entries(obj)) { console.log(key, value); }4.2.3 数组去重时Map的妙用提到数组操作绕不开“去重”这个经典话题。ES6之后最简单的去重方式是Set但如果碰到“根据对象某个字段去重”的需求Map配合遍历就非常实用const users [ { id: 1, name: 张三 }, { id: 2, name: 李四 }, { id: 1, name: 张老三 } ]; const uniqueUsers Array.from( new Map(users.map(user [user.id, user])).values() );这里的逻辑是把每个用户映射成[id, user]的键值对放入Map后出现的相同id会覆盖先出现的最后用Array.from取其values()来转回数组。整个过程一次遍历加一次转换清晰高效。4.2.4 数组转字符串时隐式遍历踩过的一个小坑直接用String(arr)或arr 会把数组元素用逗号拼接但如果数组里有洞空洞会被当成空字符串处理。join()也有类似行为。如果需要精确控制空值的转换不要依赖隐式行为先通过map处理一遍再拼接const arr [1, null, 3]; const str arr.map(item item ?? 0).join(-); // 1-0-34.2.5 NodeList遍历时的兼容性在现代浏览器中NodeList已经支持forEach但在某些旧环境里还是会报错。一个稳妥的做法是先转换const nodes document.querySelectorAll(.item); Array.from(nodes).forEach(node { // 处理节点 });如果你已经在用for...of则完全不用关心NodeList是否支持forEach因为它本身就是可迭代的。这些坑并不复杂但实际工作里出现频率不低。每次排查这类问题我都有一个习惯先打印一下“这个数组到底是什么”再确认length到底是多少然后才进入遍历逻辑的排查。很多bug不是遍历方式写错了而是数据本身和预想的不一样。调试的第一步永远是确认数据形态。5. 从“能跑”到“好用”的建议最后分享一点我自己的心得体会。数组遍历作为一个基础话题很多人觉得写出来就能跑不需要追求什么。但实际维护项目久了你会发现代码的可读性、性能边界、语义清晰度这些都是真实影响开发效率的因素。我现在的习惯是默认用for...of来写需要中断或跳过的遍历用map、filter、reduce来处理数据变换只有对性能有极致要求的热点路径才会回到经典for循环。这个习惯不是天生的是看了太多同事写的代码在半年后被接手者痛苦地修改之后总结出来的。过程中你还会摸索到不少小技巧。比如利用Array.from({ length: n }, (_, i) i)快速生成连续数值数组这在造测试数据时非常方便比如用flatMap一次性完成“展开再映射”的双重操作避免先map再flat的两遍遍历再比如用reduce把二维数组拍平用at(-1)安全取最后一个元素而不修改原数组这些都是在“遍历”这个主题下延伸出来的细碎知识点积累了就会形成肌肉记忆。如果你刚接触这些内容不必急着把所有方式都背下来。先把for、forEach、for...of、map这四种真正用熟搞清楚它们的返回值和中断行为剩下的方法会在你遇到具体问题时自然想起来。多在不同场景里写一写比单纯看十篇总结都管用。真到写代码的时候脑袋里能立刻出现“这个场景应该用哪个方法为什么”这篇内容就算没白看。