事件循环:宏任务 / 微任务 / 渲染时机

本节目标

浏览器事件循环(每一轮):

① 从「宏任务队列」取一个任务执行(脚本、事件回调、setTimeout、I/O)
② 执行所有「微任务」(Promise.then、queueMicrotask、MutationObserver)
③ 是否需要渲染?(约每 16.6ms 一帧,或由浏览器决定)
   → 是:执行 requestAnimationFrame 回调 → 布局 → 绘制
④ 回到 ①

关键结论:

  1. 微任务在渲染前被清空:Promise.then 不会等下一帧,当前宏任务一结束就立刻全跑完。
  2. 宏任务之间才会插入渲染:一个超长的宏任务会把渲染挤掉 → 页面卡顿。
  3. rAF 在渲染前执行:适合「改 DOM,让浏览器一帧画一次」。
  4. setTimeout 最小延迟约 4ms(嵌套后),且时机不精确(被前面的任务拖延)。

下面这段 Node 代码(Node 与浏览器模型高度一致)会真实输出 1 4 3 2:

// 运行环境:Node.js 14+ 或浏览器控制台
// 步骤:node brw-l9.js
// 输出顺序固定为:1 4 3 2(同步 → 微任务 → 宏任务)

console.log('1')                          // 同步代码,宏任务内立即执行
setTimeout(() => console.log('2'), 0)     // 宏任务:本轮宏任务后、下一轮才执行
Promise.resolve().then(() => console.log('3'))  // 微任务:当前宏任务结束后立刻执行
console.log('4')                          // 同步代码

为什么微任务先于渲染:微任务被定义为「当前任务的延续」,浏览器保证它们在渲染前全部跑完,避免中间态闪烁。

rAF vs setTimeout:

// rAF:跟随屏幕刷新(通常 60fps),浏览器不渲染(如切到后台)就暂停 → 省电、平滑
function loop() { update(); requestAnimationFrame(loop) }

// setTimeout 倒计时:后台仍可能运行,但会被节流(嵌套后 ≥4ms,后台可能 1s/次)

任务拆分的伏笔(下一节展开):把长任务拆成多个宏任务(setTimeout(0) / postMessage / scheduler.yield),让渲染能插队,页面不卡。

名词解释

宏任务(Macrotask / Task):事件循环里「一轮」处理一个的工作单元,包括:脚本执行、事件回调、setTimeout/setInterval、I/O、UI 渲染相关。它的「调度方法」是 setTimeout(fn, 0)(排到下一轮宏任务)、queueTask(fn)(概念上)。每一轮循环只取一个宏任务执行。

微任务(Microtask):当前宏任务结束后、渲染前必须清空的任务队列,包括:Promise.then/catch/finally、queueMicrotask、MutationObserver。方法:queueMicrotask(fn) 把函数加入微任务队列。它的特点是「插队到本轮末尾、渲染之前」,所以微任务总在下一个宏任务之前跑完。

requestAnimationFrame(rAF):「在浏览器下一次重绘之前调用我」的回调。方法:requestAnimationFrame(cb) 返回 id,cancelAnimationFrame(id) 取消。它和微任务不同:微任务在「渲染前立刻」,rAF 在「渲染这一帧前」——适合做动画和 DOM 批量更新,且切后台会自动暂停(省电)。

课后练习

练习 1:下面代码输出顺序是什么?

console.log('A')
setTimeout(() => console.log('B'), 0)
Promise.resolve().then(() => console.log('C'))
setTimeout(() => console.log('D'), 0)
Promise.resolve().then(() => console.log('E'))
console.log('F')

答案:A F C E B D。执行过程:同步打印 A、F;当前宏任务结束,清空微任务队列 → C、E;然后进入下一轮宏任务,按顺序取 setTimeout → B,再下一轮 → D。微任务总在两个 setTimeout(不同宏任务)之间被清空。

练习 2:为什么「一个超长的同步 for 循环」会让页面完全卡住、连滚动都动不了?

答案:同步 for 循环属于当前宏任务的一部分,它没结束,「一轮循环」就走不到 ②清空微任务、③渲染 那一步。浏览器整整这一轮都没机会渲染和响应输入,于是页面假死。解决思路是把长循环切成多个宏任务(用 setTimeout(0) / scheduler.yield() 让出主线程),让每一小片执行完都能插入一次渲染。

总结

事件循环是前端「最该懂、却最常被背成八股」的知识点。我希望你记住的不是顺序口诀,而是**「为什么是这个顺序」**:微任务被设计成「本轮延续」,是为了保证 Promise 回调在渲染前全部落地,避免用户看到半成品;渲染被夹在宏任务之间,是为了让每帧都有机会刷新;rAF 紧贴渲染,是为了动画与屏幕同步。这套设计的底层目标只有一个——让「响应用户」和「更新画面」永远有插入的机会。所以当你写出「卡死页面」的代码时,本质就是你霸占了「一轮循环」不让它走到渲染。下一节我会教你怎么「主动让出」主线程,把这套理论变成不卡顿的实战。