ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

JavaScript数组循环完全指南:从forEach到性能优化

JavaScript数组循环完全指南:从forEach到性能优化 1. 数组循环不止是“遍历一遍”JavaScript里的数组循环是每个写前端的人几乎每天都要碰的东西。但你可能也发现了同一个数组有的人用for有的人用forEach有的人甩出一行map就完事。写法千奇百怪性能、可读性、踩坑概率也完全不一样。这篇内容不打算讲那种“这是for这是while大家跟我读一遍”的入门教程。我更想聊的是数组循环背后到底有哪些门道不同场景下应该怎么选以及我在真实项目里踩过的那些坑。无论是刚入行不久、还在纠结用哪种循环的初学者还是已经写了两三年、想系统梳理一下的老手这篇文章应该都能给你点东西。先给个结论放这儿数组循环没有绝对的“最佳写法”只有“当前场景下最合适的写法”。但如果你能把各种循环方式的底层行为和适用边界都搞清楚写出来的代码既快又不容易出bug那就是真正吃透了这东西。2. 先想清楚你手里的是数组还是“看起来像数组的东西”2.1 数组的本质是连续内存加原型方法数组在JavaScript里是个很有意思的存在。它不像C语言里那种严格意义上的“连续内存块”更像是一个经过特殊处理的对象key是数字索引value是元素同时继承了一个Array.prototype原型上面挂着一堆方法map、filter、forEach、reduce、some、every……这些方法本质上都是在帮你做“循环”这件事只是各自有不同的返回值和处理逻辑。这也就解释了为什么“循环数组”这件事会被拆成这么多写法你每次选循环方式实际上是在选一种处理语义。比如map的语义是“逐个转换并返回新数组”filter的语义是“挑出符合条件的组成新数组”forEach的语义是“我只要副作用不要返回值”。语义选对了代码读起来就像在表达业务逻辑而不是在描述机器步骤。2.2 类数组对象和可迭代对象是两回事很多人写循环写出bug问题往往不在循环本身而在循环的对象。比如document.querySelectorAll返回的NodeList它虽然有length、也能用索引访问但它不是Array实例直接调用forEach在老版本浏览器里会报错。arguments对象也是这样函数里的arguments是个类数组没有数组的方法。这时候你可能有三种处理方式第一种是Array.prototype.slice.call(arguments)把它转成真数组第二种是Array.from(arguments)这是ES6提供的更直观的写法第三种是直接用展开运算符[...arguments]。这三种方式都能完成转换但适用场景略有不同。Array.from最强的地方在于它还能接受第二个参数——一个map函数相当于转数组的同时做一次遍历处理一步到位。另外一个容易混淆的概念是“可迭代对象”。只要对象有Symbol.iterator方法它就是可迭代的可以用for...of遍历。数组本身是可迭代的字符串也是Set、Map也是。但普通对象不是所以不能用for...of直接遍历对象。这也是很多初学者写for...of对象报错的根本原因。3. 选型之前先看清每种循环的“脾气”3.1 for循环最原始也最可控先说最基础的for循环。很多老程序员对数组方法有偏见觉得花里胡哨不如for直接。但for循环最大的价值不是性能而是可控性。你可以随时break跳出可以continue跳过本次可以自由控制索引的增减步长甚至可以在循环体里修改索引来实现复杂的跳跃逻辑。但for循环的缺点也特别明显容易写出“差一错误”也就是边界判断不对。比如for (let i 0; i arr.length; i)这种经典的越界写法最后拿到的arr[i]是undefined。另外for循环本质上是“命令式”代码读代码的人需要自己在大脑里模拟一遍执行过程才能理解这段循环在干什么。3.2 for...of循环既干净又能跳出ES6引入的for...of循环是我个人非常喜欢的一种方式。它不像传统for那样要手动管理索引也不像forEach那样不能break。它直接遍历“值”写法简洁语义清楚。const arr [12, 25, 8, 30, 17]; for (const item of arr) { if (item 20) { console.log(第一个大于20的是${item}); break; } }看到没需要提前终止的时候for...of比forEach舒服太多了。而且for...of不仅能遍历数组还能遍历Set、Map、生成器等所有可迭代对象通用性很强。3.3 forEach只管干活不管中断forEach是数组方法里最常用的一个。它的好处是代码看起来干净不会出现索引那种“噪音”。但它有几个非常反直觉的坑很多人掉进去过第一个坑forEach里不能用break跳出循环。你写了break直接报语法错误。想提前结束只能抛异常但这显然不是正常业务的写法。第二个坑forEach里return不会终止循环只是跳过当前这一次回调。很多从其他语言转过来的朋友以为return相当于“退出函数”但在forEach里它只是说“本次处理到此为止继续下一个”。第三个坑forEach遍历过程中如果修改了数组元素回调里看到的值可能和你预期不一致。这个后面细说。第四个坑forEach没法很方便地拿到当前索引以外的信息。其实它是有索引参数的回调函数第二个参数就是index但很多人写的时候总是忽略它。const list [a, b, c]; list.forEach((item, index, array) { console.log(index, item, array); });回调里第三个参数甚至能拿到原始数组的引用。但注意如果你在回调里push了新元素forEach是会遍历到新增元素的这就可能导致意外行为。3.4 map/filter/reduce声明式循环map、filter、reduce这三兄弟是函数式编程在JavaScript里的主力。它们的共同点是不直接操作原始数据而是返回一个新结果。map做“一对一转换”filter做“筛选”reduce做“归约”。const prices [100, 200, 300]; const withTax prices.map(price Math.round(price * 1.13)); const affordable prices.filter(price price 200); const total prices.reduce((sum, price) sum price, 0);这类写法的好处是代码极其简洁而且意图自描述。看到map你就知道是转换看到filter你就知道是筛选。坏处是如果回调函数里写的逻辑太复杂反而会变得难以阅读。我的建议是回调逻辑超过三行就该抽成具名函数而不是堆一大坨箭头函数。3.5 some/every/find带判断的循环some表示“有没有至少一个满足条件”every表示“是不是全都满足”find表示“找出第一个满足条件的元素”。这三个方法都能提前终止遍历而且语义非常清晰能用它们的地方就别自己写for循环去判断了。const users [{name: 张三, age: 18}, {name: 李四, age: 25}]; const hasAdult users.some(user user.age 18); const allAdult users.every(user user.age 18); const firstAdult users.find(user user.age 18);配合可选链和箭头函数这三行代码能把原来的一大段for循环逻辑压到一行而且读起来像英文句子一样顺畅。我自己在Code Review时看到有人用for循环去“找有没有某个元素”都会建议换成some或find——不是为了炫技是这种写法的边界情况更少不容易写错。4. 实操过程中最容易被忽略的细节4.1 遍历时修改数组后果比你想的严重先说一个真实踩过的坑。有一次我处理一个列表需求是“把符合条件的元素删掉”。我当时想都没想就写了const arr [1, 2, 3, 4, 5]; arr.forEach((item, index) { if (item % 2 0) { arr.splice(index, 1); } }); console.log(arr); // [1, 3, 5]还是别的结果是[1, 3, 5]看起来好像对了。但如果换一组数据const arr [1, 2, 3, 4, 5, 6]; arr.forEach((item, index) { if (item % 2 0) { arr.splice(index, 1); } }); console.log(arr); // 实际结果是 [1, 3, 5, 6]看到了吗6没有删掉。原因是splice会改变数组长度和索引forEach遍历使用的索引是单调递增的删除元素后后面的元素往前挪了一位但循环索引已经过去了等于“跳过了”一个元素。正确的做法是要么用filter重新生成数组要么倒序遍历删元素。// 推荐filter生成新数组 const result arr.filter(item item % 2 ! 0); // 或者倒序遍历避免索引偏移 for (let i arr.length - 1; i 0; i--) { if (arr[i] % 2 0) { arr.splice(i, 1); } }这条经验值多少钱至少值一次线上bug修复的时间。你在循环过程中对数组做结构性修改push、pop、splice、sort时一定要想清楚索引会不会乱掉。4.2 异步循环forEach的“坑”是同步的“假循环”另一个高频问题是在循环里做异步操作。比如你要循环上传文件、循环发请求、循环查数据库很多人很自然地写成const ids [1, 2, 3, 4, 5]; ids.forEach(async (id) { await fetch(/api/data/${id}); });然后发现请求确实是发出去了但后面的代码没法等所有请求完成。因为forEach不会等待异步回调执行完毕它只是把所有回调一股脑丢出去就完了。async在这里只是把forEach变成了“并发发起异步任务”而不是“逐个等待”。这种场景下有几种选择用for...of配合await串行执行逻辑最清晰用Promise.all配合map并发执行用reduce配合Promise串行执行且能传递结果。// 串行一个个来 for (const id of ids) { await fetch(/api/data/${id}); } // 并发同时发出去全部完成再继续 await Promise.all(ids.map(id fetch(/api/data/${id})));如果你要控制并发数量不能用Promise.all全量并发可能导致服务器被打挂那可以自己写个带并发限制的工具函数或者直接去找p-limit这类现成的库。但原理上for...ofawait这条组合拳是最能在性能和逻辑清晰度之间取得平衡的。4.3 break、return、continue在循环里的“行为地图”很多初学者对break、return、continue在不同循环方式里的表现非常困惑。这里我整理一个速查表循环方式breakcontinuereturnfor跳出整个循环跳过本次继续下一次退出整个函数for...of跳出整个循环跳过本次继续下一次退出整个函数forEach语法错误跳过本次回调只结束当前回调循环继续map/filter/reduce语法错误语法错误只结束当前回调some/every/find相当于找到结果自动停止返回true继续判断只结束当前回调看到规律了吗能在外部控制循环流程的只有for和for...of数组方法内部完全有一套自己的逻辑。所以如果你的需求里有“找到就停”“不满足就提前结束”这类条件优先考虑for...of或者some/find这类能提前终止的方法。4.4 对象不是数组别硬循环循环一个普通对象的时候最常见的姿势是for...in。但for...in有个坑它会遍历到原型链上可枚举的属性。如果不小心你会拿到一些莫名其妙的东西。所以用for...in遍历对象时最好配合hasOwnProperty判断const obj { a: 1, b: 2 }; for (const key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { console.log(key, obj[key]); } }更现代一点的做法是用Object.keys、Object.values、Object.entries来解决Object.entries(obj).forEach(([key, value]) { console.log(key, value); });Object.entries返回的是[key, value]组成的二维数组然后你对这个二维数组做循环想怎么遍历都行。这样既绕开了原型链污染问题又能解锁数组的所有方法。5. 动手撸一个“带终止条件的forEach”5.1 需求分析前面说了forEach不能break。但实际业务里很多场景是我要遍历数组处理每一个元素一旦满足某个条件就停止后续处理。你当然可以用for...of但有时候项目代码里全面采用了函数式风格或者你想封装一个通用的工具方法那就可以自己实现一个“可终止的forEach”。这种需求在真实项目中很常见。比如你有一个批量任务列表每个任务执行起来很昂贵一旦发现某个任务失败就立即停止后续任务的执行。这时候内置的forEach显然是撑不住的。5.2 代码实现function forEachBreakable(arr, callback) { for (let i 0; i arr.length; i) { const result callback(arr[i], i, arr); // 回调返回 false就终止循环 if (result false) { break; } } } // 使用示例 const tasks [ { name: 任务A, ok: true }, { name: 任务B, ok: false }, { name: 任务C, ok: true }, ]; forEachBreakable(tasks, (task, index) { console.log(开始执行${task.name}); if (!task.ok) { console.log(任务失败停止后续执行索引 ${index}); return false; } });这个实现的原理其实特别简单forEach做不到break是因为它的回调返回值没有被外部循环所利用。我们自己写一个for循环作为底层驱动然后把“是否继续”的判断权通过返回值暴露给调用者。回调返回false就break否则继续。这就相当于既享受了forEach的写法简洁又保住了for循环的控制力。5.3 更进一步让回调支持async/await如果你需要在这套工具里支持异步任务比如“串行执行异步操作一旦失败立即停止”那只需要把底层循环改成for...of并await回调的返回值async function forEachBreakableAsync(arr, callback) { for (const [index, item] of arr.entries()) { const result await callback(item, index, arr); if (result false) { break; } } }这里用arr.entries()是为了拿到[index, item]这种结构然后用数组解构语法一次取出两个变量。两个版本的差异在于同步版本用for循环就够了异步版本必须用for...of加上await否则无法保证串行执行。5.4 为什么不用for...in顺带提一句遍历数组的时候永远不要用for...in。虽然它也能拿到索引const arr [a, b]; for (const index in arr) { console.log(index, arr[index]); }但这里拿到的index是字符串不是数字而且for...in遍历的是可枚举属性如果有谁往Array.prototype上添加了自定义方法for...in会把它们也遍历出来。网上很多“老代码”这么写但放到现代工程里完全不应该出现。6. 性能对比到底谁更快6.1 实测结果很多人关心性能我直接说结论在绝大多数业务场景下for循环和数组方法的性能差异完全可以忽略你根本不需要为性能去选循环方式。真正影响性能的是循环体内部做了什么而不是循环本身。但为了严谨还是做个简单的性能对比。假设一个长度10万、元素是数字的数组分别用for、for...of、forEach、map做累加或转换。在最新版Chrome里趋势大概是这样for循环最快因为它几乎没有任何额外开销forEach比for慢一点点但差距通常在个位数百分比for...of比forEach再慢一点因为迭代器协议有额外开销map在纯转换场景下和forEach差不多但它会创建一个新数组内存开销更大。这个测试结果在不同浏览器、不同机器上有波动但整体排序基本稳定。如果你的循环数量级在几千几万这点差异根本感知不到。6.2 真正要担心的内存问题真正需要警惕的是map这类方法会产生新数组。如果你在循环里嵌套循环再用map生成新数组又用filter生成另一个新数组数据量大时内存占用会成倍上涨。比如const bigData Array.from({ length: 100000 }, (_, i) i); const result bigData .filter(n n % 2 0) .map(n n * 10) .reduce((acc, n) acc n, 0);这段代码虽然简洁但filter先生成了一个5万长度的新数组map又生成了一个5万长度的新数组最后才reduce。如果在内存受限的环境里跑大批量数据可以考虑用for循环一次搞定省掉中间数组的开销。6.3 过早优化是万恶之源写代码的顺序建议是先保证逻辑正确、可读性好遇到真实的性能瓶颈用performance工具定位到具体代码再针对性地优化循环写法。不要一开始就因为“听说for最快”把所有map都改成for循环那是典型的“过早优化”。要知道for循环写多了出边界bug的概率也更大省下来的那几毫秒还不够调试费的时间成本。7. 数组去重、数组转字符串等高频循环场景实战7.1 去重Set一行搞定但要看场景数组去重几乎是循环场景里出场率最高的问题。最简洁的做法是利用Set的特性const arr [1, 2, 2, 3, 3, 4]; const unique [...new Set(arr)]; console.log(unique); // [1, 2, 3, 4]但这里有个细节Set的去重是基于严格相等SameValueZero的也就是说1和1会被当成不同值两个对象即使结构完全一样也不会被去重。如果你要去重的是一个对象数组那Set就无能为力了。比如const users [ { id: 1, name: 张三 }, { id: 1, name: 张三 }, ]; const uniqueUsers [...new Set(users)]; // 长度还是2因为引用不同这种情况你需要基于某个唯一标识去重比如按idconst seen new Set(); const uniqueById users.filter(user { if (seen.has(user.id)) { return false; } seen.add(user.id); return true; });这里用filter配合Set的“查重标记”模式本质上还是一次遍历但逻辑很清晰。filter回调只在seen里没有该id时返回true同时把id加到seen里这样后出现的重复项就被过滤掉了。7.2 数组转字符串join远比循环拼接靠谱把数组转成字符串大家可能第一反应是用循环拼接。比如const arr [a, b, c]; let str ; for (let i 0; i arr.length; i) { str arr[i]; if (i arr.length - 1) { str ,; } }但数组本身提供了join方法一行就搞定了const str arr.join(,);join底层是用C实现的在V8引擎里性能比JavaScript层的手工字符串拼接好太多。而且join支持传入任意分隔符不传默认用英文逗号。如果数组里有嵌套数组要先拍平flat或flatMap否则会拼出“1,2,3,4”这种带嵌套结构的字符串注意区分。7.3 二维数组和矩阵操作前面热词里出现了“二维数组”和“C语言二维数组”相关的问题JavaScript里二维数组其实就是一个数组套数组。循环遍历二维数组最常见的就是双重for循环const matrix [ [1, 2, 3], [4, 5, 6], [7, 8, 9], ]; for (let row 0; row matrix.length; row) { for (let col 0; col matrix[row].length; col) { console.log(matrix[${row}][${col}] ${matrix[row][col]}); } }如果想把二维数组拍平成一维数组ES2019提供了flat方法const flat matrix.flat(); // [1, 2, 3, 4, 5, 6, 7, 8, 9]flat默认只拍平一层如果需要深度拍平传Infinity。但注意flat把空位也去掉了这个行为在某些场景下要注意。7.4 稀疏数组forEach会跳过空位JavaScript数组允许“空位”holes比如const arr [1, , 3]; console.log(arr.length); // 3 console.log(arr[1]); // undefined这个arr[1]位置不是undefined值而是一个真实的“空位”。区别在于forEach、map、filter等数组方法遍历时会跳过空位但for循环不会跳过。所以如果你用map处理这个数组const result arr.map(item item * 2); console.log(result); // [2, 空位, 6]结果是保留了空位。但如果你用for循环读到arr[1]会得到undefined再乘2就是NaN。这个差异非常隐蔽我在实践里就遇到过一次一个接口返回的数组里有空位前端用map生成新数组渲染出来的列表中间少了一块。排查了半天才发现是空位问题。后来养成了习惯从接口拿到的数据先过滤一下空位const cleaned arr.filter(item item ! undefined);注意用filter过滤空位和undefined是两回事filter会跳过空位也会处理值为undefined的元素。具体业务里可以用item ! null这种宽松判断把null和undefined都滤掉前提是你确认业务上不需要这两个值。8. 常见问题速查表与避坑指南8.1 高频报错与行为异常我整理了一份我在实际项目中遇到的问题清单每一行都是一个真实踩过的坑表现根本原因解决方案forEach里写break报语法错误forEach不支持break改用for...of或some循环里splice删元素后漏项索引偏移倒序遍历或改用filterfor...in遍历数组拿到字符串索引for...in的语义问题改用for...ofmap回调里改了原数组map的设计是返回新数组确认意图别依赖副作用循环里发异步请求结果顺序错乱并发执行而非串行for...ofawait或Promise.all大数组map后内存暴涨生成了多个中间数组改用for循环一次处理数组有稀疏空位map结果不对数组方法跳过空位先过滤空位8.2 forEach里的索引参数到底怎么用forEach的回调函数有三个参数当前元素、当前索引、数组本身。很多人只用第一个参数但索引在某些场景下非常有用。比如你要把数组渲染成列表每行的序号就是索引const items [项目A, 项目B, 项目C]; items.forEach((item, index) { console.log(第${index 1}个${item}); });还有一个技巧通过索引判断是不是最后一个元素避免在拼接时多加分隔符items.forEach((item, index) { str item; if (index items.length - 1) { str 、; } });不过这种拼接场景我更推荐join这里只是展示索引的实际用途。8.3 循环变量作用域var和let的坑for循环里的声明语句用var和let完全是两个世界// 用var循环结束后i还在 for (var i 0; i 3; i) { setTimeout(() console.log(i)); // 输出 3, 3, 3 } // 用let每次循环都是一个新的i for (let i 0; i 3; i) { setTimeout(() console.log(i)); // 输出 0, 1, 2 }原因很简单var声明的变量是函数作用域循环结束后它仍然存在只是值变成了最终值let声明的变量是块级作用域每次迭代都会创建新的绑定所以闭包捕获到的是当次循环的i。这个知识点在面试题里出现频率极高在真实项目里也坑过不少人——尤其是你给一组按钮绑定点击事件点击后取到的i永远等于最后一个值那一刻你会深刻理解这个坑的痛苦。8.4 while循环什么时候用for循环能干的活while循环基本都能干反之亦然。但有些场景while更顺手let i 0; while (i arr.length) { // 这里可以做更复杂的判断 if (arr[i] target) { break; } i; }当你不知道循环次数、只知道终止条件的时候while更合适。比如从某个数组中不断取出元素直到满足条件。但while有个风险很容易忘记更新循环变量导致死循环。我自己的习惯是能用for就用for因为for把“初始化、条件、更新”写在了一行里结构更完整不容易漏更新。8.5 生成器与无限数组循环的边界玩法如果你的“循环”不只是在已有数组上遍历而是需要按规则生成序列那么生成器Generator配合for...of会有奇效。比如生成一个斐波那契数列的前N项function* fibonacci() { let a 0, b 1; while (true) { yield a; [a, b] [b, a b]; } } const fib fibonacci(); for (let i 0; i 10; i) { console.log(fib.next().value); }这里生成器是无限序列不能直接放进数组方法里遍历但你可以配合next手动取或者用for...of配合计数器来取前N项。这种玩法不算日常高频场景但了解它对理解“循环”的本质很有帮助——循环不一定是在“遍历已经存在的东西”也可以是在“按需生成东西”。8.6 实战里的一个综合案例最后给你一个我最近实际写过的场景把今天聊的几个点串起来。需求是有一个订单列表每个订单有商品列表要把所有订单里金额大于100的商品筛选出来并按销售额从高到低排序。const orders [ { id: 1, items: [{ name: 键盘, price: 88 }, { name: 显示器, price: 1200 }] }, { id: 2, items: [{ name: 鼠标, price: 45 }, { name: 键盘, price: 58 }] }, { id: 3, items: [{ name: 耳机, price: 150 }] }, ]; const expensiveItems []; for (const order of orders) { for (const item of order.items) { if (item.price 100) { expensiveItems.push(item); } } } expensiveItems.sort((a, b) b.price - a.price); console.log(expensiveItems);这里我用for...of嵌套遍历订单和商品是因为需要在前一层的每一笔订单里逐项做判断这种场景如果用flatMap加filter也能写const expensiveItems orders .flatMap(order order.items) .filter(item item.price 100) .sort((a, b) b.price - a.price);两种写法结果一样但第二种更“声明式”一行表达一个步骤逻辑链条非常清晰。flatMap把嵌套结构拍平成一维filter做条件筛选sort做排序。如果你的项目里其他人都习惯函数式写法用第二种会让你代码风格更统一如果团队里新手多第一种更直白、容易打断点调试。没有绝对的对错关键是团队风格一致。9. 最后分享一个我自己养成的习惯我在代码评审里经常看到有人纠结要不要为了“优化”把forEach改成for循环或者把map改成for循环push。说实话除非你的项目要做那种数据密集型运算比如处理几十万条数据的表格或者做canvas像素级操作否则真的没必要纠结这一点点性能差异。我做了这么久前端真正因为循环写法导致性能问题的项目屈指可数反而因为循环逻辑写错导致线上bug的次数多得多。所以我现在定了一个简单的选择原则先考虑语义是否匹配再考虑可读性性能放在最后考虑。要转换数组用map要筛选用filter要归约用reduce要中断用for...of要索引控制用for。这个原则帮我解决了很多选择困难也让我的代码在可读性和健壮性上都稳定了不少。还有一个建议如果你常用的数组方法里有什么API记不住别怕查文档不丢人。用得多了自然就熟了。真正值钱的是你对这个语言数据结构底层行为的理解——数组是对象、索引是属性、循环是控制流、遍历是语义。把这几个点串起来你看待JavaScript数组循环的视角就会完全不同。
返回列表