深入理解 JavaScript 事件循环:从代码执行到浏览器渲染
在 JavaScript 开发中,我们经常会遇到这样的困惑:为什么 setTimeout(fn, 0) 并不是立即执行?为什么 Promise 的回调总是比 setTimeout 先运行?async/await 到底是如何影响执行顺序的?
这些问题背后的核心机制,就是 事件循环(Event Loop)。
很多开发者对 Event Loop 的理解停留在“宏任务”和“微任务”的口诀上,但如果不结合浏览器的渲染进程和主线程模型,很难真正理解其设计初衷。今天,我们将通过一段具体的代码,结合浏览器的工作原理,层层剖析事件循环的执行机制。
一、浏览器的主线程模型
要理解 Event Loop,首先要明确 JavaScript 在浏览器中的运行环境。
浏览器是多进程架构,但我们写的 JavaScript 代码主要运行在 渲染进程(Render Process) 的 主线程(Main Thread) 上。这是一个单线程环境。
这意味着,主线程在同一时刻只能做一件事。它需要负责:
- 执行 JavaScript 代码。
- 处理 DOM 解析和样式计算。
- 处理布局(Layout)和绘制(Paint)。
- 响应用户交互(点击、输入等)。
如果主线程被一段耗时的同步代码阻塞(例如一个巨大的 for 循环),页面就会停止响应,甚至出现卡顿。为了解决这个问题,浏览器引入了 消息队列 和 事件循环 机制,将耗时任务异步化,确保主线程尽可能快地回到空闲状态,去处理下一帧的渲染或用户交互。
二、事件循环的核心流程
我们可以将事件循环的一个完整周期简化为以下几个步骤:
- 执行同步代码:从调用栈(Call Stack)开始,执行所有同步任务。
- 收集异步任务:遇到异步 API(如
setTimeout、Promise、addEventListener),将回调函数注册到对应的队列中。 - 执行微任务:当调用栈清空(同步代码执行完毕)后,立即检查 微任务队列(Microtask Queue)。如果队列中有任务,则依次执行,直到队列清空。注意:在微任务执行过程中产生的新微任务,也会在当前轮次中被执行。
- 渲染(可选):微任务清空后,浏览器会检查是否需要更新 UI(重绘或重排)。如果有需要,则进行渲染。
- 执行宏任务:从 宏任务队列(Macrotask Queue) 中取出一个任务执行。
- 循环:回到步骤 3,开始下一轮事件循环。
优先级总结:同步代码 > 微任务 > 渲染 > 宏任务。
三、代码实战剖析
为了更直观地理解,我们来看一段涵盖了常见异步场景的代码。这段代码包含了同步日志、Promise、setTimeout、async/await、queueMicrotask 以及 MutationObserver。
console.log('同步代码 1');
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => {
console.log('setTimeout 1 内部微任务');
});
}, 0);
const promise1 = new Promise((resolve) => {
console.log('Promise 构造函数');
resolve();
console.log('Promise 构造函数内 resolve 后');
});
promise1.then(() => {
console.log('Promise.then 1');
setTimeout(() => {
console.log('Promise.then 1 内部 setTimeout');
}, 0);
});
async function asyncFn() {
console.log('async 函数同步部分');
await Promise.resolve();
console.log('await 后微任务');
}
asyncFn();
console.log('同步代码 2');
queueMicrotask(() => {
console.log('queueMicrotask 微任务');
});
const observer = new MutationObserver(() => {
console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1');
1. 同步执行阶段
当脚本开始加载,主线程开始执行同步代码。此时,调用栈不为空,所有异步回调只是被注册,不会执行。
执行顺序如下:
- 打印
同步代码 1。 - 遇到
setTimeout:这是一个宏任务。浏览器将其回调函数注册到宏任务队列,不执行。 - 执行
new Promise:注意,Promise 的构造函数是同步执行的。- 打印
Promise 构造函数。 - 执行
resolve():这会将promise1.then的回调注册到微任务队列。 - 打印
Promise 构造函数内 resolve 后。
- 打印
- 执行
asyncFn():- 打印
async 函数同步部分。 - 遇到
await:await后面的Promise.resolve()会暂停函数执行,将await之后的代码(即打印await 后微任务)注册为微任务。
- 打印
- 打印
同步代码 2。 - 执行
queueMicrotask:将回调注册到微任务队列。 - 执行
MutationObserver相关代码:- 创建观察器并观察
div。 div.setAttribute触发属性变更。- 观察器的回调被注册到微任务队列。
- 创建观察器并观察
此时同步代码全部执行完毕,调用栈清空。控制台当前输出:
同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
2. 微任务清空阶段
同步代码结束后,事件循环检查微任务队列。此时队列中有四个任务(顺序取决于注册顺序):
promise1.then的回调。asyncFn中await后的代码。queueMicrotask的回调。MutationObserver的回调。
引擎会依次执行它们:
- 执行
promise1.then:- 打印
Promise.then 1。 - 内部遇到
setTimeout:注册一个新的宏任务到宏任务队列(这是下一轮的宏任务)。
- 打印
- 执行
asyncFn后续:- 打印
await 后微任务。
- 打印
- 执行
queueMicrotask:- 打印
queueMicrotask 微任务。
- 打印
- 执行
MutationObserver:- 打印
MutationObserver 微任务。
- 打印
此阶段结束后,控制台追加输出:
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
3. 渲染与宏任务阶段
微任务队列清空后,浏览器有机会进行 UI 渲染。随后,事件循环从宏任务队列中取出第一个任务执行。
-
第一个宏任务:最开始的
setTimeout(0ms)。- 打印
setTimeout 1。 - 内部遇到
Promise.resolve().then:这会产生一个新的微任务。 - 关键点:在当前宏任务执行完毕后,会再次检查微任务队列。
- 执行内部产生的微任务:打印
setTimeout 1 内部微任务。
- 打印
-
第二个宏任务:在
promise1.then中注册的setTimeout。- 打印
Promise.then 1 内部 setTimeout。
- 打印
最终控制台完整输出:
同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2
Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务
setTimeout 1
setTimeout 1 内部微任务
Promise.then 1 内部 setTimeout
四、几个关键细节的深度解读
通过上面的分析,有几个容易混淆的点需要特别说明。
1. Promise 构造函数是同步的
很多人误以为 new Promise 是异步的。实际上,传入 new Promise 的执行器函数(executor)是立即同步执行的。只有 .then、.catch、.finally 中的回调才是异步的微任务。
考虑:这种设计保证了 Promise 状态的确定性。在 resolve 被调用之前,我们可以在构造函数内完成所有同步初始化逻辑。
2. async/await 的本质
async 函数返回一个 Promise。await 关键字可以理解为语法糖,它将 await 之后的代码包装成了 .then() 的回调。
优点:代码可读性极高,解决了“回调地狱”问题。
缺点:如果滥用,例如在循环中串行 await,会导致异步操作变成同步等待,降低性能。
// 不推荐:串行执行,总耗时 = 所有请求时间之和
for (const url of urls) {
await fetch(url);
}
// 推荐:并行执行,总耗时 = 最慢请求的时间
const promises = urls.map(url => fetch(url));
await Promise.all(promises);
3. MutationObserver 的优先级
MutationObserver 用于监听 DOM 变化。它的回调也是微任务。
设计意图:这是为了保证 DOM 操作的一致性。当我们在一个事件回调中多次修改 DOM 时,我们不希望每次修改都触发重排重绘,也不希望观察者回调被宏任务打断。将其放入微任务队列,可以确保在当前脚本执行完毕、渲染之前,统一处理所有的 DOM 变更通知。
4. 渲染时机
渲染并不是在每一个微任务后都进行,而是在 微任务队列清空后,下一个宏任务执行前。 这意味着,如果你在微任务中修改了样式,浏览器会在微任务全部执行完后,统一计算样式并绘制。这也是为什么我们说微任务适合处理需要“立即”生效但不需要等待下一帧的逻辑。
五、性能优化与避坑指南
理解 Event Loop 最终是为了写出性能更好的代码。
1. 避免长任务(Long Task)
浏览器通常以 60FPS 运行,意味着每一帧只有约 16.6ms 的时间。如果同步代码或微任务执行时间过长,会阻塞主线程,导致掉帧。
建议:将耗时计算拆分为多个宏任务(使用 setTimeout 或 requestIdleCallback),让出主线程给渲染。
2. 微任务不要无限嵌套
虽然微任务优先级高,但如果在微任务中不断生成新的微任务,会导致宏任务(如 setTimeout、UI 渲染、用户交互)一直无法执行,造成页面无响应。
场景:递归调用 Promise.resolve().then(...) 可能导致栈溢出或界面卡死。
3. 合理使用 requestAnimationFrame
对于动画相关的逻辑,不要依赖 setTimeout 或 setInterval,因为它们无法保证与浏览器的刷新率同步。应使用 requestAnimationFrame,它会在下一次重绘之前调用,保证动画流畅。
六、总结
JavaScript 的事件循环机制是单线程模型下实现高并发、非阻塞 I/O 的基石。
- 同步代码 优先执行,占据调用栈。
- 微任务 在同步代码结束后、渲染前立即清空,适合处理状态联动和 DOM 监听。
- 宏任务 用于处理耗时操作和定时任务,每次循环只执行一个。
- 渲染 穿插在微任务清空和宏任务执行之间。
掌握这一机制,不仅能帮助我们准确预测代码执行顺序,更能让我们在面对页面卡顿、交互延迟等性能问题时,找到正确的优化方向。希望这篇文章能帮你建立起对 Event Loop 更立体、更务实的认知。