重新理解事件循环:宏任务、微任务与渲染时机

为什么 Promise 总是比 setTimeout 先执行?事件循环不是一个面试八股,而是理解异步、动画与卡顿的钥匙。

“事件循环”大概是前端面试里被问烂了的话题,但很多人背下了”宏任务、微任务”的顺序,却答不上来一个更实际的问题:我的动画为什么会卡? 这篇想把事件循环放回它真正的用途里——它决定了你的代码在什么时刻运行,以及屏幕在什么时刻更新。

为什么需要事件循环

JavaScript 是单线程的:同一时刻只有一段代码在跑。这个设计避免了多线程的锁与竞态,但也带来一个问题——如果某个操作要等很久(网络请求、定时器、用户点击),单线程岂不是要一直干等?

答案是:不等。JavaScript 把这类”要等”的操作交给宿主环境(浏览器或 Node)去处理,自己继续往下跑;等结果就绪,再通过一套调度机制把回调”塞回来”执行。这套调度机制,就是事件循环

它的核心结构其实很简单:

  • 一个调用栈(Call Stack):当前正在执行的函数。
  • 一个宏任务队列(Task Queue / Macrotask):存放 setTimeout、事件回调、I/O 等。
  • 一个微任务队列(Microtask Queue):存放 Promise.thenqueueMicrotaskMutationObserver 等。

一轮循环到底做了什么

事件循环的一”轮”(tick),可以精确地描述成这样几步:

1. 从宏任务队列取出一个任务,执行它(直到调用栈清空)。
2. 清空整个微任务队列——
   注意:期间新产生的微任务,也要在本轮一并执行完。
3. 如果需要,执行渲染(样式、布局、绘制、合成)。
4. 回到第 1 步,取下一个宏任务。

这里有两个最容易被忽略、却最关键的细节:

每执行完一个宏任务,就会把微任务队列”一次性清空”。 微任务不是”每轮执行一个”,而是”执行到一个不剩”。

渲染发生在微任务之后、下一个宏任务之前。 也就是说,微任务是”插队在渲染之前”的。

理解了这两点,很多现象就顺理成章了。

经典题:执行顺序

先看一段几乎每个前端都见过的代码:

console.log('1 script start');

setTimeout(() => console.log('2 setTimeout'), 0);

Promise.resolve()
  .then(() => console.log('3 promise 1'))
  .then(() => console.log('4 promise 2'));

console.log('5 script end');

输出顺序是:

1 script start
5 script end
3 promise 1
4 promise 2
2 setTimeout

用上面的模型来解释:

  1. 整段 <script> 本身就是第一个宏任务。同步代码先跑完,打印 15
  2. 期间 setTimeout 的回调进了宏任务队列,两个 .then 进了微任务队列。
  3. 同步代码结束、调用栈清空后,先清空微任务队列:打印 3,这又产生一个新微任务(第二个 .then),继续清空,打印 4
  4. 微任务清空后,才轮到下一个宏任务:打印 2

结论一句话:同一轮里,微任务永远排在宏任务前面。 这也是”Promise 总比 setTimeout 快”的真正原因——不是 Promise 更快,而是它们在不同的队列里。

微任务的双刃剑:饿死渲染

既然微任务会在渲染前被”一次性清空”,那如果微任务里不断产生新的微任务呢?

function loop() {
  Promise.resolve().then(loop); // 每个微任务又产生一个微任务
}
loop();

这段代码会让页面彻底卡死——因为微任务队列永远清不空,事件循环卡在第 2 步,永远走不到第 3 步的渲染。页面既不重绘,也不响应点击。

这是一个重要的直觉:微任务不是”更高级的异步”,它是”插在渲染前面的加急队列”。 用得好能保证状态一致性(比如 Vue 的 nextTick、React 的批处理都建立在微任务上),用不好就会饿死渲染。

和渲染对齐:requestAnimationFrame

既然渲染发生在每轮循环的固定时机,那么”在渲染前做点事”就有了专门的入口——requestAnimationFrame(rAF)。它的回调会在下一次绘制之前执行,因此是做动画的正确姿势:

function animate() {
  element.style.transform = `translateX(${x}px)`;
  x += 2;
  requestAnimationFrame(animate); // 与刷新率对齐,通常每 16.7ms 一帧
}
requestAnimationFrame(animate);

对比一下三种”定时”手段的定位:

手段时机适合做什么
Promise.then / 微任务当前宏任务结束、渲染之前状态合并、批量更新
requestAnimationFrame每次绘制之前,与刷新率对齐视觉动画、随帧更新
setTimeout(fn, 0)作为宏任务,下一轮才执行让出主线程、拆分长任务

一个常见误区是用 setTimeout 做动画——它既不保证和刷新率对齐,最小延迟也常被浏览器限制在 4ms 以上,结果就是掉帧和不均匀。动画请用 rAF。

用宏任务”让路”:拆分长任务

反过来,如果你有一段计算量很大的同步代码,它会长时间占住调用栈,导致这期间无法渲染、无法响应交互(这正是”长任务”卡顿的来源)。这时可以主动把它切成小块,用宏任务让出主线程:

async function processInChunks(items) {
  for (let i = 0; i < items.length; i++) {
    doHeavyWork(items[i]);
    // 每处理一批就让一次路,给渲染和交互留出机会
    if (i % 100 === 0) {
      await new Promise(resolve => setTimeout(resolve));
    }
  }
}

现代浏览器还提供了更语义化的 scheduler.yield()isInputPending() 来做这件事,但底层思路都是一样的:主动交还主线程,让事件循环有机会去渲染和处理输入。

小结

把这篇的要点收束成几句话:

  1. 事件循环让单线程的 JavaScript 得以处理异步——它在调用栈、宏任务、微任务之间调度。
  2. 每个宏任务之后,微任务队列会被一次性清空;渲染排在微任务之后、下一个宏任务之前。
  3. 微任务是”渲染前的加急队列”,能保证一致性,也可能饿死渲染。
  4. 视觉动画用 requestAnimationFrame;拆分长任务用宏任务让路。

事件循环不是一道用来背的题。它描述的是”你的代码”和”屏幕更新”之间的节奏关系——一旦你能用它解释卡顿,就说明真正理解了。


More like this