☰
JavaScript循环语句完全指南:从语法到异步与性能优化
2026/9/26 18:08:02 网站建设 项目流程

在项目里被循环语句卡住过的人,应该不在少数。不管你是刚接触 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...in:

const 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换成let,ES6 的块级作用域会让每一轮循环的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) { // 忘了写 i++,i 永远是 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...of+await
for...in遍历对象多出属性原型链上的可枚举属性被遍历用Object.hasOwn过滤
循环里频繁操作 DOM 页面卡顿多次触发重排重绘用DocumentFragment批量插入
对象键遍历顺序和插入顺序不一致整数键名会按升序重排需要保序时用Map

4.5 一个经常忽视的问题:空数组与边界条件

边界问题看起来不起眼,实际最容易出事故。比如空数组时for...of和forEach都是静默跳过,但如果你用do...while而循环体里又在没数据时调用了不存在的字段,就会直接抛错。

我见过这样一个线上问题:某页面用while循环读取分页数据,接口第一页有数据,第二页数量为 0,代码里用while (pageData.length > 0)作为条件,结果没问题。但另一个同事改成了do...while,先执行一次循环体再去判断,在数据源本来就是空的情况下,第一次循环体就拿到空数据,再尝试取data[0].name时就崩了。排查到之后,我专门在代码里加了对“空结果立即返回”的保护,这个场景的教训就是:写循环前先问自己——如果一次都不该执行,这个循环能正确退出吗?

5. 从会写到写对:循环语句的学习路径建议

很多初学者学循环,靠的是背语法、刷例题,然后直接在项目里套用。我建议换个路径:先理解“为什么要有循环语句”,再理解“每种循环对应的控制流差异”,最后通过“改错题”和“实际场景”来巩固。

改错题是效率很高的方式。比如给你一段死循环的while,让你在两分钟之内找出原因;给你一段闭包陷阱的按钮点击,让你解释为什么打印结果不对。这些问题一开始可能想不通,但只要在实际调试器里打断点跑一遍,观察每次循环时变量值的变化,理解就会非常牢靠。

实际场景方面,可以找几个高频需求练手:

  1. 用循环实现数组去重(不限方法);
  2. 用循环把一个多维数组展平成一维数组;
  3. 用循环生成一份“全排列”或“九九乘法表”;
  4. 在大列表接口中,用循环配合await分页抓取全部数据;
  5. 内层循环里发现条件满足,尝试用标签跳出两层循环。

做完这几个题目,再回头看for...of、forEach、while这些选择,就会有一种“这把工具是干这活的,那把工具是干那活的”的感觉,而不是死记硬背选哪个。

我个人在实际使用中的体会是,循环语句是一面镜子,它能照出一个人对“变量作用域”“异步执行顺序”“数据结构”这三件事的理解程度。很多人写不好循环,根源并不是循环语法不会,而是作用域和事件循环的底层机制没吃透。把这三件事理顺了,循环问题基本就解决了一大半。希望这篇梳理能帮你把循环语句用得更顺手,下次遇到莫名其妙的遍历 bug,心里能多几条排查的路线。

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

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

立即咨询