人人都会AI编程

6.2 浏览器事件循环完整机制

更新时间:2026-07-11

在上一节我们提到 JavaScript 是单线程的,通过事件驱动模型处理异步。现在我们要把这个过程真正拆开,看清楚每一帧里到底发生了什么。理解事件循环,是写出正确异步代码、排查诡异 Bug 的根本。

6.2.1 核心组件:调用栈、任务队列、微任务队列

事件循环由三部分组成:

  • 调用栈(Call Stack)

记录当前正在执行的函数。函数被调用时入栈,执行完毕时出栈。栈顶永远是当前正在执行的代码。同步代码一条条入栈、执行、出栈,直到调用栈清空。

  • 任务队列(Task Queue / MacroTask Queue)

存放“宏任务”的队列。常见的宏任务来源:setTimeoutsetInterval、I/O(网络请求完成、文件读取完成)、UI 渲染事件(如 DOMContentLoaded)、用户交互事件(如点击、滚动)等。每个宏任务都包裹着一个回调函数。

  • 微任务队列(MicroTask Queue)

存放“微任务”的队列。微任务主要来自:Promise.then/catch/finallyMutationObserverqueueMicrotask()。微任务队列会在当前宏任务执行完毕后、下一个宏任务开始前被完全清空

6.2.2 一次事件循环的完整流程

浏览器的一次事件循环(一个 tick)可以拆解为以下步骤:

  1. 执行一个宏任务(从任务队列中取出最老的一个)。
  2. 将该宏任务中的同步代码压入调用栈执行,期间可能产生新的微任务或宏任务(注意:新宏任务会被放入任务队列尾部,等待后续 tick 执行)。
  3. 宏任务执行完毕后,清空微任务队列。也就是连续取出所有微任务并执行,直到微任务队列为空。在微任务执行过程中,新产生的微任务仍会被添加到当前微任务队列并在这个阶段执行,所以微任务可能“没完没了”,但不会出现递归死循环。
  4. 判断是否需要渲染。浏览器通常在一次事件循环结束后,如果判断有渲染的必要(如 DOM 或 CSSOM 发生变化,且距离上次渲染已经过去大约 16.67ms),则会进入渲染阶段:
  • 执行 requestAnimationFrame 回调。
  • 执行样式计算、布局(重排)、绘制(重绘)、合成等渲染流水线。
  1. 进入下一次事件循环,返回到第 1 步,取出下一个宏任务。

关键点:一个宏任务 → 全部微任务 → 可能渲染 → 下一个宏任务,这个顺序是死记硬背也要记住的准则。

6.2.3 宏任务与微任务分类速查

| 宏任务(MacroTask) | 微任务(MicroTask) |
|---------------------|---------------------|
| setTimeout / setInterval | Promise.then/catch/finally |
| I/O 操作完成回调(如文件读取、网络请求) | MutationObserver 回调 |
| UI 事件(click, scroll, input 等) | queueMicrotask() 主动添加 |
| setImmediate(Node.js) | process.nextTick(Node.js,不属于严格微任务,但优先级更高,这里仅对比浏览器) |
| requestAnimationFrame 在渲染阶段执行,不算标准宏任务 | - |

6.2.4 用一段代码看清顺序

console.log('1');  // 同步

setTimeout(() => {
  console.log('2');  // 宏任务
}, 0);

Promise.resolve().then(() => {
  console.log('3');  // 微任务
});

console.log('4');  // 同步

输出:1432

解释:

  • 同步代码先执行,输出 1、注册宏任务、注册微任务、输出 4
  • 宏任务执行完毕,调用栈空。
  • 清空微任务队列,输出 3
  • 考虑是否需要渲染(本例无渲染需求)。
  • 下一轮事件循环,取出 setTimeout 宏任务,输出 2

6.2.5 渲染时机与事件循环的关联

“每执行完一个宏任务就渲染一次”这种说法并不准确。浏览器的渲染是机会性的,遵循以下规则:

  • 在一次事件循环结束后,浏览器会检查是否有需要渲染的内容(变更的 DOM / 动画帧请求)。
  • 如果满足渲染条件,就会在下一个宏任务之前插入渲染步骤。
  • requestAnimationFrame 回调就在本次渲染之前执行,因此适合用来同步更新动画状态。
  • 如果主线程长时间被同步代码或大量微任务占据,渲染会被延迟,导致页面卡顿。

这正是为什么尽量避免在微任务中进行大量计算的原因:微任务会阻塞渲染,让用户看到白屏或掉帧。

6.2.6 常见“反直觉”的输出题示例

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

Promise.resolve()
  .then(() => {
    console.log('B');
    return Promise.resolve();
  })
  .then(() => console.log('C'));

console.log('D');

输出顺序:DBCA。微任务链上的 C 在当前微任务队列清空时会紧接着执行,因为.then() 返回的 Promise 会再次向当前微任务队列追加一个微任务,所以它会在宏任务 A 之前完成。

理解事件循环,不仅是为了应对面试,更是为了在日常开发中做出正确的异步决策:该用防抖还是节流?为什么定时器不准?为什么我的页面莫名其妙卡住了?答案全藏在这个循环里。