
JavaScript 循环这四个字在搜索引擎里搜出来的东西特别杂有讲 for/while 语法的有讲 forEach 遍历数组的还有一堆事件循环循环引用报错的。我当初学的时候就有个疑问这到底是一个知识点还是四个知识点答案其实是四个而且每一个都能单独拿出来聊一上午。今天这篇就按我自己的理解把 JavaScript 循环这件事从头到尾捋一遍从最简单的 for 语法到最容易翻车的事件循环再到报错排查最后补一块跟其他语言循环的对比全是实操角度的真实经验。1. 先搞清楚JavaScript里的循环到底有几种1.1 语法循环的五兄弟for、while、do-while、for...in、for...of先说最基础的。JavaScript 语法层面有五种循环写法很多初学者背了又忘原因是没有理解它们各自出生的目的。for 循环是最经典的计数型循环三个表达式把初始值、条件和步进写在一行里适合我知道要循环多少次的情况for (let i 0; i 5; i) { console.log(i); // 0 1 2 3 4 }while 循环适合我不知道要循环多少次只知道什么时候停的场景。比如读取文件直到读完、轮询某个条件直到成立let count 0; while (count 5) { count; }do-while 循环和 while 的区别只有一个先执行一遍循环体再判断条件。所以不管条件成不成立循环体至少跑一次。这个特性在先处理再检查的场景里特别有用比如输入校验时先弹出一次输入框再判断要不要继续弹。for...in 循环是用来枚举对象属性的注意是属性不是值const obj { name: 张三, age: 30 }; for (const key in obj) { console.log(key, obj[key]); // name 张三 / age 30 }for...of 循环是 ES6 引入的用来遍历所有可迭代对象包括数组、字符串、Set、Map拿到的是值本身const arr [10, 20, 30]; for (const value of arr) { console.log(value); // 10 20 30 }很多新手分不清 for...in 和 for...of我提供一个特别土但特别好记的口诀in 是索引of 是值。for...in 用在对象上拿键数组上拿下标而且拿到的下标是字符串for...of 用在数组上拿元素对象直接用会报错因为普通对象不可迭代。把两个混用是初级阶段最容易出的 bug。1.2 数组方法也算循环forEach、map、filter、reduce的真实身份接下来这层就微妙了。像 forEach、map、filter、reduce 这些数组方法从语法角度看不属于循环语句但它们的本质就是循环——内部帮你遍历了一遍数组再把每个元素交给回调函数处理。[1, 2, 3].forEach((item) { console.log(item); // 1 2 3 });这段代码和 for 循环做的事情一模一样只是把手动控制指针移动这件事封装掉了。我见过不少工作两三年的前端写的代码里全是 forEach从来不用 for 循环也没什么问题。但有一点必须在心里有数forEach 不支持 break 和 continue想提前退出遍历只能靠 throw 异常这种歪门邪道不推荐。如果你有遍历到某个条件就停的需求最干净的做法是用 some 或 every[1, 2, 3, 4, 5].some((item) { if (item 3) return true; // 相当于 break });用 some 替代 forEach 实现提前退出是我在实际项目里最常用的技巧。map 适合每个元素映射成新元素filter 适合挑出符合条件的元素reduce 适合把整个数组合并成一个值。它们和 forEach 一样本质都是循环的不同变体只不过职责边界更清晰。2. 不同循环的适用场景与性能差异2.1 for 与 while什么时候用哪个才顺手老生常谈的问题但值得认真回答。我自己的经验是能用 for 写清楚的不要用 while。for 把循环三要素集中在一行阅读者一眼就能看出循环从哪里开始、到哪里结束、每次走几步while 只给一个条件循环变量的初始化和更新散落在循环体外面读代码的人得靠猜。看一个典型例子同样是遍历一个队列// for 版初始化、条件、步进在一行 for (let i 0; i queue.length; i) { process(queue[i]); } // while 版步进在循环体里容易漏 let i 0; while (i queue.length) { process(queue[i]); i; // 这行忘了写就是死循环 }那 while 是不是完全没用也不是。当循环条件不是计数而是某个状态时while 更自然。比如我需要不断调用一个接口直到返回的 status 是 success 为止这个场景用 while 比 for 顺眼得多因为循环次数本身就是未知的硬套一个计数器属于自欺欺人。do-while 的适用场景更窄我工作中真正用到的次数一只手数得过来主要在第一遍无论如何要执行的校验逻辑里。2.2 for...of 与 for...in迭代值和枚举属性的区别这俩混用造成的 bug我在 code review 里见过太多次了。有一条核心结论必须记住数组遍历请用 for...of不要用 for...in。for...in 遍历数组时的行为很坑。首先它拿到的是可枚举属性不只是索引还包括数组上自定义的属性其次索引是字符串类型参与数值运算时容易出幺蛾子最后它的遍历顺序不保证是数值升序。实测一段代码const arr [1, 2, 3]; arr.custom hello; for (const key in arr) { console.log(key); // 0 1 2 custom }custom 这个自定义属性也被遍历出来了如果里面逻辑是 arr[key] * 2结果就会出现 NaN。for...of 就没有这个问题因为它是基于 Symbol.iterator 迭代器协议的只关心可迭代内容不关心额外属性。总之记住这句话for...in 是设计给对象用的for...of 是设计给数组、字符串这一类可迭代数据结构用的。在对象上想拿键值对用 for...in 或 Object.keys、Object.entries 都可以但在数组上别用 for...in。2.3 数组方法遍历的取舍可读性优先还是性能优先性能问题我得说句实话在绝大多数业务代码里循环的性能差异根本不构成瓶颈。一个数组几百上千个元素for 和 forEach 的耗时差距是微秒级的用户感知不到。真正需要抠性能的是两种场景一是超大数组几万、几十万条数据且每次循环内部有高开销操作二是循环嵌套层数很深性能被放大 N 次方。如果非要排序我实测下来现代 V8 引擎里for 索引循环是最快的尤其当数组长度被缓存成局部变量时// 缓存长度避免每轮循环都查一次 length for (let i 0, len arr.length; i len; i) { process(arr[i]); }其次是 for...of因为迭代器协议有轻微开销再往后才是 forEach、map 这一类需要回调函数调用的方法。但真实业务里我建议优先考虑可读性不建议为了那几微秒把代码写得云里雾里。有个折中方案明确需要性能、又需要 break/continue 的逻辑用 for纯粹的遍历操作用 forEach 或 for...of 更好读。2.4 break、continue、return 在不同循环里的行为差异这个点看着基础实际坑了不少人。break 是跳出当前整个循环continue 是跳过本轮、进入下一轮这两个在 for、while、for...of 里行为一致。需要注意的是 forEach 里面根本没有 break 和 continue 的容身处写了直接语法报错。return 就更容易搞混了。在普通函数中使用 for 循环return 会直接终止整个函数循环自然也被终止但在 forEach 的回调函数里写 return只代表结束当前这一轮的遍历相当于 continue不会终止整个 forEach也不会让外层函数返回function test() { [1, 2, 3].forEach((item) { if (item 2) return; console.log(item); // 只会打印 1 和 3 }); console.log(执行结束); // 这行照样执行 }我见过有同事在 forEach 里写 return 以为能提前退出整个函数结果下面代码继续跑数据又被写了一份最后排查了半天。这就是数组方法不算真正的循环语句的直接后果——它的流程控制能力天生比 for 弱一截。所以在回调函数里用到 return 时一定要想清楚这个 return 到底是作用于哪一层。3. 事件循环JavaScript另一维度的死循环3.1 单线程的 JavaScript 为什么需要事件循环如果说前面聊的都是怎么写循环那事件循环就是 JavaScript 在运行时内部是怎么一直循环的。很多前端面试环节都会问事件循环但理解它的前提是先接受一个事实JavaScript 是单线程语言。单线程意味着同一时间只能干一件事但如果所有的事都排队等着一个卡住后面全卡住页面就白屏了。所以 JavaScript 引擎采用的是事件循环模型主线程运行当前代码碰到耗时的异步操作网络请求、定时器、DOM 事件就把它挂起交给浏览器或 Node 环境去处理自己继续执行后面的同步代码。等异步操作完成了它的回调函数会被塞进一个任务队列等主线程空闲下来再从队列里取出来执行。这个等主线程空闲→从队列取任务→执行→再等下一个任务的过程就是一个循环。我自己理解事件循环时用了个生活类比它就像一个只有一位服务员的餐厅桌上有好几桌客人的订单。服务员先把能立刻上的菜都端上端不了的需要后厨慢慢做就记下来等后厨做好了再喊服务员去端而服务员每端完一桌就会去看下一张还有没有能上的菜。这个端完就去看下一单的机制就是事件循环。3.2 宏任务与微任务setTimeout、Promise、async/await 的真实顺序事件循环里最核心、也最常被考的就是宏任务和微任务的顺序。JavaScript 里任务分成两类宏任务和微任务。宏任务包括setTimeout、setInterval、I/O 操作、UI 渲染事件、MessageChannel 等。微任务包括Promise 的 then、catch、finally、queueMicrotask、MutationObserverNode 里还有 process.nextTick它比普通微任务更先执行。执行顺序的规则是每执行完一个宏任务都会清空当前所有微任务然后再取下一个宏任务。一个循环轮次大致是宏任务 → 所有微任务 → 下一个宏任务。用代码验证最快setTimeout(() console.log(setTimeout), 0); Promise.resolve().then(() console.log(promise1)); Promise.resolve().then(() console.log(promise2)); console.log(同步代码);打印顺序一定是同步代码 → promise1 → promise2 → setTimeout。原因就是 Promise 回调属于微任务在第一个宏任务结束前就被清空了setTimeout 的回调属于下一个宏任务必须等微任务队列清空后才轮到。这个顺序决定了微任务总是插在宏任务之前执行。async/await 本质是 Promise 的语法糖。async 函数里的 await 后面有个隐藏的 Promise.resolve().then()所以 await 之后的代码也属于微任务。理解这一点很多看起来顺序奇怪的代码就能看懂了。3.3 事件循环中容易踩的延时坑与面试题拆解说两个我实际踩过的坑。第一个是 setTimeout 不保证准时执行。我在做倒计时功能时用 setTimeout 的延时去推进位置结果页面切换到后台再回来倒计时就错乱了。原因很简单浏览器会节能降频后台页面的定时器会被合并或延迟事件循环不是准点发车而是有空就做。正确的倒计时写法是每次触发时用当前时间重新计算剩余时间而不是累加固定步长。第二个坑是for 循环 setTimeout这个经典组合for (var i 0; i 5; i) { setTimeout(() console.log(i), 0); } // 输出5 5 5 5 5var 声明的 i 是函数级作用域循环结束后 i 已经变成 5而 setTimeout 回调是宏任务等它执行时 i 早就不是当初循环时的 i 了。解决办法有三把 var 改成 let每次循环创建独立绑定或者用 IIFE 包裹住 i或者用 bind 传参。这个题面试被问烂了但工作中的确有人因为没理解事件循环里的延时执行看的不是定义时的值而是执行时的值写出过线上 bug。再追问一层如果循环里是 Promise 而不是 setTimeout结果又会不同for (let i 0; i 5; i) { Promise.resolve().then(() console.log(i)); } // 输出0 1 2 3 4因为微任务在循环结束前就插入了当前事件循环的队尾而且 let 保证了每次循环的 i 都是独立的等微任务执行时拿到的是各自那轮的值。这俩对比一下宏任务和微任务的区别就彻底清楚了。4. 循环引用的坑与常见报错排查4.1 JSON.stringify 与 Converting circular structure to JSON第三种循环不是我们主动写的循环而是数据结构里出现了循环引用——对象内部有一个属性直接或间接指向了它自己。最常见的表现是 JSON.stringify 直接抛错const obj { name: test }; obj.self obj; JSON.stringify(obj); // Uncaught TypeError: Converting circular structure to JSON这不是代码 bug而是 JSON.stringify 在遍历对象属性时发现这个对象我见过而且还没有序列化完又遇到了它等于走进了死循环所以它选择直接抛异常来保护自己。排查思路很直接报错信息里的 Converting circular structure 已经明说了原因接下来就是沿着对象的属性逐层找谁指向了自己。4.2 实际业务里最常产生循环引用的三类场景第一类是对象自引用比如上面那种 obj.self obj测试代码里常见生产代码里也偶尔出现——给对象挂了一个指回自身的快捷方式。第二类是两个对象互相引用。比如 A 对象里存了 BB 对象里又存了 A。最常见的是父子节点关系树形结构里每个节点存了 parent 和 childrenchildren 里嵌套着子节点子节点又通过 parent 指回父节点形成闭环。第三类是DOM 引用之间的循环。给一个元素对象挂载了另一个元素或自定义属性而那个元素又引用了回来比如通过 dataset 存对象引用或者挂事件处理函数时在函数上绑了元素本身。这类循环引用在低版本 IE 时代还可能导致内存泄漏现代引擎的垃圾回收器已经能处理无法从根部到达的循环引用但如果引用链还被外部全局变量持有照样释放不掉。4.3 排查与解决方案WeakMap、visited 集合与序列化黑名单排查循环引用我常用的手段是写一个深拷贝或深度遍历函数时维护一个已访问集合。如果遍历过程中发现某个对象已经在集合里就说明有循环。用 WeakMap 存已经访问过的对象最合适因为 WeakMap 的键是弱引用不会影响垃圾回收function hasCircular(obj) { const seen new WeakSet(); function walk(value) { if (value null || typeof value ! object) return false; if (seen.has(value)) return true; seen.add(value); for (const key of Object.keys(value)) { if (walk(value[key])) return true; } return false; } return walk(obj); }遇到需要 JSON.stringify 的数据可以先把循环引用的属性值替换成可序列化的形式或者实现 toJSON 方法告诉 JSON.stringify 这个对象该怎么处理自己的循环属性。序列化场景还有一种更省事的思路直接把循环引用的属性从拷贝对象里剔除掉根本不让它参与到后续的数据传输里。4.4 顺带辟谣热搜里的数据错误(循环冗余检查)和 JavaScript 无关写这篇的时候我瞄了一眼热词发现有个循环冗余检查也带循环两个字。这个名词和 JavaScript 半点关系没有它是 Windows 文件系统在做 CRC循环冗余校验时发现磁盘扇区数据异常抛出的错误通常出现在移动硬盘、U盘或老旧的机械硬盘上。搜索引擎把err:23 数据错误(循环冗余检查)和JavaScript 循环关联在一起纯粹是因为中文里都出现了循环这个词。做前端的如果遇到这个报错建议先换个盘符插口试试或者用磁盘检测工具扫一遍跟写代码没任何直接关系省得乱排查方向。5. 和其他语言循环的对比与迁移扫盲5.1 Python 的 for...else 和 JS 的循环看似相似坑在细节因为热词里出现了不少 Python 循环的内容我特意查过一圈资料后来发现从 Python 转来写 JavaScript 的人最容易在两个点上犯迷糊。第一个是 Python 的 for...else。Python 里 for 循环后面可以跟一个 else 块这个 else 会在循环**正常结束没有被 break 打断**时执行。JavaScript 没有这个语法直接搬写法是会报错的。想实现同样的逻辑需要用一个标志位记录是否提前退出let found false; for (const item of list) { if (item.id targetId) { found true; break; } } if (!found) { // 模拟 Python 的 for...else没找到才执行 }第二个是 Python 的 range。Python 里for i in range(5)很自然JS 里没有 range 函数很多人会用数组长度模拟或者直接 for 计数。ES6 之后可以用展开语法生成一个数字数组[...Array(5).keys()]但可读性并不比普通 for 好我一般老老实实写 for。5.2 Shell、SQLOracle循环写法的迁移要点Shell 脚本里最常见的 for 循环是这种for i in {1..10}; do ... done它其实更像 JS 里的 for...of——遍历的是一个列表而不是维护一个计数器。所以从 Shell 过来写 JS 的人看到 for...of 会觉得很亲切但看到for (let i 0; i n; i)这种写法反而会不习惯。我的建议是在 JS 里遍历数组、集合时直接用 for...of需要数值索引时才用传统的三表达式 for不要硬把 Shell 的风格翻译过来。Oracle 数据库里的循环主要是 PL/SQL 的 LOOP、FOR 和 WHILE还有游标CURSOR循环。游标循环的本质和 JS 遍历数组很像一行一行地取数据取完为止。唯一要注意的是前端代码里没有游标取完这个概念遍历完数组就结束了不需要显式关闭资源。反过来如果后端返回给你的数据量太大JS 循环里又做了大量计算页面就会卡顿——这也是从后端习惯转到前端时必须接受的新问题。5.3 从别的语言转来写 JS 循环最常见的四个误区我简单汇总一下跨语言转过来时的四个高频误区每个斗都有人踩过第一个误区是以为 var 和 let 在循环里的行为一样。实际上 var 没有块级作用域循环变量在循环结束后仍然存在而且定时器、异步回调里拿到的是同一个变量。从 Java、C 转过来的人最容易被这个坑绊倒。第二个误区是在 for 循环里用 const。Java 的 foreach 里变量是有效的JS 的 for...of 也可以用 const但传统三表达式 for 里用 const 是无效的因为 i 本身就是重新赋值。很多人第一次写for (const i 0; i 5; i)直接报错原因就在这里。第三个误区是觉得 forEach 一定比 for 简单。forEach 只是写法简短但它无法 break、无法 return 外层函数也没有索引虽然回调的第二参数可以拿到索引在某些场景下反而比 for 复杂。我的经验是单轮简单操作用 forEach多条件判断、需要中途退出、需要操作索引的用 for 或 for...of。第四个误区是忽略数组稀疏对循环的影响。JS 数组可以有空位for...of会跳过空位吗不会它拿到的是 undefined。forEach反而会跳过空位const arr [1, , 3]; arr.forEach((item) console.log(item)); // 1 3空位被跳过 for (const item of arr) { console.log(item); // 1 undefined 3 }这个差异我在处理从后端返回的稀疏数据时踩过确实花了不少时间去排查。6. 实操总结把循环写成可复用的工具函数6.1 手写一个支持异步的 forEach前端开发里最常见的需求之一遍历数组但每一项都要发请求而且要求串行执行。如果直接用 forEach回调里的 await 并不会让 forEach 停下来等因为 forEach 里的回调是被逐个调用的它不等待 Promise 完成// 错误示范会一次性把所有请求都发出去 list.forEach(async (item) { await api(item); }); console.log(结束); // forEach 不会等异步完成正确的串行版本用法很简单——用 for...of 或普通 for 循环配合 awaitasync function serialForEach(list, fn) { for (const item of list) { await fn(item); } }这个工具函数只有三行但解决了 90% 的遍历数组串行异步问题。如果还需要处理并发上限比如最多同时发 5 个请求那就得用更完整的并发控制函数了考虑用 map 配合 Promise.all 或自己写一个带 concurrency 参数的循环这一点后面有机会单独聊。6.2 循环里做延时执行sleep 循环的两种写法有些场景需要循环里间隔一段时间再继续比如轮询接口、播放列表、动画帧控制。直接在循环里写 setTimeout 是不行的因为 setTimeout 不会阻塞循环等所有 setTimeout 都注册完回调还没有一个执行。两个方向可以解决方向一用 Promise 封装 sleep在 for 循环里 awaitfunction sleep(ms) { return new Promise((resolve) setTimeout(resolve, ms)); } async function poll() { for (let i 0; i 5; i) { const res await fetchData(); console.log(res); await sleep(2000); // 每轮之间等 2 秒 } }方向二用递归 setTimeout每次回调里判断是否继续function loopWithDelay(list, index 0) { if (index list.length) return; process(list[index]); setTimeout(() loopWithDelay(list, index 1), 500); }两种写法我都用过。轮询接口用方向一更好读在 Canvas 动画里逐帧处理序列数据方向二更直接因为不需要 async 函数包裹。要特别注意方向二如果 list 特别长setTimeout 的定时器是逐步创建的不会一下子全堵在队列里这一点比直接 for 循环快效果好。6.3 循环调试三件套console.time、断点条件与边界打印调试循环最怕的是不知道在哪一轮出了问题。我给自己固定了三件套遇到循环 bug 就按顺序排查。第一件是console.time 量性能。循环执行前 console.time(loop)结束后 console.timeEnd(loop)就能看到整段循环耗时。如果耗时异常说明循环里某个操作开小差了比如做了重复 DOM 查询、深度拷贝或者数组长度每轮都在变化。第二件是给断点加条件。在 DevTools 的 Sources 面板里打断点后可以右键断点选 Edit breakpoint输入条件表达式比如i 42这样循环跑到第 42 轮才会停下来避免手动不断点跳过按钮。第三件是边界打印。循环的常见 bug 往往出在边界i0 时、ilength-1 时、数组为空时。我会在循环开头加一个临时的 console.log(i, arr[i])尤其关注这三个位置很多问题一打印就露馅了。如果数据量大、打印太多可以加个条件只打印这些边界值for (let i 0; i arr.length; i) { if (i 0 || i arr.length - 1) { console.log(boundary:, i, arr[i]); } // 业务逻辑 }调试完记得把这些临时代码删干净不然生产环境打印一堆东西既拖性能又容易暴露敏感数据。最后再分享一个我自己的习惯写循环时先问自己三个问题——这个循环的次数是已知还是未知循环体里有没有异步操作有没有可能提前退出把这三件事想清楚再决定用 for、while、for...of 还是数组方法。很多循环 bug 都不是循环本身的语法写错了而是用错了场景。把这套思路记住比背任何循环 API 都管用。