
前阵子面试一个自称“三年经验”的前端我问了他一个问题用 for 循环遍历一个数组中途想退出该怎么办他脱口而出“用 break 啊”。我又问那用 forEach 呢他愣了几秒支支吾吾说“好像不能 break……那就 return”——然后就没有然后了。这其实是很多前端工程师的真实状态JavaScript 循环的语法早就滚瓜烂熟但一到作用域、异步、性能、可迭代协议这些深层场景就开始含糊。这篇博文我就把这些年踩过的循环坑、总结出的循环用法、排查思路一次性讲透从基础语法到事件循环从手写迭代器到跨语言对照覆盖你实际开发中几乎会遇到的所有循环问题。1. 循环家族全景别再只会写 for1.1 经典三件套for、while、do-while 的适用边界先聊最基础的。for、while、do-while 这三兄弟很多人写代码的时候全靠肌肉记忆很少想过“为什么用这个不用那个”。我的个人习惯是知道确切迭代次数或者有明确的步进逻辑用for不知道次数、只看条件的用while无论条件是否成立都至少执行一次用do-while。// 确定次数遍历数组/生成序列 for (let i 0; i arr.length; i) { // 常规处理 } // 不确定次数不断读取输入直到结束 let line; while ((line readNextLine()) ! null) { processLine(line); } // 至少执行一次菜单展示用户不退出就继续 let choice; do { choice getUserMenuChoice(); } while (choice ! quit);很多教材喜欢说“while 和 for 可以互相替换”这话没错但实际代码里选错会导致可读性灾难。比如搜一个二叉树的路径你根本不知道要循环多少层强行用 for 就得手写栈模拟代码绕得很反过来明确 i 从 0 走到 n-1 的情况你用 while 还得自己维护计数器容易漏更新导致死循环。边界条件也是热点。写for (let i 0; i 10; i)大家都会但写倒数就经常翻车for (let i 10; i 0; i--)一共执行 10 次i 从 10 到 1如果手滑写成i 0就多跑一次i 会变成 0 再退出。这种“差一”错误在二分查找、分页逻辑里危害特别大排查起来又很隐蔽我自己的经验是——凡涉及边界条件的循环第一时间先列几个边界值在脑子里跑一遍不要直接写进代码里靠测试兜底。1.2 forEach、map、reduce数组遍历三兄弟的定位数组的forEach、map、reduce表面上都是“高级循环”但定位完全不一样。forEach只是为了遍历并产生副作用没有返回值map把每个元素映射成新值返回等长新数组reduce把数组收敛成一个值。用错了就是用瑞士军刀开啤酒能用但很别扭。const nums [1, 2, 3, 4]; // forEach适合“遍历后做事情” nums.forEach((n) console.log(n * 2)); // map适合“数组变换” const doubled nums.map((n) n * 2); // reduce适合“聚合成单个结果” const sum nums.reduce((acc, n) acc n, 0);forEach 最坑的一点回调里写 break 或者 return 都没用。break 直接语法报错return 只是结束当前回调对外层循环毫无影响。很多人误以为 return 能“跳出本次循环”在 forEach 里其实等于“忽略这个元素继续跑下一个”。如果确实想在遍历中提前终止有三种替代方案换成 for-of 用 break用some在回调里 return true 来“短路”用every在回调里 return false 来“短路”。// 找第一个大于 3 的数就停下 [1, 2, 3, 4, 5].some((n) { console.log(check, n); return n 3; // 打印到 4 就停 });回调参数的顺序也是一个高频翻车点forEach((item, index) {})第二参数是下标不是数组本身。写反了控制台打印出来的就是一团乱麻。方法适合场景能否提前退出返回值for复杂条件、下标遍历、性能敏感可以 break / continue无forEach遍历并产生副作用不能可抛异常/SOME 模拟无map元素映射不能新数组filter按条件筛元素不能新数组reduce聚合计算不能可抛异常停止任意值for-of可迭代对象可以 break / continue无1.3 for-in 和 for-of一字之差一个遍历键一个遍历值for-in设计出来是给普通对象用的遍历的是可枚举属性名包含原型链上的for-of是 ES6 之后给“可迭代对象”用的遍历的是值。很多人拿for-in遍历数组结果拿到的是 0、1、2 这样的字符串下标如果数组上还有自定义属性也会被无差别遍历出来。const arr [a, b]; arr.custom x; for (const key in arr) { console.log(key); // 0, 1, custom } for (const value of arr) { console.log(value); // a, b }for-in 的正确用法是遍历普通对象但更推荐Object.keys()、Object.values()、Object.entries()三兄弟因为它们不会误上原型链语义更明确。const user { name: Tom, age: 18 }; for (const [key, value] of Object.entries(user)) { console.log(key, value); }那哪些东西能用来 for-of数组、字符串、Set、Map、arguments、NodeList、TypedArray、生成器对象都行因为它们实现了可迭代协议。普通对象没有实现所以for (const key of obj)会直接抛obj is not iterable。这也解释了为什么很多人疑惑“对象为什么不能 for-of”——因为对象没有Symbol.iterator这个入口。2. 循环背后的机制作用域、异步与事件循环2.1 var 和 let一个闭包陷阱引发的“血案”循环里用var还是let区别不只是“语法新旧”而是作用域模型不同。var是函数作用域整个循环共享同一个变量let是块级作用域每次迭代都会创建一个新的绑定。最经典的就是循环里放setTimeoutfor (var i 0; i 3; i) { setTimeout(() console.log(i), 100); } // 输出 3 3 3 for (let i 0; i 3; i) { setTimeout(() console.log(i), 100); } // 输出 0 1 2用var的时候循环结束后 i 已经变成 3定时器回调读取的是同一个内存地址所以全打印 3。用let之后每次迭代都形成了独立的作用域回调捕获的是各自那一轮的 i。这个例子面试考了十年还在考就是因为它把“作用域”和“异步回调”两个知识点焊在了一起。万一你接手的是老代码里面全是 var也有一招经典的“闭包救场”用立即执行函数给每一轮迭代传参。for (var i 0; i 3; i) { (function (n) { setTimeout(() console.log(n), 100); })(i); }我的建议是项目中直接把let作为循环变量的默认选择除非你明确需要共享变量。顺便说一句不只是forfor-in和for-of里的const声明也很好用因为每一轮迭代都是独立绑定能用const就尽量用const好处是防止循环体内误改循环变量。2.2 同步循环会卡死事件循环分片和切块JavaScript 是单线程的浏览器里和 UI 渲染共享一个主线程其实是同一个事件循环。这个时候如果你跑一个几千万次的纯计算循环页面会直接“假死”——点击没反应动画卡住连滚动都动不了。原因就是你的循环把事件循环堵死了后续的任务包括渲染、事件处理全在排队。我调过最离谱的一个线上问题有个表格组件要对几千行数据做多层嵌套的循环重排数据一上来浏览器就白屏转圈。直接在控制台能看到这个循环要跑三秒钟。三秒钟听起来不长但对用户来说就是页面无响应三秒钟体感极其糟糕。解法有三种思路。第一种是分片把一个大循环拆成多个小循环用setTimeout或requestIdleCallback让出主线程让渲染任务能插队进来。const bigArray new Array(2000000).fill(0); let index 0; const CHUNK 100000; function processChunk() { const end Math.min(index CHUNK, bigArray.length); for (; index end; index) { // 处理 bigArray[index] } if (index bigArray.length) { setTimeout(processChunk, 0); } } processChunk();第二种是Web Worker把纯计算放到独立线程主线程只负责接收结果完全不影响 UI。但 Worker 里没有 DOM操作 DOM 的数据得在主线程准备好再传进去。第三种是生成器如果你用for...of消费一个生成器函数每次next()之后可以主动选择暂停配合分片逻辑也能达到类似效果。这里要强调一个观点不是所有循环都必须分片。一个 1 万次的简单遍历对现代 V8 引擎来说就是微秒级你去分片反而画蛇添足降低可读性。我的经验是——单次循环总耗时估算超过 100ms或者涉及超大数组百万量级且循环体内有复杂计算才值得用分片。2.3 循环里的异步并发与串行差在“有没有 await”这是新手理解偏差最大的地方。很多人写循环发网络请求以为每个请求会“依次等完再发下一个”实际上循环本身是同步执行的循环体里的异步函数只要不是await等待就会像连发一样全部“同时”启动。// 错误示范看上去像串行其实是并发 async function fetchAllBad(urls) { urls.forEach(async (url) { const res await fetch(url); // 这个 await 不会阻塞 forEach }); }上面这段代码的await只对当前回调函数生效forEach根本不管回调返回的 Promise循环会瞬间跑完所有请求并发发出。如果你想要串行就必须用for...of或者普通 for 循环然后在循环体内写await。// 正确示范逐个请求 async function fetchAllGood(urls) { const results []; for (const url of urls) { const res await fetch(url); results.push(res); } return results; }反过来如果请求之间没有依赖关系我强烈推荐用Promise.all走并发它会同时发起所有请求等全部完成后统一拿结果性能比串行快一个数量级。const results await Promise.all( urls.map((url) fetch(url).then((res) res.json())) );这里有个进阶知识点for await...of。它配合异步迭代器消费流式数据比如 Node.js 里逐行读取大文件、或者读取请求体的分块数据。for await (const chunk of asyncIterable) { console.log(收到一块数据, chunk); }如果你在循环里用await处理异步数据这就是标准的“暂停-继续”流程整个循环会被拆成很多个微任务片段执行不会阻塞事件循环。理解了这个你就明白为什么“循环里能不能 await”是个伪命题——能不能关键在于用的哪种循环结构。3. 实战拆解能直接抄的循环写法3.1 用 for...of 串行执行异步任务带进度展示实际项目里“串行执行一批异步任务”的场景太多了逐个上传文件、逐条同步记录、顺序调用接口。我先给一个通用模板async function runTasks(tasks, handler) { const results []; for (let i 0; i tasks.length; i) { const res await handler(tasks[i], i); results.push(res); } return results; } // 使用逐个上传并打印进度 runTasks(fileList, async (file, index) { await uploadFile(file); console.log(进度 ${index 1}/${fileList.length}); });为什么我不用forEach上面已经讲过forEach对异步无能为力。为什么不用for...of之外的方式因为for...of不需要自己去管理下标可读性最好而且支持break提前中断这在“一旦某个任务失败就停止后续任务”的场景里非常有用。3.2 循环内的异常处理与提前退出循环里出错了怎么办“错误中断整个循环”和“错误忽略继续下一条”是两种完全不同的需求代码写法也不一样。// 需求 1有一条失败就整体停止 for (const item of items) { try { await process(item); } catch (err) { throw new Error(处理 ${item} 失败终止批量操作, { cause: err }); } } // 需求 2失败记录日志继续处理后面的项 for (const item of items) { try { await process(item); } catch (err) { console.error(跳过失败项 ${item}, err); } }还有一个容易忽略的点finally在循环里是“每轮都执行”不是“整个循环结束后执行”。如果你想把 try/catch/finally 放在循环外面语义就变成了“任意一项报错立刻跳出整个循环然后执行最后的清理逻辑”。这两种结构选哪种取决于业务上是否需要“剥离坏数据后把好数据继续做完”。我自己的习惯是批量数据同步类的操作倾向于记录失败项继续跑最后统一返回失败列表数据强一致的场景遇错就中断回滚。提前退出的几个骚操作也值得记。双循环想直接跳出外层可以用标签outer: for (let i 0; i 10; i) { for (let j 0; j 10; j) { if (i * j 25) { break outer; // 直接跳出两层循环 } } }如果你不想用标签怕团队里别人看不懂可以抽一个findMatched()函数用 return 提前返回结果。灵活性和可读性往往不能兼得我的原则是——标签这种语法能用但少用最好配合注释说明意图如果逻辑复杂到非用标签不可先想想是不是可以用函数拆解掉。3.3 手写迭代器把不“可迭代”的东西变成 for 循环可消费理解了可迭代协议你就能把任何数据结构改造成 for-of 可遍历的。这里我写一个比较常用的Range工具用来生成一个整数区间function range(start, end, step 1) { return { [Symbol.iterator]() { let current start; return { next() { if (current end) { const value current; current step; return { value, done: false }; } return { value: undefined, done: true }; }, }; }, }; } for (const num of range(1, 5)) { console.log(num); // 1 2 3 4 5 }这个例子的价值不只是提供了一个工具函数更重要的是让你看懂了 for-of 到底干了什么它会自动调用对象的[Symbol.iterator]方法拿到一个迭代器然后不断调用迭代器的next()方法直到done变为true。这个过程和手写 while 循环轮询next()是等价的const iter range(1, 3)[Symbol.iterator](); let result; while (!(result iter.next()).done) { console.log(result.value); }理解了这一层很多“诡异”行为就说得通了。比如为什么for...of能遍历字符串因为字符串类型内部实现了[Symbol.iterator]。为什么普通对象不能因为它没有实现这个协议你完全可以自己给对象补一个——不过一般不建议改内置原型用Object.entries更干净。3.4 一个经典练习买房子循环控制题网上有个流传很广的入门题目叫“买房子”经常出现在编程基础循环控制那一章比如 1.5 编程基础之循环控制 16。题目大意是第一年程序员年薪 N 万房价 200 万且每年上涨固定比例比如 10%程序员年薪每年固定增长 X%比如 20%问多少年能买得起房如果 20 年内买不起就输出 Impossible。这道题特别适合用来练循环因为它的核心就是把“变量更新”和“循环退出条件”写对function canBuyHouse(salary, salaryGrowth, housePrice, housePriceGrowth, maxYears) { let money 0; let price housePrice; for (let year 1; year maxYears; year) { money salary; // 每年攒下全部年薪 if (money price) { return year; // 买得起立刻返回 } salary * 1 salaryGrowth; // 年薪上涨 price * 1 housePriceGrowth; // 房价上涨 } return Impossible; } console.log(canBuyHouse(50, 0.2, 200, 0.1, 20));这个题目最好的地方在于它把“先判断再更新”和“先更新再判断”的语序差异暴露得非常清楚如果先涨工资再判断是否买得起结果就会差一年如果忘记更新房价就会得出永远买得起的假象。循环里变量更新的顺序往往比语法本身更容易造成隐蔽 bug。3.5 地址栏小脚本让页面里的视频转个角度聊点轻松的。以前浏览器地址栏可以直接输入javascript:开头的代码来执行一段脚本比如想临时把网页里的视频旋转过来会有人写一行代码document.querySelector(video).style.transform rotate(-90deg);配合循环还能把页面上所有视频统一旋转for (const video of document.querySelectorAll(video)) { video.style.transform rotate(-90deg); }这里有两个知识点第一querySelectorAll返回的是NodeList它实现了可迭代协议可以直接用 for-of 遍历不需要转成数组第二现在谷歌浏览器出于安全考虑已经不允许在地址栏里直接执行粘贴的javascript:代码但你可以打开 F12 开发者工具在 Console 面板里执行同样的代码效果完全一样。顺便解释一个高频疑问javascript:void(0)是什么意思void运算符会计算后面的表达式并返回undefined所以void(0)就是返回 undefined。以前很多a标签的href会写成javascript:void(0)来阻止页面跳转纯属一种“古老约定”。现代前端更推荐直接用a标签的href#配合 JSpreventDefault()或者干脆用 button 元素语义更清晰。你在控制台看到javascript:void(0)相关的报错多半是某个网页旧版链接有残留不是你的 JavaScript 代码出问题。4. 常见问题与排查技巧实录4.1 页面卡死如何定位到是哪段循环惹的祸浏览器标签页变“无响应”是循环问题里最常见也最吓人的一种。原因不外乎三类死循环、超大循环体阻塞主线程、循环内同步请求或无限递归。遇到页面卡死先别慌不要直接刷新刷新会丢失现场。打开 DevTools 的 Sources 面板点击暂停按钮Pause浏览器会中断当前正在执行的 JS并停在调用栈里。你真能看到当前卡在哪一行的循环上while (true) { // 断点停在这里就说明这里在空转 }排查死循环还有一个很笨但很有效的技巧给循环变量打条件断点。比如怀疑某个 for 循环的变量异常右键断点选择“Add conditional breakpoint”填一个可疑条件i 100000循环跑到 10 万次时自动停下这时候你去观察调用栈里的变量值基本能一眼看出问题。我踩过最无语的一个坑是循环条件里依赖一个对象属性结果这个属性在循环体内被意外改写了导致条件永远为真。这种 bug 用肉眼检查很容易漏但用条件断点加 Watch 表达式把循环变量和最外层的条件项都添加到 Watch 面板会非常直观。4.2 循环引用对象互相引用导致的报错“循环引用”这个词在 JavaScript 里有两种完全不同的含义都需要小心。第一种是对象循环引用一个对象直接或间接引用了自己。最典型的就是JSON.stringify遇到循环引用会抛Converting circular structure to JSON。你没法直接序列化这种结构但可以用一种叫WeakMap的机制来做带循环检测的深拷贝function deepCloneWithCycle(obj, seen new WeakMap()) { if (typeof obj ! object || obj null) return obj; if (seen.has(obj)) return seen.get(obj); // 遇到重复引用直接返回 const clone Array.isArray(obj) ? [] : {}; seen.set(obj, clone); for (const [key, val] of Object.entries(obj)) { clone[key] deepCloneWithCycle(val, seen); } return clone; }第二种是模块循环引用a.js import 了 b.jsb.js 又 import 了 a.js某些情况下打包工具会报Cannot access xxx before initialization或者在浏览器控制台看到undefined值。排查思路是把循环依赖的引用拆掉改成事件回调、依赖注入或者动态 import。// 循环依赖的例子a 和 b 互相引用 // 改为 lazy require // b.js let getA; setTimeout(() { import(./a.js).then((m) (getA m.getA)); }, 0);我的经验是模块循环引用往往不是怎么修的问题而是设计上就该重构成单向依赖的问题。先用打包工具的可视化依赖图找出环再把环中间的某条边剪断比在 import 里兜圈子靠谱得多。4.3 看到 err:23 数据错误(循环冗余检查)这锅不归 JavaScript 背这个报错经常让前端摸不着头脑。它长这样err:23 数据错误(循环冗余检查)。听着跟 JavaScript 循环有关系其实完全不是一回事。这是操作系统/文件系统层面的错误全称叫 Cyclic Redundancy CheckCRC错误发生在磁盘读取、文件解压、光盘读取这类场景表示某个文件在存储介质上已经损坏数据校验和过不去。如果你是在解压压缩包或者拷贝文件时看到err:23正确的处理方式是重新下载/拷贝源文件换一个磁盘路径再试用系统自带的磁盘检查工具扫描硬盘坏道。它跟 JavaScript 的 for 循环、while 循环没有一点关系纯粹是硬件/文件系统的问题。报错来源处理方式err:23 数据错误(循环冗余检查)文件系统 / 磁盘重下文件、换磁盘、扫描坏道Uncaught RangeError: Maximum call stack size exceededJS 递归/循环检查递归终止条件或把递归改循环Uncaught TypeError: xxx is not iterableJS 可迭代协议确认对象有 Symbol.iterator这种“长得像但又不是一回事”的报错最容易耽误时间我自己的经验是拿到任何报错第一反应先看它来自什么环境。浏览器控制台的报错是渲染引擎抛的操作系统的报错是内核/驱动抛的分清楚环境再排查至少省一半时间。4.4 跨端桥接场景里的循环陷阱提到“OC 和 JavaScript 互相调用”很多人以为是另一个话题但这里面循环的坑一点不少。以前做过一个 Hybrid 项目客户端原生代码OC 封装把一批数据通过 WebView 桥接到 JavaScript前端拿到一批任务后要循环里边读取回调结果边展示进度。最容易出的问题也是典型的“数组循环 回调时机”问题你用 forEach 去循环桥接返回的任务列表在回调里把数据推到一个全局数组结果由于回调是异步的循环早跑完了回调还没执行后续逻辑拿到的数组始终是空的// 错误 bridgeTaskList.forEach((task) { bridge.invoke(task, (result) { results.push(result); // 这段执行时 forEach 早就结束了 }); }); // 正确 async function runBridgeTasks(taskList) { const results []; for (const task of taskList) { const result await new Promise((resolve) bridge.invoke(task, resolve)); results.push(result); } return results; }这说明一个更通用的道理只要你在循环里碰到了异步回调、事件回调、桥接回调第一时间就应该考虑用 for-of await 或者 Promise.all 去管理生命周期而不是傻等在回调里改闭包变量。4.5 一个真实排查记录表格渲染卡顿的循环解法举个例子。有次同事反馈“表格数据一多页面就卡到没法操作”。我先打开 Performance 面板录了一段性能能看到一长串紫色脚本任务展开发现是一个多层循环大概长这样for (let i 0; i data.length; i) { for (let j 0; j data[i].items.length; j) { // 这里做字符串拼接、对象拷贝、DOM 尺寸读取... } }问题是循环体内频繁读取了 DOM 的offsetWidth每次读取都会强制浏览器重排reflow这个操作在循环里的开销是灾难级的。优化方向有两个一是把 DOM 读取移出循环一次读完存成变量二是把数据量大的部分用分片处理。改成下面这种就顺滑多了const tableWidth container.offsetWidth; // 只读一次 let row 0; const TOTAL data.length; function renderChunk() { const BATCH 50; const end Math.min(row BATCH, TOTAL); for (; row end; row) { // 只做纯计算 构建字符串/对象 } if (row TOTAL) { requestAnimationFrame(renderChunk); // 渲染一帧后继续 } } renderChunk();这个案例教会我一件事循环性能瓶颈十有八九不在循环语法本身而在循环体内干了什么。循环只是放大镜真正的问题通常在体内部被循环放大出来了。5. 循环的边界跨语言对比与特殊形态5.1 不同语言里的循环思想通用语法各玩各的很多前端会写 JavaScript顺带也写过 Python、Shell、SQL甚至接触过 Oracle 数据库的 PL/SQL。每个语言的循环语法各有脾气值得放在一起对比。Python 的for...else是一个很容易绕晕的特性当for循环正常走完没有被 break 打断时执行else块如果break了else就不执行。写个例子感受一下for n in range(2, 10): if n 5: break else: print(循环正常结束) # 不会执行因为 break 了Shell 的for循环更像“遍历一个词列表”跟 C 风格的下标循环完全是两种味道for file in *.txt; do echo $file doneOracle 的 PL/SQL 里循环有三种基本形态LOOP ... END LOOP无边界死循环靠 EXIT 退出、WHILE ... LOOP、FOR i IN 1..10 LOOP整数范围遍历。还有个细节是FOR的计数变量默认递增 1不需要自己声明。这些语言对比的价值在于循环的本质无非是“初始化—判断—更新—块执行”四个步骤的排列组合。你理解了本质走到任何语言都能快速套用它的语法变体。有次看到一个指标公式平台类似股票软件的公式编辑器也有人问“有没有循环指令”其实大部分公式语言都提供了类似for或while的语句只是名字不叫 JavaScript 循环而已。5.2 循环单链表与图的环检测循环不止“遍历”这一种形态编程里的“循环”除了语句层面的 for/while还有一个身份是指数据结构中的环比如循环单链表。双向循环链表大家都见过而链表里的环检测是一个经典算法问题怎么判断一个链表是否存在环最经典的方法是快慢指针Floyd 判圈算法一个指针每次走一步另一个每次走两步如果链表有环两个指针一定会在环内相遇如果没有环快指针会先到终点。function hasCycle(head) { let slow head; let fast head; while (fast fast.next) { slow slow.next; fast fast.next.next; if (slow fast) { return true; } } return false; }这算法不难但它特别容易在“边界条件”上翻车链表为空要返回 false只有一个结点的链表不可能有环。写这个 while 循环前先想清楚退出条件这又回到了“循环先列边界”的原则上。5.3 RNN、循环冗余检查等“循环”与 JavaScript 循环的区别互联网搜索里一聊到“循环”会混进来大量领域名词循环神经网络RNN、门控循环单元GRU、深度循环模型、循环冗余检查……它们跟 JavaScript 里的循环几乎没有直接关系但容易让人搜花眼。RNN 里的“循环”指的是网络结构在时间维度上反复复用同一个模块数据在时间步之间传递而不是编程语言里for循环。门控循环单元GRU是 RNN 的一种变体网络结构改进长短期记忆网络LSTM的简化版本通常用 Python 和深度学习框架实现跟前端写 JavaScript 循环的完全是另一套知识体系。如果你看到标题带“深度循环模型”那大概率是深度学习内容不是 JavaScript 循环。循环冗余检查里的“循环”来自 CRC 算法对二进制数据的多项式除法运算是一种校验算法。它既不是编程语句也不是某个语言的专属概念读出来的错误提示“err:23 数据错误(循环冗余检查)”常见于文件损坏场景前面已经细说。把这些概念分清楚的好处是你搜问题的时候能快速判断哪些搜索结果跟自己真正遇到的问题相关不会一搜“循环”进入另一个技术圈子出不来了。5.4 循环反模式清单这些写法尽量别出现在发版代码里最后列一个我这些年做 code review 时常用的“循环反模式”检查清单循环体内修改数组长度。比如在 for 循环里根据条件splice当前数组会导致下标错位跳过元素。正确做法倒序遍历或者先筛选再处理。循环体内频繁操作 DOM。每操作一次 DOM 都可能触发重排/重绘性能急剧下降。正确做法字符串或文档碎片先在内存里组装好最后一次性挂载。循环里发同步请求。浏览器上发同步 XHR 会直接锁住整个 UI这个反模式基本等于自杀式写法。正确做法异步请求 await/Promise.all。正则表达式在循环里反复用到但不清 lastIndex。粘滞匹配的正则每次执行会从上一次结束的位置继续不清零结果全乱。正确做法在循环外声明正则或在每次循环里重置lastIndex 0。嵌套循环不思考时间复杂度。两层 10 万级数组嵌套就是 100 亿次操作浏览器肯定扛不住。正确做法用哈希表/Set 把查找从 O(n) 降到 O(1)用空间换时间。这些反模式有一个共同特点它们不会立刻报错而是“跑起来有点慢”“偶尔结果不对”极难通过单元测试发现却会在生产环境给你惹出大麻烦。所以做代码评审的时候我每次都会特别关注循环这一小块见过太多线上事故都是从一段看似无害的循环开始的。最后再分享一个调试循环的小技巧这是我最近特别喜欢用的遇到一个特别慢的循环又不想等它跑完可以直接在 DevTools 里对循环那行加“条件断点”。比如for (let i 0; i 100000; i)右键断点选 “Add conditional breakpoint”输入i 523循环跑到第 523 次时就停下来你再逐步往后单步执行观察变量的变化轨迹。比起刷屏打 console.log这种方式干净得多也快得多。如果你也常被循环里的诡异 bug 折腾不妨试一次就会发现原来定位问题可以这么顺畅。