YAOTU INSIGHTS

JavaScript事件循环详解:宏任务、微任务与页面卡顿排查

JavaScript事件循环详解:宏任务、微任务与页面卡顿排查
1. 单线程的 JavaScript凭什么不卡先说个场景。我之前排查一个线上页面故障用户反应“点按钮没反应页面整个冻住”。看了复现视频页面确实卡了好几秒才恢复。同行第一反应往往是“是不是接口挂了”“是不是渲染卡了”但追下去发现根因是一段纯前端的循环逻辑一个数据量很大的数组排序加上每次比较时还做了深拷贝几万条数据跑下来主线程吃了好几秒。这件事的底层原理就是 JavaScript 事件循环。理解它你才算真正看懂了“为什么单线程的 JS 能异步”“为什么 setInterval 会丢帧”“为什么 async/await 看起来像同步代码”以及最关键的一条——“为什么一个愚蠢的同步循环能把整个页面冻死”。先建立一个基本共识JavaScript 是一门单线程语言。这里的单线程指的是它只有一个主线程来执行代码同一时刻只能做一件事。这个设计是有历史原因的——脚本语言最初只是在浏览器里操作 DOM、处理用户交互如果同时有两个线程去改动同一个 DOM 节点你没法确定页面应该听谁的就算加锁锁也解决不了所有竞态问题反而引入死锁风险。所以干脆规定所有 JS 代码都在同一个“执行流”上串行跑。那问题来了单线程意味着一次只能干一件事那浏览器同时处理鼠标点击、网络请求、定时器、动画难道不是“同时”吗1.1 调用栈当前正在做的事第一块基石是调用栈。你可以把调用栈理解为“当前这条执行线上还没执行完的函数清单”。JS 引擎在执行代码时遇到一个函数调用就把这个函数压入栈顶函数执行完弹出栈顶回到上一层继续。function a() { console.log(a start); b(); console.log(a end); } function b() { console.log(b); } a();这段代码的流程就是全局执行上下文入栈调用 aa 入栈输出 a start调用 bb 入栈输出 bb 弹栈回到 a输出 a enda 弹栈程序结束。这就是“调用栈”这个名字的由来——它只负责记录当前这一刻正在执行的嵌套调用栈是 LIFO后进先出所以你看到的控制台输出永远是嵌套结构先展开、再收回。有一个很直观的现象可以验证栈的存在递归没有出口时报错。function foo() { foo(); } foo(); // RangeError: Maximum call stack size exceeded这就是大家常说的“爆栈”。不是内存被彻底吃光了而是栈帧数量超过了引擎设定的上限一般几万到几十万不等。所以我做代码评审时看到一个递归函数没有终止条件或者递归深度明显和输入规模正相关会直接要求改写成循环或加缓存。这不是风格偏好而是栈真的会爆。1.2 等待区回调函数来自哪里调用栈解决了“现在做什么”但真正让单线程 JS 能异步的是它背后有一套“等待区 调度机制”。浏览器提供的异步 API——比如 setTimeout、Promise、fetch、事件监听——在执行时并不会在 JS 主线程里傻等而是把任务交给浏览器的其他线程去处理等结果有了就把回调函数丢进一个队列里排队。这个队列就是常说的“任务队列”或“消息队列”。核心规则是主线程上的同步代码执行完了才会从队列里拿一个任务进来执行。队列里的回调不能插队不能中断正在执行的同步代码。注意“同步代码执行完”这个限定词它意味着如果在主线程里写了一个死循环那么队列里的所有回调永远等不到执行的机会——页面就是在这种状态下冻住的。这解释了开头那个线上问题不是网络慢了不是浏览器不行而是主线程被同步计算霸占队列里那些响应点击、更新界面的任务全部排队饿死了。1.3 事件循环最朴素的版本既然有队列就必须有个“调度员”不断去检查栈空了没有空了就去队列拿下一个任务。这个“调度员”就是事件循环。事件循环的本质是一个死循环伪代码写出来非常简单while (true) { if (栈为空 队列里有任务) { 拿出队首任务执行它的回调; } }每个被拿出来的队首任务都会在栈里被完整执行完包括它内部嵌套的同步调用执行完后再回到循环开头看下一个任务。注意这里“任务”指的是一个完整的执行单元——一次鼠标点击回调、一个 setTimeout 回调、一次 fetch 后的 then 回调都各自是一个任务。我见过很多人把事件循环理解成“一个循环在处理所有回调”方向对但太模糊。真正的关键在于这个循环决定了任务的调度顺序和优先级。而优先级的区分就是宏任务和微任务的由来。2. 宏任务与微任务两条队列的博弈把事件循环等价于“排队一个个执行回调”在 ES6 之前大致没问题。但 Promise 出现后回调的调度方式变复杂了因为你不可能让 Promise.then 的回调去和用户点击、定时器这些任务排在同一张队伍里——那会导致微任务的响应延迟明显变高。所以现代规范把任务分成了两大类宏任务macrotask和微任务microtask。2.1 为什么必须区分微任务想象一个场景你在 Promise 里同步 resolve 了一个值后面立刻跟上 .then 回调。这个 then 里的回调逻辑上是“紧跟当前代码之后”执行的。但如果把它扔进宏任务队列那它至少要等到当前任务执行完、再等到下一轮事件循环才能执行。如果前面还有一大堆排队的老任务这个 then 回调就要等待很久。这显然不合理。于是微任务被设计出来了当一个宏任务执行到尾声时事件循环不会立刻去拿下一个宏任务而是先把当前宏任务期间产生的所有微任务全部执行干净之后才可能进入下一次事件循环迭代。微任务队列是“插队”的存在但只能在两个宏任务的缝隙里插。所以 Promise.then、queueMicrotask、MutationObserver 这些 API 产生的回调都是微任务。而 setTimeout、setInterval、I/O 回调、UI 交互事件回调、MessageChannel 等都属于宏任务。2.2 一次事件循环迭代的完整流程把完整流程列出来很多关于执行顺序的疑问就能直接消除从宏任务队列里取出队首的一个任务压入调用栈执行。所谓“取出一个任务”是取出整个任务单元这个任务单元内部的同步代码会一口气执行完。该宏任务执行过程中产生的微任务会按顺序依次加入微任务队列。注意 Promise 回调也是在这里安排的——它们不会马上执行而是等当前宏任务的同步代码全部结束、调用栈空了之后才轮到微任务队列登场。当前宏任务执行完毕调用栈清空后事件循环去检查微任务队列不为空就把微任务队列里的所有任务一个一个取出来执行直到微任务队列清空。特殊情况下微任务执行过程中又产生了新的微任务这些新微任务会追加到微任务队列尾部在当前循环里继续被执行直到微任务队列彻底为空。这也就是为什么递归调用 Promise.resolve().then 能无限循环而不给宏任务机会的原因——它根本不归还控制权。微任务队列清空后事件循环可能会触发浏览器渲染具体时机见后面的第 4 章。回到第 1 步继续取下一个宏任务。这里有一个非常反直觉的点微任务队列的优先级高于渲染。也就是说如果在微任务里不断产生新的微任务浏览器永远没有机会渲染页面页面同样会卡顿。这种问题在真实项目里遇到过——某个 SDK 在 then 链里递归调度每次微任务都 append 一个 DOM 节点结果页面白屏了很久。2.3 用一道题把调度规则吃透纸上谈兵不够直接上一段经典代码我们从头到尾推演一遍console.log(1); setTimeout(() { console.log(2); }, 0); Promise.resolve() .then(() { console.log(3); }) .then(() { console.log(4); }); console.log(5); // 输出顺序推演过程第一次宏任务脚本整体开始同步代码从上到下执行。输出 1。遇到 setTimeout注册回调把这个宏任务推进宏任务队列定时器到时后才会被取出。这里用 0 毫秒并不代表立即执行而是代表“尽快加入宏任务队列”。遇到 Promise.resolve().then()注册微任务回调加入微任务队列。输出 5。同步代码执行完毕当前宏任务结束。检查微任务队列发现有一个 then 回调执行它输出 3。这个微任务执行过程中又注册了下一个 then也就是输出 4 的回调它会被追加到微任务队列尾部。微任务队列不为空继续执行下一个微任务输出 4。微任务队列清空这一轮事件循环结束。从宏任务队列取出 setTimeout 的回调执行输出 2。所以最终输出顺序是15342。这个例子每次讲给别人听都很好用。它直观展示了三件事同步代码优先于一切回调、微任务优先于宏任务、setTimeout(0) 并不快它只是“下一个宏任务”。理解了这道题再去看网上各种“事件循环输出顺序题”基本就是同一套推演逻辑的排列组合。3. “非阻塞架构”的真相谁在后台干活到了这里必须说清楚一个很多人容易搞混的概念JavaScript 本身并不提供“多线程并行执行代码”的能力但浏览器为它提供了多线程的工作环境。真正让事件循环有空去调度回调的是整个浏览器架构而不是 JS 引擎单打独斗。3.1 异步 API 底层是谁在等拿 setTimeout 举例。你在 JS 里写 setTimeout(callback, 1000)这 1000 毫秒的计时并不是 JS 主线程在数秒。浏览器会把这个定时器注册到计时器线程里计时结束回调才被丢进宏任务队列等待主线程事件循环来取。fetch 也一样。网络请求真正在收发数据时JS 主线程并没有阻塞等待而是网络线程在完成请求。响应到达后浏览器再把相关回调包括把 Promise 状态置为 fulfilled塞进任务队列等主线程事件循环来触发 then。你可以把这个模型理解成“点外卖”你在家里下单商家在做菜骑手在路上送你该干啥干啥。外卖到了放在门口任务队列你有空才去拿。下单、做菜、送餐这些过程都不占用你的时间——这就是非阻塞的含义。JS 主线程就是那个屋子里的人它没有被外卖平台内部的任何流程卡住。3.2 浏览器里的多线程分工现代浏览器的一个页签内部是由多个进程和线程协作的。渲染进程里至少存在这样几条关键线程主线程负责 JS 执行、DOM 布局、样式计算、绘制合成决策的逻辑层面、合成线程负责把页面图层合成并提交给 GPU、网络线程、IO 线程、定时器线程等。主线程的任务最重执行 JS、计算样式、布局、绘制。它一旦被占用其他线程就算完成了自己的工作也得排队等主线程处理。所以“非阻塞”不是指线程无限多而是指大多数耗时环节不会让主线程干等——真正让主线程卡的是你把它当成 CPU 密集型计算工具来用。3.3 Web Worker唯一能绕开主线程的合法出口如果一个计算任务真的很重比如图像处理、数据处理、复杂的加密哈希这些没办法靠“异步 API 外包”完成因为它们是纯 JS 计算。这时就该用 Web Worker。Web Worker 是一个真正的独立线程它和主线程并行执行 JS 代码互不阻塞。但它有两个限制第一Worker 里没有 DOM、没有 window不能直接操作页面第二主线程和 Worker 之间只能通过 postMessage 传递消息数据的传递本质上是克隆或转移是有开销的。所以实践中的做法是重计算放到 Worker 里计算完成后通过 postMessage 把结果发回主线程主线程收到消息后再更新 DOM。注意这个过程本身是宏任务级别的回调同样要等事件循环调度但它让主线程在计算期间保持了流畅不再被长任务卡住。我记得有段时间做数据可视化要处理几万条节点的力导向布局直接在主线程算动画卡到 2 帧每秒。后来把布局计算挪到 Worker主线程只负责接收每一帧的节点坐标并绘制帧率直接恢复正常。这件事说明一个道理非阻塞不等于不做事而是“该谁做的活别让主线程扛”。4. 渲染时机事件循环和“看得见的流畅”之间的关系很多人学完事件循环记住了宏任务、微任务的顺序却不知道它和页面渲染之间还有个微妙的关系。这个关系直接决定了页面卡不卡、动画流不流畅。4.1 渲染发生在事件循环的哪一步浏览器不是每一轮事件循环都会去渲染页面。现代浏览器大体遵循这样的节奏事件循环在清空微任务队列之后会进入一个“更新渲染”的候选阶段但会不会真的渲染取决于是否存在待处理的渲染机会比如 DOM 有改动、样式有变化、渲染帧的调度策略、以及设备刷新率通常 60Hz也就是每帧约 16.7ms。这里有一个很容易被忽视的点宏任务和微任务之间指的是“两个宏任务之间”浏览器可以插入渲染。所以如果你用 setTimeout 递归来驱动动画回调是一个宏任务它执行完下一个宏任务还没取到之前浏览器可能会渲染一帧。但如果你的动画逻辑是用微任务递归驱动微任务会一直被清空、又一直被追加浏览器根本没有机会走到渲染那一步动画自然画不出来——而且你会看到一个“卡死”但又没死循环的诡异页面。4.2 长任务是卡顿的真正原因长任务Long Task是指任何在主线程上持续运行时间超过 50ms 的任务。50ms 这个阈值是有讲究的在 60Hz 刷新率下一帧只有约 16.7ms一段超过 50ms 的连续主线程工作意味着至少跨越了三帧的渲染机会。用户能在交互层面感知到的明显卡顿通常就是从这里来的。你可以打开浏览器的性能面板录一段页面操作的轨迹然后去主线程条带里看那些标红的长任务。每个长任务点进去能看到它的调用栈——直接定位到是哪一段 JS 占用了这么久。这就是我在源头排查时最常用的手段。4.3 给主线程“切片”两种让出页面控制权的方式如果一段计算不能移到 Worker那就要考虑把它切成小块让事件循环有机会在块与块之间插入渲染。具体做法就是“把一个大任务拆成多个宏任务”。假设有一个 10 万次的循环直接跑会阻塞主线程至少几十毫秒。可以改成用 setTimeout 或者 MessageChannel 来分片每次只处理几千条处理完 setTimeout 一下让出主线程让浏览器有机会渲染再在下一个宏任务里接着处理。这里有一个经验之谈如果循环里没有涉及依赖上一次执行的全局状态分片很简单如果有关联状态就需要你自己维护一个“当前进度”的变量。这个维护成本容易出错所以能塞 Worker 的就塞 Worker分片方案适合那些“不能跨线程”的短期任务。还有另一个 API 值得一提requestIdleCallback。它会在浏览器空闲时段调用传入的回调适合做低优先级的任务比如埋点上报、日志整理。但它有两个坑第一它不能保证一定执行忙碌时可能永远等不到空闲第二它在某些浏览器里执行频率很低不适合做核心任务。它和 requestAnimationFrame 不一样后者是每帧渲染前调用优先级更高。5. 实战里的坑setTimeout 不准时、async/await 的微任务本质以及卡顿排查链路把理论和实战连起来你会发现事件循环不是停留在面试题里的概念它直接影响开发中的很多“怪现象”。5.1 setTimeout 为什么不准时经常有人问我“setTimeout 设了 300ms为什么实际 800ms 才执行”原因是定时器从注册到回调执行中间隔了两层定时器线程的计时到达后回调只是被放入了宏任务队列。此时如果主线程正忙比如在执行其他同步任务或者队列前面还排着其他宏任务这个回调就得等。更隐蔽的因素是浏览器的节能策略后台标签页的定时器会被节流最小间隔可能被提升到 1000ms 甚至更高嵌套超过五层的 setTimeout每层最小延迟会被限制在 4ms 以上。所以测试一下经常会发现在后台页面 setTimeout(0) 实际变成 setTimeout(1000) 级别。这就要求你写代码时别把 setTimeout 当作精确计时工具。要精确计时应该用记录时间戳差值的方式或者用 performance.now() 来校准。动画更是别用 setTimeout 死磕帧率那个就该用 requestAnimationFrame。5.2 async/await 的微任务本质async/await 在语法上像同步代码底层仍是 Promise 的微任务调度。理解这一点能解释很多“反直觉”的执行顺序案例。async function test() { console.log(async start); await Promise.resolve(); console.log(async end); } console.log(sync); test(); console.log(main end); // 输出sync → async start → main end → async end推演一下调用 test 时async 函数体开始执行输出 async start遇到 await右侧 Promise.resolve() 状态已经是 fulfilled但 await 后面的代码也就是 console.log(async end)会作为一个微任务被送出先等当前宏任务结束。于是回到主流程输出 main end。最后微任务队列清空时输出 async end。这个顺序的要点在于await 后面的代码不是“立即执行”的而是必定进入微任务队列。如果你在 await 前后对某个变量做了读写要特别注意这中间的时序它和写同步代码的时候完全不同。5.3 一套完整的卡顿排查链路最后分享一个排查页面卡顿的实用链路这套方法我在前面提到的排序卡顿问题上实际用过。第一步先在性能面板里录一段操作轨迹把触发卡顿的操作完整执行一遍标记出卡顿起始区间。第二步看主线程记录里标红的长任务点击最长的那个看调用栈。如果调用栈显示的是一个匿名函数或某个框架内部函数再用代码冒泡把函数名打出来或者打断点定位。第三步拿到调用栈后看它是不是同步计算、是不是大数据量循环、有没有在循环里做 DOM 操作或深拷贝。大部分情况到这里就已经能找到根因了。第四步是修。能拆分数据的拆分能加缓存的加缓存能移动到 Worker 的就移。不推荐的做法是“凭感觉加 debounce”——如果算法本身就是 O(n²) 的debounce 只能降低触发频率不能降低单次消耗。这里再多说一句排查卡顿的时候千万不要只盯着 JS 执行看。有时候布局抖动也能制造长任务——比如循环里反复读取 offsetWidth 又修改样式会造成强制同步布局导致每次读取都被迫重新计算样式。这种问题在性能面板里的表现是“渲染”段消耗很高而不是 JS 段。所以看到卡顿先量一量主线程各阶段耗时分布再决定动手方向别一上来就改 JS。我个人在实际排查中还有个习惯把性能面板的记录片段保存下来每次修复后重新录制对比确认任务时间真的降下来了再收手。不看数据就宣称“修好了”后面大概率会被线上打脸。事件循环这套机制不是面试背书的考点它决定了你在浏览器里写的每一段代码会以什么顺序、什么代价被执行。把这个模型吃透了排查卡顿、优化动画、理解异步顺序都只是水到渠成的事。