ARTICLE DETAIL

资讯详情

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

JavaScript循环语句完全指南:从语法到异步与性能优化

JavaScript循环语句完全指南:从语法到异步与性能优化 在项目里被循环语句卡住过的人应该不在少数。不管你是刚接触 JavaScript 的新手还是写了几年业务代码的老手只要跟数组、对象、DOM 元素打过交道就一定绕不开 for、while、forEach 这些老朋友。但循环语句远不止“重复执行代码”这么简单选错循环方式、没搞懂中断机制、循环里碰到异步逻辑都可能让你的程序跑出让人摸不着头脑的结果。这篇博文就围绕 JS 循环语句做一次系统梳理从基础写法、选型思路、中断控制到底层执行原理再到几个高频踩坑场景把我自己实际用下来的经验一起放进去。内容适合零基础入门的朋友也适合想查漏补缺的初中级开发者你可以把它当成一份 JS 循环主题的工具手册来用。1. JS 循环语句全家桶每种写法的脾气和性格写循环的人最怕什么最怕的是“只知道一种写法遇到问题就硬套”。JS 里的循环语句说多不多说少也不少实际开发中高频出现的无非就是for、while、do...while、for...in、for...of和数组的forEach。它们的语法都很简单但背后的适用场景千差万别。1.1 循环语句的基础形态对比先看最传统的for循环它的本质是“三个表达式驱动”的循环控制结构for (初始化表达式; 条件表达式; 更新表达式) { // 循环体 }执行顺序是这样的先执行初始化表达式然后判断条件表达式是否为真为真就进入循环体循环体执行完再执行更新表达式更新完回头继续判断条件。这个顺序必须记牢因为它决定了你到底循环多少次、什么时候退出。while循环则是“先判断、后执行”的典型代表while (条件表达式) { // 循环体 }它只有条件判断内部要自己维护变量的变化。如果条件永远为真就会死循环。我见过不少新人写while (true)忘了写 break 的页面直接假死这个问题后面会专门说。do...while则是“先执行、后判断”do { // 循环体 } while (条件表达式);它的特点是循环体至少会执行一次。这看起来只是顺序差异实际业务里却能派上大用场。比如你要从用户输入里读取一段内容要求“至少输入一次之后判断是否继续”用do...while就比while自然得多。至于for...in它被设计用来遍历对象的可枚举属性const person { name: 张三, age: 30 }; for (const key in person) { console.log(key, person[key]); }这里有个所有人都应该知道的坑for...in不只遍历对象自身的属性还会把原型链上可枚举的属性一起带出来。所以实际项目中如果没有hasOwnProperty的检查很容易遍历出你根本不需要的东西。for...of是 ES6 引入的遍历方式专门为可迭代对象设计比如数组、字符串、Map、Set 这些const list [10, 20, 30]; for (const item of list) { console.log(item); }它是这几类循环里写起来最舒服的代码简洁不会像for...in那样把索引当成字符串取出来也不会遍历到原型链上的属性。最后是数组的forEach方法严格来说它不是“语句”而是数组原型上的一个方法[1, 2, 3].forEach((item, index) { console.log(index, item); });forEach代码更语义化适合做遍历操作但它在控制循环方面非常死板break和continue对它无效。这一点后面我会专门用一小节展开讲。1.2 不同循环的实现形态一览用一张表把它们的核心差异整理出来方便遇到场景时快速选型循环方式适用对象能否 break/continue是否支持 return典型场景for数组、次数已知的循环支持函数内支持计数循环、按索引操作while条件驱动型循环支持函数内支持读取数据直到结束do...while至少执行一次的循环支持函数内支持菜单选择、重试逻辑for...in对象属性支持函数内支持遍历对象键名for...of可迭代对象支持函数内支持遍历值而非索引forEach数组不支持只结束本次回调简单遍历、UI 渲染分类查表只是第一步要真正用好还得理解“为什么这个场景适合这种循环”。比如for...of能直接拿到值而不是索引这在处理字符串、Map 这类数据时优势特别明显而for循环的优势在于需要同时控制多个起点、终点和步长时你想怎么走就怎么走灵活性无人能比。2. 循环的中断与跳过break、continue、return 到底怎么用循环写多了你会发现一个循环里并不总是要老老实实跑满全部次数。找数据找到就直接退出更高效遇到不合法的值跳过就完事。这时候怎么打断、怎么跳过就成了区分代码水平高低的细节。2.1 break 与 continue最基础的断路开关break的作用是立即终止整个循环后面的循环次数不再执行。continue则是跳过本轮循环的剩余代码直接进入下一轮判断。看一个搜索数组的例子const numbers [3, 8, 15, 6, 9]; let targetIndex -1; for (let i 0; i numbers.length; i) { if (numbers[i] 15) { targetIndex i; break; // 找到了就不继续翻了 } } console.log(targetIndex); // 2这就是break的典型场景查到了目标直接退出避免无意义的后续遍历。再看continue的典型场景比如过滤掉数组里的偶数并打印奇数for (let i 0; i 10; i) { if (i % 2 0) { continue; // 偶数直接跳过打印 } console.log(i); // 1 3 5 7 9 }很多人写代码时习惯用“取反 if 嵌套”代替continue。比如上面这段我也经常见到有人写成for (let i 0; i 10; i) { if (i % 2 ! 0) { console.log(i); } }这两种写法在结果上没区别但从代码可读性角度看continue能减少一层嵌套尤其在循环体里本来就有很多逻辑的时候提前continue掉“不需要处理的场景”后面的代码就能默认“都是要处理的场景”心智负担会小很多。2.2 多层循环break 只能跳出最内层怎么办不知道你有没有遇到过这种情况写了一个双层循环在里层发现条件满足想着break一下直接结束两层循环结果程序只跳出了里层外层还在那不知疲倦地继续跑。JS 里break只能终止它所在的最近一层循环这是很多初学者踩过的坑。解决的办法有两个一个是额外加个标志位let shouldStop false; for (let i 0; i 5 !shouldStop; i) { for (let j 0; j 5; j) { if (i * j 12) { shouldStop true; break; } } }另一个更优雅的方式是用标签语句label。给外层循环起个名字里层break的时候指名道姓要跳出哪个循环outer: for (let i 0; i 5; i) { for (let j 0; j 5; j) { if (i * j 12) { break outer; } } }这个写法的可读性其实比标志位强很多但需要团队里的人看得懂标签语法。标签语句在早期 JS 里用的人少很多前端开发者没见过所以我一般在团队自用代码里会用标签在对外提交的代码里会改成标志位减少沟通成本。2.3 forEach 里怎么“打断”三个替代方案这是我在工作中被问得最多的问题之一——forEach不支持break和continue那在forEach里怎么中断遍历先说结论forEach无法被真正中断。它的设计初衷就是“对每个元素执行一次回调”你在回调里面写break会直接报语法错误写continue同样报错。此时正常写法的确应该换成for...of循环但如果你就是想在链式调用的场景里实现类似效果一般有三个替代思路。第一个思路是“提前 return”模拟continue[1, 2, 3, 4, 5].forEach((item) { if (item % 2 0) return; console.log(item); // 1 3 5 });注意这个return只是结束当前这一次回调并不是跳出整个forEach。所以它只等价于continue没法等价于break。第二个思路是“用抛出异常的方式强制中断”try { [1, 2, 3, 4, 5].forEach((item) { if (item 3) throw new Error(break loop); console.log(item); }); } catch (e) { // 到这里遍历就被中断了 }这个方法确实能打断遍历但非常不优雅还会被代码评审的同事抓着问半天。我自己只在面试题里讲这个思路实际项目里不会真这么用。第三个思路最实在非要中断提前用some或find这类“可停”的数组方法。some遍历时一旦回调返回true就会立刻停止遍历[1, 2, 3, 4, 5].some((item) { console.log(item); return item 3; // 遍历到 3 就停止 });这本质上不是“打断forEach”而是换了一种“既能链式调用、又能提前停”的遍历方式。所以我的经验是如果你一开始就预感到“这个遍历可能需要提前退出”那就压根不要选forEach直接用for...of干净利落。3. 循环实操全景数组、对象、DOM 与异步场景的落地写法光会写循环体的语法还不够真正的工作里循环要处理的是真实数据数组去重、对象取值、DOM 节点遍历、异步逻辑里的循环每一个场景都有它独特的讲究。3.1 数组遍历的进阶合并去重、排序与查找数组是前端开发最常打交道的数据结构之一很多日常需求其实就是“遍历 条件处理”的组合拳。我记得有次做数据清洗需要把两个接口返回的数组合并并去重当时很多人直接用双重循环去重代码冗长性能还差。后来换成Set加展开运算符一行搞定const arr1 [1, 2, 3, 4]; const arr2 [3, 4, 5, 6]; const merged [...new Set([...arr1, ...arr2])]; console.log(merged); // [1, 2, 3, 4, 5, 6]虽然这里没有显式写循环但Set去重的底层逻辑就是哈希表式的遍历。理解循环能帮你读懂这些语法糖背后的真实行为不要以为不写for就真的没有循环。数组排序也经常用循环思维去理解。比如手写一个简单的冒泡排序虽然业务中更多会用Array.prototype.sort但初学者用for嵌套亲手写一次排序对“循环控制变量”“交换位置”“内层循环边界”这些概念的理解会一下子立体起来function bubbleSort(arr) { const result [...arr]; for (let i 0; i result.length - 1; i) { let swapped false; for (let j 0; j result.length - 1 - i; j) { if (result[j] result[j 1]) { [result[j], result[j 1]] [result[j 1], result[j]]; swapped true; } } if (!swapped) break; // 一轮下来没有交换说明已经有序 } return result; }这里我留了个优化细节内层循环的终点是result.length - 1 - i。原因很简单每一轮冒泡都会把当前未排区间的最大值“冒”到最后已经排好的位置就不用再碰了。如果某次内层循环一个交换都没发生说明整个数组已经有序直接break能省下后续无意义的比较。查找和过滤就更不用说了filter、find、findIndex这些方法背后都对应着循环遍历。理解循环的本质不管用方法还是用语句心里都门儿清。3.2 对象遍历的边界只取自身属性别带上原型链对象遍历的热搜词里有“js 获取字典的所有值”。在实际场景里我们经常要从一个对象里提取所有值或者遍历键值对。最直接的办法是用Object.keys配合forEach或者直接用for...inconst dict { apple: 1, banana: 2, cherry: 3 }; // 方式一 Object.keys(dict).forEach((key) { console.log(key, dict[key]); }); // 方式二 for (const key in dict) { if (Object.hasOwn(dict, key)) { console.log(key, dict[key]); } }关于for...in必须强调一个原则不检查原型链就遍历对象属性等于在走钢丝。因为任何在原型上扩展过属性的代码都可能污染你的遍历结果。ES2022 之后推荐用Object.hasOwn替代传统Object.prototype.hasOwnProperty.call写法更简洁也避免了对象自身hasOwnProperty被覆盖的问题。还有一个细节for...in遍历对象属性时键名的排列顺序不是完全按照“插入顺序”的。整数键名会先按升序排列然后才是字符串键名按插入顺序排列。这个特性了解即可前端业务里依赖对象键序遍历顺序的场景其实很少真要讲究顺序该用Map而不是普通对象。3.3 DOM 遍历实战获取节点列表后的循环处理现在换到一个前端更日常的场景——DOM。document.querySelectorAll返回的是一个NodeList它不是真正的数组但可以被forEach直接调用。比如要给页面上所有.card元素绑定点击事件document.querySelectorAll(.card).forEach((card, index) { card.addEventListener(click, () { console.log(点击了第 ${index 1} 张卡片); }); });这里隐藏着一个典型坑NodeList的forEach在部分旧浏览器比如旧版 IE里是不存在的如果你需要兼容旧环境先转成数组更稳妥Array.prototype.slice.call(document.querySelectorAll(.card)).forEach(...)现在写项目大多用现代浏览器了这个兼容问题不常见但如果维护老系统还是留个心眼。另一个 DOM 场景是“市州三级联动”这种选择省份联动城市再联动区县。下拉框的选项本质上就是用for循环生成option元素追加进去。我当初实现的时候是这样处理的function renderOptions(selectEl, options) { selectEl.innerHTML ; options.forEach((opt) { const option document.createElement(option); option.value opt.code; option.textContent opt.name; selectEl.appendChild(option); }); } // 省变化时渲染城市列表 renderOptions(citySelect, cityDataMap[provinceValue]);这里为什么不用innerHTML拼接字符串因为insertAdjacentHTML或innerHTML在数据量大的时候虽然快但容易引入 XSS 风险而且动态绑定事件还得二次查找。createElement逐项创建虽然代码多但职责清晰数据可控安全系数也更高。小型下拉框完全够用这算是实操中的取舍经验。3.4 循环与事件循环异步逻辑下的陷阱与正确姿势热词里出现了“js 事件循环”这里很容易和循环语句扯上关系但大家要注意区分事件循环Event Loop是 JS 运行时处理异步任务的机制而循环语句是同步代码的重复执行结构。不过两者确实会相遇最经典的问题就是变量作用域。先看这个著名面试题for (var i 0; i 5; i) { setTimeout(() { console.log(i); }, 0); } // 输出5 5 5 5 5用var声明时i是函数作用域整个循环共享同一个变量。等到定时器里的回调真正执行时循环早就跑完了i已经停在 5所以打印的全是 5。解决办法有两个一个是把var换成letES6 的块级作用域会让每一轮循环的i独立保存一份另一个是用闭包包一层for (var i 0; i 5; i) { (function (j) { setTimeout(() { console.log(j); }, 0); })(i); }这个问题的根源在于 JS 事件循环的“先走完同步任务再执行异步回调”机制。理解了这个底层逻辑再遇到 setTimeout 里拿到错误值、循环发请求总是取到最后一项这种问题就不会两眼一抹黑了。还有一类经常踩的坑是在for...of循环里配合async/await做逐项异步操作async function processList(list) { for (const item of list) { await fetchData(item); } }for...of在 ES2018 之后天然支持await可以确保每次拿完数据再进下一次循环。如果你用forEach来写里面的“await”实际上是无效的——forEach不会停下来等异步回调完成导致所有请求同时发出顺序完全失控。所以记住循环里有异步依赖时优先选for...of而不是forEach这是无数人踩出来的经验。4. 高频问题与排查实录循环里的那些实战大坑代码写得越多越发现“技术点易讲疑难杂症难排”。循环语句涉及的问题类型其实相当集中下面把我遇到的几个高发问题跟排查思路分享一下每一项背后都是我或者同行在真机环境里折腾过的经验。4.1 死循环为什么页面卡死了死循环是最让人头大的问题之一表现通常很直接页面无响应、CPU 占用飙高、浏览器标签页崩溃。常见的死循环写法有几类。第一类是循环条件写反或者忘记更新let i 0; while (i 10) { // 忘了写 ii 永远是 0 }第二类是条件判断使用了永不变化的表达式let isRunning true; while (isRunning) { // 里面没有把 isRunning 改成 false 的分支 }第三类在业务代码里更容易出现就是循环条件里用到浮点数比较。比如for (let i 0; i ! 1; i 0.1) { // 浮点累加存在精度误差i 可能永远不等于 1 }因为 JS 的浮点数是 IEEE 754 标准0.1 0.2 ! 0.3这个老梗很多人都知道但在循环里写成i ! 1这种精确比较往往真的会死循环。排查这种问题的方式很简单在循环体里加个计数器超过预期次数直接break或者用i 1这类范围判断替代相等判断。我做代码审查时有个习惯看到while和do...while第一眼看条件表达式是否会变化看到for第一眼看更新表达式是否真的能推进循环。这两眼能挡住大半的死循环 bug。4.2 循环里的闭包陷阱变量还是原来的变量闭包陷阱在事件绑定和异步回调里最常见。前面提过的 setTimeout 打印问题就是一个典型案例再举一个实际业务场景const buttons document.querySelectorAll(.tab-btn); for (var i 0; i buttons.length; i) { buttons[i].onclick function () { console.log(点击了第 i 个按钮); }; }结果是点任何按钮都打出一个数字而且往往是最后一个索引。原因还是var的函数作用域导致所有回调共享同一个i。用let是最省心的修复for (let i 0; i buttons.length; i) { buttons[i].onclick function () { console.log(点击了第 i 个按钮); }; }如果不方便用let用 IIFE立即执行函数表达式也可以for (var i 0; i buttons.length; i) { (function (index) { buttons[index].onclick function () { console.log(点击了第 index 个按钮); }; })(i); }排查这类问题时我一般先看变量声明是var还是let然后把循环体里注册的回调想象成“延迟执行”的代码问自己一个问题这些回调真正执行时循环变量已经变成什么了想清楚这个闭包陷阱十有八九都能避开。4.3 循环方法的性能差异别在不需要优化的时候过度优化前端社区里关于“哪种循环性能最好”的争论不少有人测出for比forEach快有人说for...of最慢。我的观点是在小数组几百几千条的情况下几种循环方式的性能差距小于一个数量级对用户体验没有任何可感知的影响完全不需要纠结。真正影响性能的是“循环体内部做了什么”。比如在循环里反复操作 DOM就是明显的性能杀手。每操作一次 DOM浏览器都要触发重排或重绘几百上千次下来页面就很难受。正确做法是先在循环里拼好数据最后一次性更新视图const container document.getElementById(list); const fragment document.createDocumentFragment(); data.forEach((item) { const li document.createElement(li); li.textContent item.name; fragment.appendChild(li); }); container.appendChild(fragment);这里还用到了DocumentFragment把多次 DOM 操作合并成一次整体插入浏览器开销立刻降下来。这个优化思路我觉得比纠结for还是forEach的性能差异有价值得多。另一个影响性能的点是“循环里做重复计算”。比如每次循环都取array.length老优化教程会说“先存到变量里”。但现代 V8 引擎早就对这类基础操作做了优化手动存不存基本没差别。真正要注意的是循环体内不要有层数过深的嵌套或大量不必要的函数调用。4.4 常见问题速查表把高频问题做一个速查表方便排查时直接对照症状可能原因解决路径循环跳不出去页面无响应条件永远为真 / 没写更新表达式检查条件表达式可变性加最大次数保护forEach里用break报语法错forEach不支持中断控制改用for...of或some点击事件全部取到最后一项的值var闭包共享同一变量用let声明或 IIFE 包裹循环里发请求顺序乱、结果不对forEach不能等待异步完成改用for...ofawaitfor...in遍历对象多出属性原型链上的可枚举属性被遍历用Object.hasOwn过滤循环里频繁操作 DOM 页面卡顿多次触发重排重绘用DocumentFragment批量插入对象键遍历顺序和插入顺序不一致整数键名会按升序重排需要保序时用Map4.5 一个经常忽视的问题空数组与边界条件边界问题看起来不起眼实际最容易出事故。比如空数组时for...of和forEach都是静默跳过但如果你用do...while而循环体里又在没数据时调用了不存在的字段就会直接抛错。我见过这样一个线上问题某页面用while循环读取分页数据接口第一页有数据第二页数量为 0代码里用while (pageData.length 0)作为条件结果没问题。但另一个同事改成了do...while先执行一次循环体再去判断在数据源本来就是空的情况下第一次循环体就拿到空数据再尝试取data[0].name时就崩了。排查到之后我专门在代码里加了对“空结果立即返回”的保护这个场景的教训就是写循环前先问自己——如果一次都不该执行这个循环能正确退出吗5. 从会写到写对循环语句的学习路径建议很多初学者学循环靠的是背语法、刷例题然后直接在项目里套用。我建议换个路径先理解“为什么要有循环语句”再理解“每种循环对应的控制流差异”最后通过“改错题”和“实际场景”来巩固。改错题是效率很高的方式。比如给你一段死循环的while让你在两分钟之内找出原因给你一段闭包陷阱的按钮点击让你解释为什么打印结果不对。这些问题一开始可能想不通但只要在实际调试器里打断点跑一遍观察每次循环时变量值的变化理解就会非常牢靠。实际场景方面可以找几个高频需求练手用循环实现数组去重不限方法用循环把一个多维数组展平成一维数组用循环生成一份“全排列”或“九九乘法表”在大列表接口中用循环配合await分页抓取全部数据内层循环里发现条件满足尝试用标签跳出两层循环。做完这几个题目再回头看for...of、forEach、while这些选择就会有一种“这把工具是干这活的那把工具是干那活的”的感觉而不是死记硬背选哪个。我个人在实际使用中的体会是循环语句是一面镜子它能照出一个人对“变量作用域”“异步执行顺序”“数据结构”这三件事的理解程度。很多人写不好循环根源并不是循环语法不会而是作用域和事件循环的底层机制没吃透。把这三件事理顺了循环问题基本就解决了一大半。希望这篇梳理能帮你把循环语句用得更顺手下次遇到莫名其妙的遍历 bug心里能多几条排查的路线。
返回列表