JavaScript数组循环深度解析:从forEach到for...of的性能与选型
2026/9/15 23:30:16 网站建设 项目流程

我接手过一个挺有意思的优化任务:一段在后台任务里处理数据的代码,数据量其实不大,大概二十万条,但每次执行都要卡住两三秒。定位之后发现,问题不在算法,而在循环本身——一个嵌套循环里用forEach对数组做过滤,每次回调还创建了新的闭包。那时候我就意识到,“JavaScript数组循环”这件事,表面上人人都会写,但真要写出又快又稳、还符合工程规范的代码,门道远比你想象的多。

这篇文章我不打算只列API,那没有意义。我会从底层的行为差异讲到真实业务里的选型逻辑,再把我踩过的坑和我做过的性能测试数据全部摊开。不管你是刚入门前端、正在自学JavaScript基础语法,还是已经写了几年业务代码但很少深究循环效率,这篇文章都能给你一些可落地的参考。文章里涉及的代码我会尽量完整给出,有些地方会解释引擎层面的原理,偏底层,但我会用大白话讲清楚。

1. 先搞清楚:JavaScript里到底有几种数组循环,它们各自在解决什么问题

很多新手困惑的根源在于,JavaScript提供给数组循环的工具太多了:老牌的forwhile,ES5时代的forEachmapfilterreduce,ES6带来的for...offor...in、迭代器、生成器。选择太多本身就是难点。所以第一步不是背语法,而是建立一个坐标系——每一种循环方法是为了解决哪个具体问题而存在的。

1.1 索引循环:for、while 与 do...while,为什么它们仍然不可替代

for循环是几乎所有编程语言共有的基础结构,JavaScript里的for长这样:

const arr = [1, 2, 3, 4, 5]; for (let i = 0; i < arr.length; i++) { console.log(arr[i]); }

它的三个表达式——初始化、条件判断、步进更新——给了开发者最大灵活度。你可以在条件里写任意复杂的判断,可以在循环体内改变索引走向,甚至倒序遍历。while则是把条件判断放前面,适合“不知道要循环多少次,只知道什么时候停”的场景;do...while至少执行一次,适合先执行后判断的逻辑。

我在实际开发中体会最深的一点是,当循环次数很大,或者每次循环内部要做的事很复杂时,for循环依然是性能上限最高的选择。原因后面单独讲,这里先记住一个结论:如果你要处理的是几十万上百万的数据,默认先考虑forwhile,没有回调函数带来的额外开销,也没有迭代器协议层层转发的损耗。

另一个容易被忽略的点是:很多人写for循环用let i = 0, len = arr.length; i < len; i++这种写法,把length缓存起来,这在大数组场景下确实能省掉每次取属性的一点点开销。现代引擎其实已经对arr.length做了优化,但这种写法本身没有任何坏处,反而让意图更明确:循环边界在开始时就已经固定。

1.2 函数式遍历家族:forEach、map、filter、reduce,语义优先的时代

ES5带来的Array.prototype方法改变了很多人写循环的方式。核心原因不只是语法糖,而是语义化——代码读起来是在说“我要对每个元素做映射”而不是“我要从0数到length减一”。

  • forEach:对每个元素执行一次回调,像循环一样遍历,但不关心返回值。
  • map:对每个元素执行回调并收集返回值,生成一个新数组。适合做数据变换,一对一映射。
  • filter:根据回调返回值筛选元素,生成一个子集数组。
  • reduce:把数组归约为一个值,回调会接收累加器,适合求和、统计、累积计算。
  • someevery:判断数组是否满足条件,返回布尔值。

它们的共同点是都有回调函数,且回调接收三个参数:当前值、当前索引、原数组。从工程可读性角度,优先使用语义化方法可以让维护者一眼看懂意图。比如:

const prices = [100, 200, 300]; // 要得到每个价格增加20%后的新数组 const newPrices = prices.map(price => price * 1.2);

比写for循环逐个push到新数组,语义清晰得多。我自己写代码时,如果数组体量不大(几千条以内)而且逻辑清晰,会优先用这类函数式方法。它们带来的性能损失在小型数据上几乎感知不到,但代码的可读性和后期维护性远胜手写循环。

1.3 for...of 与 for...in 的分工,很多老前端都容易混淆

for...infor...of虽然长得像,底层逻辑完全不是一回事:

  • for...in遍历的是“可枚举属性名”,也就是对象的键。对数组来说,遍历到的是字符串形式的索引"0"、"1"、"2",同时还会把原型链上可枚举的属性也捞出来。所以拿for...in遍历数组,本身就有设计层面的错位。
  • for...of遍历的是“可迭代对象的值”。数组、字符串、Set、Map、arguments、Generator 都是可迭代的,所以for...of拿到的是循环队伍里的值,而不是键。

一个典型场景是遍历字符串数组:

const words = ['hello', 'world']; for (const word of words) { // 拿到的是 'hello'、'world' console.log(word); }

for...of可以搭配breakcontinuereturn(在函数里)来中断或跳过,这一点比forEach灵活得多。JS引擎在for...of上的性能也已经优化得相当好,在某些引擎版本里甚至逼近原生for。所以ES6之后,如果你的代码风格偏好现代一点,而且需要灵活的流程控制,for...of是个很好的平衡点。

表格总结一下常见循环方法的核心差异:

方法返回值能否中断/跳过适用场景性能参考
forbreak / continue / return高性能、复杂索引逻辑最优
while / do...whilebreak / continue / return条件驱动循环最优
forEach不能 break,return 仅跳过当前回调简单遍历
map新数组不能中断数据映射
filter新数组不能中断按条件筛选
reduce任意值不能中断归约聚合
for...ofbreak / continue / return现代写法、可中断遍历
for...inbreak / continue / return遍历对象键不建议用于数组

2. 性能差异不是玄学:从引擎层面看for循环、forEach和for...of的差距

性能对比,如果只是人云亦云地说“for循环最快”,那没有任何说服力。我下面从两条线去拆解:一条是实际的基准测试结果,另一条是引擎执行这些循环时背后发生的事。

2.1 一次真实的基准测试:在我的机器上,数据长什么样

最近我专门写过几个测试文件,用performance.now()去测不同循环方式遍历大数组的耗时。测试环境是Chrome最新稳定版,数据量是一百万条纯数字数组,每次测试做10轮取平均。

测试结果大概如下(每次跑会略有浮动,但量级差是稳定的):

循环方式一百万条数据耗时(约)
for 缓存 length5ms
for 不缓存 length6ms
while5-6ms
for...of8-10ms
forEach12-15ms
map(创建新数组)15-20ms

那差距大吗?对一百万数据来说,forEachfor慢了两三倍,但绝对数字也就差了不到10毫秒。所以如果你的数组就几百上千条,纠结性能差异纯属浪费时间。真正要关注性能,往往是在渲染层、大文件解析、日志处理、图像像素遍历这类高密度场景。

有意思的是,for...of在大多数情况下并不比forEach慢多少,有时候甚至更快。因为它不需要每次回调都创建一个新的函数调用栈,只是走一下迭代器协议取值,然后循环体直接在当前作用域执行。这个我在2.2节展开讲。

2.2 函数调用开销:为什么forEach会慢

forEach的核心开销有三个:

第一,每次迭代都会调用回调函数。函数调用不是免费的,它需要创建对应的执行上下文,处理参数绑定,结束后销毁上下文。如果回调里再写闭包引用外部变量,引擎还需要维护作用域链。百万次函数调用的累计成本,远远高于普通循环里的几次属性访问。

第二,回调里的this和参数绑定forEach的第二个参数可以指定回调里的this指向,引擎需要额外的绑定逻辑。而箭头函数虽然没有自己的this,但它创建闭包访问外层this,同样有作用域解析成本。

第三,回调函数本身不容易被JIT深度优化。像for循环这种单层结构,V8的TurboFan可以把它优化为非常精简的机器指令序列;而forEach每次回调调用相当于一次间接跳转,优化器要保守一些,内联(inlining)的难度更大。

换句话说,for循环是“光着膀子跑”,forEach是“穿了两层外套跑”。这个比喻虽然糙了点,但原理差不多。

2.3 迭代器协议:for...of为什么没有想象中那么慢

ES6的for...of之所以没有被打上“超级慢”的标签,是因为你在代码里写for (const item of arr)时,引擎先拿到数组的迭代器,然后循环调用迭代器的next()方法取到值。理论上这是三层结构:循环控制、迭代器对象、数组内部。早期实现里这确实很慢,但现代引擎做了大量优化。

V8在TurboFan里会把常见的数组for...of模式“去糖化”——识别出你遍历的是一个普通数组,直接优化成类似for索引循环的机器码,省去迭代器创建和next()调用的开销。所以你会看到,在实际测试中for...of在大多数现代浏览器里已经很接近for循环了。

不过话说回来,这只是针对数组,如果for...of遍历的是SetMap或者自定义迭代器对象,那就没法做这种优化,性能会明显下降。所以在高性能路径上,直接操作数组时for仍然是最保险的选择。

2.4 隐藏类、元素种类和预优化:一个容易被忽略的方向

V8中对象和数组都有“隐藏类”和“元素种类”的概念。数组如果从一开始就是整数数组,引擎可以按PACKED_SMI_ELEMENTS来存储,访问是最快的;如果在循环中突然往数组里塞一个字符串或者对象,元素种类退化,后续所有访问速度都会下降。

所以“循环内不要顺便改变数组结构”这条建议是有底层依据的。常见的不良写法是:

const list = [1, 2, 3]; list.forEach(item => { if (someCondition) { list.push(item * 2); // 循环里改数组长度 } });

这样不仅容易造成死循环,还会让引擎对数组的优化失效。如果确实要动态扩展结果,建议先把结果保存在一个新数组里,循环结束再合并。

3. 真实业务场景下的循环选型:组合打法比单一方法可靠

聊完基础和方法论,接着回到实际开发。业务里的数据不会像教科书那样规整,反而是各种嵌套、异步、对象数组混合的情况。这里我会按场景拆开讲,每个场景给出我认为最合适的写法。

3.1 大数组渲染与数据处理:把循环的“火车”开好

在数据可视化或者表格渲染场景里,经常要把几万条数据批量处理成渲染节点。比如要把经纬度坐标数组从数组结构转成类字符串结构:

// 原始坐标数组,类似 [[116.40, 39.90], [121.47, 31.23]] // 需要拼接成 "116.40,39.90 121.47,31.23" const coords = [[116.40, 39.90], [121.47, 31.23]]; let result = ''; for (let i = 0; i < coords.length; i++) { result += `${coords[i][0]},${coords[i][1]} `; } result = result.trim();

这种做法在几万条数据下依然很快。反过来,如果你用forEach加模板字符串拼接,性能差距在这个场景下就会比较明显。另一个建议是:如果要做的是批量变换且需要新数组,优先map,不要写“空数组加for循环push”。如下所示:

// 推荐 const scaled = points.map(p => ({ x: p.x * scale, y: p.y * scale })); // 不推荐 const scaled = []; for (let i = 0; i < points.length; i++) { scaled.push({ x: points[i].x * scale, y: points[i].y * scale }); }

单纯看性能差距很小,但map写法更具声明性,别人读代码时一眼就能确定“它不会修改原始数组,只负责生成新数组”。

3.2 异步循环:为什么在forEach里写await是无效的

这是一个特别常见的坑,而且很多有几年经验的前端都会踩。问题在于forEach拿到回调函数后,根本不管你是不是异步函数,它不会等待你返回的Promise。所以下面这段代码的执行顺序会乱掉:

const urls = ['/api/a', '/api/b', '/api/c']; urls.forEach(async (url) => { const data = await fetch(url); console.log(data); }); console.log('end'); // 会先执行

这里forEach同步地启动了三个异步请求,但不会等它们全部完成。如果你需要按顺序串行请求,用for...of配合await

for (const url of urls) { const data = await fetch(url); console.log(data); }

因为for...of循环体本身就是普通语句块,await可以直接使用,在循环内等待异步完成后再进入下一次迭代。如果你要并行发起请求,再统一等结果,用Promise.all配合map更合适:

const results = await Promise.all(urls.map(url => fetch(url).then(res => res.json())));

为什么是map而不是forEach?因为map返回一个新数组,收集了每个回调的返回值。这里的回调是async函数,返回值本身就是Promise,所以Promise.all可以接收。

3.3 对象数组的合并、去重与查找:从O(n²)到O(n)

业务里最常遇到的操作之一,是有两个对象数组,要根据某个ID字段做合并或去重。常见但低效的写法是两层循环:

// 时间复杂度 O(n*m) const merged = []; for (const itemA of listA) { const match = listB.find(itemB => itemB.id === itemA.id); if (match) { merged.push({ ...itemA, ...match }); } }

如果listAlistB都有几千条,这里就几十万甚至上百万次比较。更高效的做法是先建一个“查找表”——用普通对象或者Map做索引,把查找从O(n)降为O(1):

const mapB = new Map(listB.map(item => [item.id, item])); const merged = listA .filter(item => mapB.has(item.id)) .map(item => ({ ...item, ...mapB.get(item.id) }));

第一步用maplistB转换成Map,花费O(n);第二步遍历listA,每次查找Map是O(1),整体复杂度O(n)。当数据量上涨时,这个差距是质的差别。

另外一个跟对象相关的操作是合并两个对象数组,去掉重复项。最朴素的思路是维护一个Set存ID:

const seen = new Set(); const uniqueList = []; for (const item of [...listA, ...listB]) { if (!seen.has(item.id)) { seen.add(item.id); uniqueList.push(item); } }

这种写法的意图很明确:用Set记住已经见过的ID,遇到新的才放进结果数组。尽量避免在循环里用filter嵌套造成平方复杂度。

3.4 事件监听里的循环:监听函数绑定的老问题

还有一个热词是“javascript监听”,很多新人用循环绑定事件时会遇到一个经典问题:循环变量被事件回调捕获时,拿到的往往是循环结束后的值。比如这样:

const buttons = document.querySelectorAll('button'); for (var i = 0; i < buttons.length; i++) { buttons[i].addEventListener('click', function () { console.log(`点击了第${i}个按钮`); // 全是最后一个 }); }

原因是var声明的i是函数作用域,事件回调执行时i已经变成了buttons.length。解决方案有三个:用let声明循环变量,或者用闭包绑定固定值,或者利用事件对象的属性。这里最推荐的写法是:

for (let i = 0; i < buttons.length; i++) { buttons[i].addEventListener('click', function () { console.log(`点击了第${i}个按钮`); }); }

let每次循环都会创建独立的词法环境,回调捕获的是当次迭代的i。这种代码改动最小,语义也最清晰。再强调一次:循环里绑定事件监听,优先用let

4. 高频踩坑实录:数组循环里的那些隐蔽问题

这部分我想用自己的踩坑经历来写。这些坑有一个共同点:代码语法完全没问题,不报错,但结果不对,或者性能悄然劣化。

4.1 稀疏数组:forEach会跳过,for循环不会

JavaScript数组的“稀疏”指的是数组中存在空位,比如:

const arr = [1, , 3]; // 中间是 empty 而不是 undefined const arr2 = new Array(5);

forEachmapfilter等函数式方法在遍历时,会跳过这些空位。这就可能导致一个诡异现象:你用map生成的数组长度对,但某些位置是空的,后面对它们做处理时拿到undefined

forfor...of则不会跳过,它们会把空位当作普通位置处理,访问到的是undefined。以前我处理一个上传文件列表时,因为某个位置的文件对象被splice掉后没有重新整理数组,再用forEach遍历统计总大小,发现算出来少了一截。排了半天才发现是稀疏数组的坑。

所以,当你对数组做删除操作、清空操作,或者用new Array(n)占位时,要格外小心。如果希望数组始终稠密,可以用Array.from或者展开运算符来“压实”:

const dense = [...sparseArr]; // 展开运算符会把 empty 转为 undefined

4.2 循环里的闭包捕获:var 和 let 的差别不只是作用域

这个坑在3.4已经提过,但它在一般数组循环里的危害更大。比如用forEach循环往Promise队列里塞任务:

const tasks = []; for (var i = 0; i < 10; i++) { tasks.push(function () { console.log(i); }); } tasks[3](); // 输出 10,不是 3

改成let之后:

const tasks = []; for (let i = 0; i < 10; i++) { tasks.push(function () { console.log(i); }); } tasks[3](); // 输出 3

这里不只是“抄作业式”地用let,理解背后的原理更关键:var只有函数级作用域,所有迭代共享同一个变量;let是块级作用域,每次迭代都会新建绑定。当回调被推迟执行(事件、定时器、异步任务)时,let捕获的是各自迭代的值,var捕获的是最终值。

4.3 中断和跳过:forEach没有break,怎么提前退出

forEach不支持breakcontinue,这在遇到“满足条件就停止遍历”的需求时很让人头疼。你可能会想到在回调里用return,但它只会跳过当前元素,不会终止整个循环。一个错误的示范:

const arr = [1, 2, 3, 4, 5]; arr.forEach(item => { if (item > 3) return; // 只是跳过大于3的元素,并不是终止 console.log(item); // 1 2 3 });

如果真的需要提前退出,从语义上就应该选择可中断的循环。有两个方向:一是改用for...ofbreak,二是利用someevery的特性。some在回调返回true时停止遍历,every在回调返回false时停止遍历。比如判断数组里是否存在满足条件的元素:

const hasLarge = arr.some(item => item > 1000);

这比filter(...).length > 0高效,因为filter会全量跑完并生成新数组,而some会在找到第一个满足项后立即退出。这个细节在数据量大的时候收益很可观。

4.4 循环内修改数组结构:越界、漏项、死循环

在循环中向数组追加数据、删除数据、替换数据,都可能让循环失控。比如典型的死循环:

const list = [1, 2, 3]; for (let i = 0; i < list.length; i++) { if (list[i] === 2) { list.push(4); // length 一直在变 } }

还有删除当前项导致的漏项:

const list = [1, 2, 2, 3]; for (let i = 0; i < list.length; i++) { if (list[i] === 2) { list.splice(i, 1); // 删掉后索引没回退,会漏掉后面一个2 } }

这种代码在数据规整时可能碰巧没问题,一旦出现连续重复项就出bug。我自己的习惯是:需要边遍历边删除的,要么倒序遍历,要么先收集要删除的索引,最后统一删除。倒序遍历因为索引递减,删除当前项不会影响前面的位置:

for (let i = list.length - 1; i >= 0; i--) { if (list[i] === 2) { list.splice(i, 1); } }

4.5 运行时报错:Failed to load module script 之类的常见误读

还有一个热词是“failed to load module script: expected a javascript module script”,这个报错本质和数组循环无关,但和JavaScript运行环境有关。它通常出现在<script type="module">加载ES模块失败时。有人会在for循环里动态创建<script>标签加载模块,结果因为CORS、MIME类型或路径问题一直报错。

这不是循环方法的错,而是模块加载机制问题。如果你的循环里做了类似动态加载的操作,请优先确认脚本路径、服务器MIME类型(必须是text/javascript)以及CORS头,别把时间浪费在优化循环上。

5. 从会用代码到写出好代码:我的循环优化经验

最后这部分不谈语法,谈谈我在代码审查和团队协作中反复看到的问题,以及我现在写字面意义上的“好循环”的一些习惯。这些经验比单纯记住API更值钱。

5.1 循环里的代码职责要单一

一个循环只做一件事。这话听着空,但做起来不容易。比如在同一个forEach里既过滤数据、又改DOM、还上报埋点,一旦哪里出问题,查起来非常费劲。更好的做法是拆成几步:

const validItems = data.filter(item => item.isValid); const viewModels = validItems.map(item => toViewModel(item)); viewModels.forEach(item => renderItem(item));

虽然多跑了一两遍,但每一遍职责清晰,且中间产物可调试、可单元测试。性能上,如果没有达到几十万数据量,这点额外遍历损耗完全可接受。

5.2 循环体保持“纯”,尽量避免外部副作用

副作用指的是修改外部变量、操作DOM、发起网络请求等行为。循环体内副作用越多,越难测试,也越容易被并发或时序问题搞崩。理想状态下,循环只是在做“输入到输出”的变换。当然,写渲染代码时不可避免地要操作DOM,这时尽量把要渲染的数据先准备好,循环外再统一执行DOM操作,减少回流和重绘次数。

渲染大量列表项时,一个实用技巧是使用DocumentFragment

const fragment = document.createDocumentFragment(); for (const item of list) { const li = document.createElement('li'); li.textContent = item.name; fragment.appendChild(li); } listContainer.appendChild(fragment);

这比循环里每次appendChild到容器要好很多,因为减少了对真实DOM的频繁修改次数。

5.3 用 HBuilder 这类工具开发时的调试建议

如果你是用HBuilder、VS Code这类编辑器做前端开发,调试循环代码时可以多用调试器,而不是到处console.log。断点打在循环体内,每一轮迭代你都能看到索引、当前元素、局部变量的实时状态,排查数组越界或漏项问题会轻松很多。尤其处理几十万条数据时,千万别在循环里写console.log,控制台会直接卡死。

另外,Chrome开发者工具的Performance面板可以录制一段脚本执行,查看哪些函数占用了大量时间。我以前优化一个数据导入功能时,就是用Performance定位到forEach回调里的一个隐藏的JSON序列化操作,那个操作本身比循环还慢好几倍。

5.4 一次真实优化项目的复盘

最后复盘我开头提到的那个案例。原始代码大致是:

list.forEach(a => { const match = otherList.filter(b => b.id === a.id); if (match.length) { result.push({ ...a, ...match[0] }); } });

问题很明显:otherList在每次迭代里都被完整遍历一遍。我把otherList先转成Map,然后用mapfilter重写,再优化了循环内的对象展开逻辑,最终相同数据量下的耗时从2.3秒降到60毫秒左右。这个量级提升不是微调能带来的,而是算法复杂度从O(n*m)降到O(n)的结果。

所以当你觉得循环慢时,先别急着纠结用forEach还是for,先看循环内部的查找、遍历、重复计算是不是有优化空间。结构上的优化永远大于语法上的微调。

6. 小结不是总结:几件我希望你带走的事

我不打算做什么句式整齐的总结,就说几个我发自内心的建议。

第一,不要因为for循环性能好就处处用它,也不要因为函数式写法优雅就处处用它。选型依据是数据量和语义清晰度。几千条以内,按可读性优先;几十万条以上,按性能优先;遇到异步逻辑,按流程控制需求优先。

第二,把mapfilterreducesomeevery的返回值和应用场景刻在脑子里,它们不是forEach的替代品,而是各有定位。用一个数组循环方法之前,先问一句“我需要它返回什么”,答案会帮你选对方法。

第三,我强烈建议你找一个周末,把今天列举的循环方式写成测试脚本,在本地跑一遍performance.now()对比。这种体感测试会让你记住“量级差距”这件事,远远比别人告诉你有用。

第四,数组循环是JavaScript里最基础的话题,也是几乎所有业务代码都会碰到的话题。把基础吃透、熟悉边界情况,后面学习ES6的迭代器、生成器、异步编程都会顺畅很多。如果这篇文章帮你解决了一个具体的bug,或者让你的某个函数快了一毫秒,那它就算没白写。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询