"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、MutationObserver,Node 里还有 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 对象里存了 B,B 对象里又存了 A。最常见的是父子节点关系:树形结构里每个节点存了 parent 和 children,children 里嵌套着子节点,子节点又通过 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、SQL(Oracle)循环写法的迁移要点
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 循环,配合 await:
async 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 循环里 await:
function 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 往往出在边界:i=0 时、i=length-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 都管用。