JavaScript宏任务与微任务:事件循环、执行顺序与实战坑点

📅 发布时间:2026/9/7 19:09:10
JavaScript宏任务与微任务:事件循环、执行顺序与实战坑点
如果你正在学 JavaScript 的宏任务和微任务我强烈建议你先把下面这段代码丢进浏览器控制台跑一遍。这是我面试前端时几乎每次都会问的问题但哪怕是有三五年经验的候选人也经常在“先执行哪种任务”这个问题上栽跟头。console.log(script start); setTimeout(() { console.log(setTimeout); }, 0); Promise.resolve().then(() { console.log(promise1); }).then(() { console.log(promise2); }); console.log(script end);在浏览器里的输出顺序是script start script end promise1 promise2 setTimeout如果你第一次看到这个结果会有点懵太正常了。因为 setTimeout 明明写的是 0 毫秒凭什么排在两个 Promise 后面这背后不是某个浏览器私自定的规则而是 JavaScript 事件循环Event Loop里“宏任务”和“微任务”两套执行队列在起作用。搞懂这一点不只是为了应付面试很多线上偶现的渲染卡顿、异步执行顺序错乱、Node 服务端任务饿死问题最终都要回到这个模型上来。1. 一道例行面试题为什么总能劝退半屋子候选人1.1 先跑一遍这道“送分题”上面那道题关键不在代码本身而在于你要清楚每行代码被推进了哪个队列。我们来拆解一下console.log(script start)和console.log(script end)是同步代码直接进调用栈执行。setTimeout(..., 0)不是立刻执行函数只是把回调放进“宏任务队列”执行时机取决于事件循环什么时候轮到它。Promise.resolve().then(...)里的回调和下面链式.then()回调会被放进“微任务队列”它们会在当前宏任务执行结束后立刻被清空。所以在第一个宏任务整段脚本执行过程中同步代码先打完两行日志然后事件循环检查微任务队列发现有两个 Promise 回调依次执行。执行完promise1之后第二个.then()会继续把promise2排进同一个微任务队列依然在当前清空周期内执行。直到微任务队列彻底为空事件循环才会回头从宏任务队列里取出那个setTimeout回调输出setTimeout。1.2 反直觉的三个原因大多数人第一次会在这里翻车主要是三个原因叠加把setTimeout(0)当成“同步执行完马上执行”忽略了它本质上是“至少延迟 0 毫秒后把回调插入宏任务队列”。把 Promise 回调理解成和同步回调一样的优先级实际上 Promise 回调属于微任务优先级高于任何宏任务。没有形成“当前调用栈空了先清空微任务队列再取一个宏任务”的循环概念。我以前带过一个小伙伴他执着地认为setTimeout(0)至少排在最前面因为“0 比 Promise.resolve().then() 更快”。后来我让他连续执行了十遍这段代码他终于反应过来问题不在时间快慢而在队列优先级。前者是“下一班车”后者是“当前商务座乘客处理完后的 VIP 通道”VIP 通道永远优先于下一班车。2. 事件循环的轮转一个宏任务、一整批微任务、一次渲染2.1 调用栈与任务队列的分工要理解宏任务和微任务的先后关系得先看清事件循环里的几个核心角色调用栈Call Stack执行同步代码的地方函数调用会一层层压栈执行完再出栈。宏任务队列Macrotask Queue也叫 Task Queue存放 setTimeout、setInterval、I/O 回调、UI 事件回调等。微任务队列Microtask Queue存放 Promise 回调、MutationObserver 回调、queueMicrotask 注册的函数。渲染步骤Render Step浏览器在合适的时间点进行页面渲染不是每次宏任务结束都一定渲染而是由浏览器根据帧率、任务耗时、用户交互等因素决定。整个轮转可以简化为执行一个宏任务 └─ 执行过程中产生的微任务按顺序进入微任务队列 清空整个微任务队列 └─ 微任务执行过程中若再产生新的微任务继续执行直到队列为空 浏览器决定是否渲染 取下一个宏任务注意这里的关键点微任务队列不是执行完一个就切回宏任务而是要把整个微任务队列清空。这跟很多人以为的“宏任务和微任务交替执行”完全不同。微任务是当前宏任务内部的“after party”不散场绝不开始下一场。2.2 宏任务与微任务的归属我在项目里会用一张表给团队成员做培训简单直观API / 场景归属队列执行时机setTimeout/setInterval宏任务至少延迟指定时间后进入宏任务队列I/O 操作宏任务事件触发后进入宏任务队列UI 事件click、input 等宏任务事件派发后进入宏任务队列Promise.prototype.then/catch/finally微任务当前宏任务执行完毕后立即执行queueMicrotask微任务同上MutationObserver微任务DOM 变化后在当前宏任务结束时批量处理requestAnimationFrame渲染前特殊队列浏览器绘制下一帧之前执行不属于宏任务或微任务很多资料会把requestAnimationFrame混进宏任务里讲严格来说它并不在宏任务队列中而是“渲染帧回调”在渲染之前统一执行。日常调试时你可以把它理解成一个比宏任务优先级更高、但在当前帧渲染前的回调队列。它经常被用来实现动画或读取最新的布局信息。2.3 渲染的时机这里有一个特别容易被忽略的实践点微任务里做 DOM 修改不会等到下一次宏任务才渲染它会在当前宏任务结束、微任务队列清空后有可能立即触发渲染。比如你连续修改同一个元素的innerHTML十次虽然代码是同步执行的但由于浏览器有批量渲染机制并不会每次修改都重绘一次。如果这些修改发生在微任务中渲染引擎依然会在微任务清空后统一处理一次。这个特性可以用来合并状态更新但反过来也会造成一个常见的性能陷阱在微任务里频繁修改大 DOM 树会在当前宏任务结束时立刻阻塞一次渲染造成卡顿。所以不要以为“异步就一定能避开性能问题”微任务同样可能阻塞渲染。3. 微任务的隐蔽陷阱递归微任务让页面假死setTimeout 变相救命3.1 一段可以“杀死”页面的代码你也许听过这样的说法不要在微任务里递归调用自身否则页面会卡死。但没几个人真正试过。其实代码非常短function loop() { Promise.resolve().then(loop); } loop();这段代码跑起来后页面会快速进入无响应状态。原因很简单每个微任务执行时又创建了新的微任务导致微任务队列永远清不空。事件循环根本没有机会回到宏任务队列也就没有机会处理用户点击、滚动、渲染等事件。浏览器试图渲染却发现主线程一直在忙碌地处理微任务只能一直等待。3.2 为什么 setTimeout 递归不会立刻卡死对比一下function loop() { setTimeout(loop, 0); } loop();这段代码会一直跑但页面通常还能响应这是因为setTimeout每次都会把下一轮循环放到宏任务队列。事件循环执行一个宏任务后会清空微任务队列然后返回浏览器渲染机会再取下一个宏任务。每两个loop()之间主线程会“喘一口气”让出控制权页面就有了响应和渲染的窗口。所以当需要做长轮询或异步循环时setTimeout比Promise.resolve().then(loop)更安全。如果你既想保持微任务的高效又不想卡死页面可以引入一个计数器或判断条件在连续执行次数达到阈值后切换成宏任务调度。3.3 真实项目中的触发场景这种递归陷阱不只是理论上的。我有一次在调优一个数据大屏的时候发现页面切到后台再回来就白屏。查了很久才发现有个轮询函数用的是 Promise 递归function pollData() { fetchData().then(() { updateChart(); pollData(); }); }正常情况下这个代码没有问题因为pollData的调用发生在fetchData返回的 Promise 回调里后一次fetchData属于异步网络操作不会在微任务里无限循环。真正的坑在于如果数据中心临时返回了缓存 Promise某些内部库会把数据包成Promise.resolve(data)那这个链就会变成微任务递归导致后续的 UI 更新和渲染一直被延迟。后来我们改成setTimeout每 1 秒触发一次轮询问题立刻消失。排查思路也很简单在浏览器 Performance 面板里查看主线程任务如果看到连续且密集的微任务长条没有宏任务间隙基本就是微任务队列被饿死了。4. async/await 的爱恨纠葛为什么 await 后面的代码总比想象中晚4.1 await 只是 .then 的语法糖没那么简单很多文章会说async/await只是 Promise 的语法糖这一点没错但它给执行顺序引入了一层容易忽略的转化规则await会暂停当前 async 函数的执行并把暂停点后面的代码作为一个微任务提交。看个例子async function async1() { console.log(async1 start); await async2(); console.log(async1 end); } async function async2() { console.log(async2); } async1(); console.log(script start);输出顺序是async1 start async2 script start async1 end为什么async1 end最后打印因为await async2()是先同步调用async2()拿到一个 Promise然后async1函数体就会立即让出控制权把console.log(async1 end)放到微任务队列中。主线程继续向下执行同步代码打印script start。当前宏任务结束后再清空微任务队列才会执行async1 end。有人会把这里的顺序记成“await 后面的代码一定延后到同步代码之后”这个记忆方向是对的但要注意如果 await 的值不是 Promise异步函数同样会把它转换为 Promise并产生一个微任务。4.2 连续 await 的执行顺序更复杂的场景是连续 awaitasync function test() { console.log(1); await 1; console.log(2); await 2; console.log(3); } test(); console.log(4);这里的输出是 1、4、2、3。第一次await 1把console.log(2)放进微任务队列第二次await 2则是在第一次微任务执行时继续把console.log(3)放进队列。所以 2 和 3 之间虽然没有同步代码但它们依然被两个微任务分隔开。这个特性在写业务代码时很容易造成时序错觉。比如有时候你请求接口后想先用缓存数据渲染页面再拿到最新数据刷新如果中间又插入了其他微任务用户可能会看到两次闪烁。解决办法是显式控制异步更新的顺序或者在关键状态变化后用统一的渲染函数聚合。4.3 await 之后可以做的优化理解了await的微任务机制对性能优化也有直接帮助。在 React 或 Vue 项目里不要在await回调之后立即同步地读取和修改大量 DOM因为这会让样式计算和重排在微任务阶段反复触发。更好的做法是把同一帧内的多次状态更新合并成一个 microtask 或直接同步更新。对于需要确保渲染完成后的测量操作比如读取 element.offsetHeight用requestAnimationFrame或setTimeout将测量延迟到下一次渲染后。如果不希望某些逻辑受微任务优先级影响可以使用setTimeout(0)显式把它降级到宏任务。5. 别在微任务里迷路Promise 链与队列顺序的微妙差异5.1 两个 Promise 链交错后的输出微任务队列通常被描述成 FIFO先进先出但只要 Promise 链交错结果就没那么简单。看下面这段代码Promise.resolve() .then(() console.log(a)) .then(() console.log(b)); Promise.resolve() .then(() console.log(c));你的第一反应可能是按顺序输出a b c但实际输出是a c b。原因在于第一条.then(() console.log(a))先进入微任务队列第二条.then(() console.log(c))后进入微任务队列。执行第一个微任务a的过程中.then(() console.log(b))会创建新的微任务并被追加到微任务队列尾部。当前微任务队列里此时还躺着c所以先执行c然后才轮得到b。这就是微任务队列“动态追加”的特性。它虽然看起来是 FIFO但队列尾部会在执行过程中不停插入新任务。只要当前正在执行的微任务产生了新微任务新任务就会被排到当前队列所有已有任务之后。5.2 微任务队列的“看似 FIFO”陷阱这个陷阱在实际代码里经常以另一种形式出现。比如有一个全局日志队列Promise.resolve().then(() log(a)); Promise.resolve().then(() log(b)); Promise.resolve().then(() log(c));这段代码确实会按a b c输出。因为三个.then是同步注册进微任务队列的先注册先输出。而如果中间插入了一个 then 回调内部又注册新的 then顺序就会变得难懂。排查这类问题的时候建议不要靠脑子模拟直接在浏览器 DevTools 里打断点看 Call Stack 和 Microtask 队列。Chrome DevTools 的 Sources 面板现在可以查看 microtask 任务被调度的位置比以前方便很多。5.3 queueMicrotask 与 MutationObserver 的应用如果你需要在当前宏任务结束后、但在渲染之前执行一段代码除了用Promise.resolve().then()还可以直接用queueMicrotaskqueueMicrotask(() { console.log(microtask); });queueMicrotask是比Promise.resolve().then更字面化的方式语义清晰也不容易产生多余的 Promise 包装。它经常用在自定义事件分发、状态同步等场景。还有一个老牌 API 是MutationObserver它在 DOM 变化时触发回调同样进入微任务队列。以前很多 MVVM 框架用它来合并 DOM 更新现在有了 React/Vue 的调度器业务代码里直接用MutationObserver的场景少了很多但在写浏览器插件时仍然有用。有一点值得注意由于微任务优先级高如果你用MutationObserver监听一次 DOM 修改在修改后同步执行 JS 反复触发新的 DOM 修改同样会形成微任务风暴。所以它适合“合并批量变化”不适合“每个变化立刻响应”。6. Node.js 这块镜子timers、check、nextTick 各有脾气6.1 Node.js 事件循环的六个阶段同样是 JavaScriptNode.js 里的异步执行顺序和浏览器并不完全一致。Node 的事件循环分成几个重要阶段timers执行setTimeout和setInterval的回调。pending callbacks执行一些系统级回调比如 TCP 错误回调。idle/prepare内部使用。poll获取新的 I/O 事件处理网络、文件等回调在这个阶段会阻塞等待新的异步事件。check执行setImmediate回调。close callbacks处理socket.on(close)等关闭回调。每个阶段都是“先执行当前阶段的所有宏任务然后在合适时机清空微任务队列”。不过从 Node 11 开始微任务的执行时机在部分场景下会和浏览器更接近也就是每个宏任务回调之后都会清空微任务而不是等到当前阶段全部结束。6.2 nextTick 和微任务的优先级纠葛Node.js 里有一个特殊的process.nextTick很多人把它当作微任务严格来说它不属于事件循环的六个阶段而是在每个阶段切换的间隙执行。它的优先级甚至比 Promise 微任务更高Promise.resolve().then(() console.log(promise)); process.nextTick(() console.log(nextTick));输出顺序是nextTick然后promise。所以如果你在 Node 里使用process.nextTick做递归会比微任务递归更可怕它会在每个阶段之间反复执行直接饿死 I/O 事件。Node 官方文档也明确告诫不要使用process.nextTick做递归任务因为它在任何情况下都有最高优先级容易阻塞事件循环。合理的使用场景是让某个回调在当前调用栈结束后、事件循环继续之前执行比如在类构造函数里注册事件时需要确保事件监听器先绑定好。6.3 setImmediate 与 setTimeout 的胜负手在 Node 顶层执行这段代码setImmediate(() console.log(setImmediate)); setTimeout(() console.log(setTimeout), 0);输出顺序可能不固定。这是因为两者在进程启动时的不同阶段进入队列setTimeout属于 timers 阶段setImmediate属于 check 阶段。如果在初始脚本之后立即进入事件循环timers 阶段会先执行输出setTimeout如果初始化事件花费的时间超过 1 毫秒setTimeout回调被提前插入 timers 队列依然先执行。总之两者成绩取决于事件循环启动时机。但如果把它们放在一个 I/O 回调里const fs require(fs); fs.readFile(__filename, () { setTimeout(() console.log(timeout), 0); setImmediate(() console.log(immediate)); });输出顺序基本固定immediate先于timeout。因为当 poll 阶段收到 I/O 回调时当前阶段是 poll接下来会进入 check 阶段执行setImmediate而 timers 要等到下一轮事件循环才开始。这个顺序差异经常出现在 Node 的定时任务、批量任务调度中。理解了阶段顺序你写出来的代码就不需要每秒几十次地去猜结果了。7. 从面试到实战用宏任务/微任务解决真实项目问题7.1 用微任务合并状态更新减少渲染抖动在实际业务里宏任务和微任务不是用来背题的而是用来“卡点”的。比如在数据报表页面中用户可能会在一个轮询周期内同时收到多条 WebSocket 消息每条消息都会触发一次状态更新和图表重绘。如果每次都直接更新 DOM浏览器会在短时间内重复计算样式、布局造成掉帧和滚动卡顿。一种常见的优化是把多次连续状态更新合并到一个微任务中let pending false; let updates []; function scheduleUpdate(data) { updates.push(data); if (pending) return; pending true; queueMicrotask(() { const batch updates; updates []; pending false; render(batch); }); }这样即使同一个事件循环里触发了十次scheduleUpdate也只会真正渲染一次。微任务刚好在 DOM 渲染之前执行所以不会打断浏览器当前的渲染管线。这个套路和 Vue/React 内部调度的思路类似但自己在项目里实现时要注意异常处理如果render抛错pending必须重新复位否则后续更新就再也进不来了。7.2 用 setTimeout 切片拆分长任务另外一个高频场景是拆分长任务。假设你有一个百万级的数组需要处理直接for循环会导致主线程长时间被占用页面白屏或者点不到按钮。你可以用宏任务把任务切片const list new Array(1000000).fill(0); let index 0; const batchSize 10000; function processBatch() { const start performance.now(); while (index list.length performance.now() - start 16) { list[index] heavyWork(list[index]); index; } if (index list.length) { setTimeout(processBatch, 0); } else { console.log(done); } } processBatch();这里的思路是每个宏任务只处理 10 万条左右或者控制单批耗时在 16ms 内然后让出主线程给渲染和用户操作。因为setTimeout是宏任务事件循环在执行完当前宏任务后会检查渲染所以页面不会长时间卡死。如果用Promise.resolve().then(processBatch)做同样的事就是前面提到的微任务风暴反而会让页面更加卡顿。不过也要注意setTimeout拆分长任务会降低总处理时间因为每次切片都有额外的宏任务调度开销。对于重要性不高、可延迟执行的逻辑可以放低优先级对于需要快速完成的任务可以在一帧内尽量多处理直到接近帧耗时上限再让出主线程。7.3 排查异步问题的思路当你遇到“为什么这段代码时机不对”的线上 bug 时我推荐按以下顺序排查先确认代码跑在浏览器还是 Node.js优先查事件循环的差异。在所有异步入口处打点输出当前执行环境的上下文判断是宏任务还是微任务。查看是否有递归微任务或process.nextTick递归导致其他异步回调饿死。在 Performance 面板里观察主线程的任务条紫色任务是渲染黄色是脚本密集的微任务长条通常说明微任务队列太长。如果问题只在特定浏览器出现注意不同浏览器对微任务刷新频率的实现并不完全相同尤其是 Safari 和旧版 Edge。按照这个顺序去查大部分跟宏任务/微任务相关的 bug 都跑不掉。我过去在业务中遇到的问题十有八九最后都落在“某个 Promise 链意外递归”或“ setTimeout 被微任务插队”这两个原因上。最后分享一个我自己特别喜欢的调试小技巧在queueMicrotask或Promise.resolve().then的回调里加一个debugger然后在 DevTools 的 Call Stack 面板里往回看调用方就能清楚看到这个微任务是由哪个宏任务注册的。这个追踪方式比在代码里到处打console.log高效得多。