在上一节我们提到 JavaScript 是单线程的,通过事件驱动模型处理异步。现在我们要把这个过程真正拆开,看清楚每一帧里到底发生了什么。理解事件循环,是写出正确异步代码、排查诡异 Bug 的根本。
6.2.1 核心组件:调用栈、任务队列、微任务队列
事件循环由三部分组成:
- 调用栈(Call Stack)
记录当前正在执行的函数。函数被调用时入栈,执行完毕时出栈。栈顶永远是当前正在执行的代码。同步代码一条条入栈、执行、出栈,直到调用栈清空。
- 任务队列(Task Queue / MacroTask Queue)
存放“宏任务”的队列。常见的宏任务来源:setTimeout、setInterval、I/O(网络请求完成、文件读取完成)、UI 渲染事件(如 DOMContentLoaded)、用户交互事件(如点击、滚动)等。每个宏任务都包裹着一个回调函数。
- 微任务队列(MicroTask Queue)
存放“微任务”的队列。微任务主要来自:Promise.then/catch/finally、MutationObserver、queueMicrotask()。微任务队列会在当前宏任务执行完毕后、下一个宏任务开始前被完全清空。
6.2.2 一次事件循环的完整流程
浏览器的一次事件循环(一个 tick)可以拆解为以下步骤:
- 执行一个宏任务(从任务队列中取出最老的一个)。
- 将该宏任务中的同步代码压入调用栈执行,期间可能产生新的微任务或宏任务(注意:新宏任务会被放入任务队列尾部,等待后续 tick 执行)。
- 宏任务执行完毕后,清空微任务队列。也就是连续取出所有微任务并执行,直到微任务队列为空。在微任务执行过程中,新产生的微任务仍会被添加到当前微任务队列并在这个阶段执行,所以微任务可能“没完没了”,但不会出现递归死循环。
- 判断是否需要渲染。浏览器通常在一次事件循环结束后,如果判断有渲染的必要(如 DOM 或 CSSOM 发生变化,且距离上次渲染已经过去大约 16.67ms),则会进入渲染阶段:
- 执行
requestAnimationFrame回调。 - 执行样式计算、布局(重排)、绘制(重绘)、合成等渲染流水线。
- 进入下一次事件循环,返回到第 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'); // 同步
输出:1 → 4 → 3 → 2
解释:
- 同步代码先执行,输出
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');
输出顺序:D → B → C → A。微任务链上的 C 在当前微任务队列清空时会紧接着执行,因为.then() 返回的 Promise 会再次向当前微任务队列追加一个微任务,所以它会在宏任务 A 之前完成。
理解事件循环,不仅是为了应对面试,更是为了在日常开发中做出正确的异步决策:该用防抖还是节流?为什么定时器不准?为什么我的页面莫名其妙卡住了?答案全藏在这个循环里。