拓冰建站拓冰建站
首页 / 资讯中心 / 正文

JavaScript数组循环深度解析:从forEach到for...of的性能与选型

我接手过一个挺有意思的优化任务一段在后台任务里处理数据的代码数据量其实不大大概二十万条但每次执行都要卡住两三秒。定位之后发现问题不在算法而在循环本身——一个嵌套循环里用forEach对数组做过滤每次回调还创建了新的闭包。那时候我就意识到“JavaScript数组循环”这件事表面上人人都会写但真要写出又快又稳、还符合工程规范的代码门道远比你想象的多。这篇文章我不打算只列API那没有意义。我会从底层的行为差异讲到真实业务里的选型逻辑再把我踩过的坑和我做过的性能测试数据全部摊开。不管你是刚入门前端、正在自学JavaScript基础语法还是已经写了几年业务代码但很少深究循环效率这篇文章都能给你一些可落地的参考。文章里涉及的代码我会尽量完整给出有些地方会解释引擎层面的原理偏底层但我会用大白话讲清楚。1. 先搞清楚JavaScript里到底有几种数组循环它们各自在解决什么问题很多新手困惑的根源在于JavaScript提供给数组循环的工具太多了老牌的for、whileES5时代的forEach、map、filter、reduceES6带来的for...of、for...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循环依然是性能上限最高的选择。原因后面单独讲这里先记住一个结论如果你要处理的是几十万上百万的数据默认先考虑for或while没有回调函数带来的额外开销也没有迭代器协议层层转发的损耗。另一个容易被忽略的点是很多人写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把数组归约为一个值回调会接收累加器适合求和、统计、累积计算。some、every判断数组是否满足条件返回布尔值。它们的共同点是都有回调函数且回调接收三个参数当前值、当前索引、原数组。从工程可读性角度优先使用语义化方法可以让维护者一眼看懂意图。比如const prices [100, 200, 300]; // 要得到每个价格增加20%后的新数组 const newPrices prices.map(price price * 1.2);比写for循环逐个push到新数组语义清晰得多。我自己写代码时如果数组体量不大几千条以内而且逻辑清晰会优先用这类函数式方法。它们带来的性能损失在小型数据上几乎感知不到但代码的可读性和后期维护性远胜手写循环。1.3 for...of 与 for...in 的分工很多老前端都容易混淆for...in和for...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可以搭配break、continue、return在函数里来中断或跳过这一点比forEach灵活得多。JS引擎在for...of上的性能也已经优化得相当好在某些引擎版本里甚至逼近原生for。所以ES6之后如果你的代码风格偏好现代一点而且需要灵活的流程控制for...of是个很好的平衡点。表格总结一下常见循环方法的核心差异方法返回值能否中断/跳过适用场景性能参考for无break / continue / return高性能、复杂索引逻辑最优while / do...while无break / continue / return条件驱动循环最优forEach无不能 breakreturn 仅跳过当前回调简单遍历中map新数组不能中断数据映射中filter新数组不能中断按条件筛选中reduce任意值不能中断归约聚合中for...of无break / continue / return现代写法、可中断遍历优for...in无break / continue / return遍历对象键不建议用于数组2. 性能差异不是玄学从引擎层面看for循环、forEach和for...of的差距性能对比如果只是人云亦云地说“for循环最快”那没有任何说服力。我下面从两条线去拆解一条是实际的基准测试结果另一条是引擎执行这些循环时背后发生的事。2.1 一次真实的基准测试在我的机器上数据长什么样最近我专门写过几个测试文件用performance.now()去测不同循环方式遍历大数组的耗时。测试环境是Chrome最新稳定版数据量是一百万条纯数字数组每次测试做10轮取平均。测试结果大概如下每次跑会略有浮动但量级差是稳定的循环方式一百万条数据耗时约for 缓存 length5msfor 不缓存 length6mswhile5-6msfor...of8-10msforEach12-15msmap创建新数组15-20ms那差距大吗对一百万数据来说forEach比for慢了两三倍但绝对数字也就差了不到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遍历的是Set、Map或者自定义迭代器对象那就没法做这种优化性能会明显下降。所以在高性能路径上直接操作数组时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配合awaitfor (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 }); } }如果listA和listB都有几千条这里就几十万甚至上百万次比较。更高效的做法是先建一个“查找表”——用普通对象或者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) }));第一步用map把listB转换成Map花费O(n)第二步遍历listA每次查找Map是O(1)整体复杂度O(n)。当数据量上涨时这个差距是质的差别。另外一个跟对象相关的操作是合并两个对象数组去掉重复项。最朴素的思路是维护一个Set存IDconst 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);forEach、map、filter等函数式方法在遍历时会跳过这些空位。这就可能导致一个诡异现象你用map生成的数组长度对但某些位置是空的后面对它们做处理时拿到undefined。for和for...of则不会跳过它们会把空位当作普通位置处理访问到的是undefined。以前我处理一个上传文件列表时因为某个位置的文件对象被splice掉后没有重新整理数组再用forEach遍历统计总大小发现算出来少了一截。排了半天才发现是稀疏数组的坑。所以当你对数组做删除操作、清空操作或者用new Array(n)占位时要格外小心。如果希望数组始终稠密可以用Array.from或者展开运算符来“压实”const dense [...sparseArr]; // 展开运算符会把 empty 转为 undefined4.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不支持break和continue这在遇到“满足条件就停止遍历”的需求时很让人头疼。你可能会想到在回调里用return但它只会跳过当前元素不会终止整个循环。一个错误的示范const arr [1, 2, 3, 4, 5]; arr.forEach(item { if (item 3) return; // 只是跳过大于3的元素并不是终止 console.log(item); // 1 2 3 });如果真的需要提前退出从语义上就应该选择可中断的循环。有两个方向一是改用for...of加break二是利用some和every的特性。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 typemodule加载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操作减少回流和重绘次数。渲染大量列表项时一个实用技巧是使用DocumentFragmentconst 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然后用map加filter重写再优化了循环内的对象展开逻辑最终相同数据量下的耗时从2.3秒降到60毫秒左右。这个量级提升不是微调能带来的而是算法复杂度从O(n*m)降到O(n)的结果。所以当你觉得循环慢时先别急着纠结用forEach还是for先看循环内部的查找、遍历、重复计算是不是有优化空间。结构上的优化永远大于语法上的微调。6. 小结不是总结几件我希望你带走的事我不打算做什么句式整齐的总结就说几个我发自内心的建议。第一不要因为for循环性能好就处处用它也不要因为函数式写法优雅就处处用它。选型依据是数据量和语义清晰度。几千条以内按可读性优先几十万条以上按性能优先遇到异步逻辑按流程控制需求优先。第二把map、filter、reduce、some、every的返回值和应用场景刻在脑子里它们不是forEach的替代品而是各有定位。用一个数组循环方法之前先问一句“我需要它返回什么”答案会帮你选对方法。第三我强烈建议你找一个周末把今天列举的循环方式写成测试脚本在本地跑一遍performance.now()对比。这种体感测试会让你记住“量级差距”这件事远远比别人告诉你有用。第四数组循环是JavaScript里最基础的话题也是几乎所有业务代码都会碰到的话题。把基础吃透、熟悉边界情况后面学习ES6的迭代器、生成器、异步编程都会顺畅很多。如果这篇文章帮你解决了一个具体的bug或者让你的某个函数快了一毫秒那它就算没白写。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门