页面卡顿的元凶:可能是你没搞懂事件循环(面试可用)

6 阅读8分钟

深入理解 JavaScript 事件循环:从代码执行到浏览器渲染

在 JavaScript 开发中,我们经常会遇到这样的困惑:为什么 setTimeout(fn, 0) 并不是立即执行?为什么 Promise 的回调总是比 setTimeout 先运行?async/await 到底是如何影响执行顺序的?

这些问题背后的核心机制,就是 事件循环(Event Loop)

很多开发者对 Event Loop 的理解停留在“宏任务”和“微任务”的口诀上,但如果不结合浏览器的渲染进程和主线程模型,很难真正理解其设计初衷。今天,我们将通过一段具体的代码,结合浏览器的工作原理,层层剖析事件循环的执行机制。

一、浏览器的主线程模型

要理解 Event Loop,首先要明确 JavaScript 在浏览器中的运行环境。

浏览器是多进程架构,但我们写的 JavaScript 代码主要运行在 渲染进程(Render Process)主线程(Main Thread) 上。这是一个单线程环境。

这意味着,主线程在同一时刻只能做一件事。它需要负责:

  1. 执行 JavaScript 代码。
  2. 处理 DOM 解析和样式计算。
  3. 处理布局(Layout)和绘制(Paint)。
  4. 响应用户交互(点击、输入等)。

如果主线程被一段耗时的同步代码阻塞(例如一个巨大的 for 循环),页面就会停止响应,甚至出现卡顿。为了解决这个问题,浏览器引入了 消息队列事件循环 机制,将耗时任务异步化,确保主线程尽可能快地回到空闲状态,去处理下一帧的渲染或用户交互。

二、事件循环的核心流程

我们可以将事件循环的一个完整周期简化为以下几个步骤:

  1. 执行同步代码:从调用栈(Call Stack)开始,执行所有同步任务。
  2. 收集异步任务:遇到异步 API(如 setTimeoutPromiseaddEventListener),将回调函数注册到对应的队列中。
  3. 执行微任务:当调用栈清空(同步代码执行完毕)后,立即检查 微任务队列(Microtask Queue)。如果队列中有任务,则依次执行,直到队列清空。注意:在微任务执行过程中产生的新微任务,也会在当前轮次中被执行。
  4. 渲染(可选):微任务清空后,浏览器会检查是否需要更新 UI(重绘或重排)。如果有需要,则进行渲染。
  5. 执行宏任务:从 宏任务队列(Macrotask Queue) 中取出一个任务执行。
  6. 循环:回到步骤 3,开始下一轮事件循环。

优先级总结:同步代码 > 微任务 > 渲染 > 宏任务

三、代码实战剖析

为了更直观地理解,我们来看一段涵盖了常见异步场景的代码。这段代码包含了同步日志、Promise、setTimeoutasync/awaitqueueMicrotask 以及 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. 打印 同步代码 1
  2. 遇到 setTimeout:这是一个宏任务。浏览器将其回调函数注册到宏任务队列,不执行
  3. 执行 new Promise注意,Promise 的构造函数是同步执行的
    • 打印 Promise 构造函数
    • 执行 resolve():这会将 promise1.then 的回调注册到微任务队列。
    • 打印 Promise 构造函数内 resolve 后
  4. 执行 asyncFn()
    • 打印 async 函数同步部分
    • 遇到 awaitawait 后面的 Promise.resolve() 会暂停函数执行,将 await 之后的代码(即打印 await 后微任务)注册为微任务。
  5. 打印 同步代码 2
  6. 执行 queueMicrotask:将回调注册到微任务队列。
  7. 执行 MutationObserver 相关代码:
    • 创建观察器并观察 div
    • div.setAttribute 触发属性变更。
    • 观察器的回调被注册到微任务队列。

此时同步代码全部执行完毕,调用栈清空。控制台当前输出:

同步代码 1
Promise 构造函数
Promise 构造函数内 resolve 后
async 函数同步部分
同步代码 2

2. 微任务清空阶段

同步代码结束后,事件循环检查微任务队列。此时队列中有四个任务(顺序取决于注册顺序):

  1. promise1.then 的回调。
  2. asyncFnawait 后的代码。
  3. queueMicrotask 的回调。
  4. MutationObserver 的回调。

引擎会依次执行它们:

  1. 执行 promise1.then
    • 打印 Promise.then 1
    • 内部遇到 setTimeout:注册一个新的宏任务到宏任务队列(这是下一轮的宏任务)。
  2. 执行 asyncFn 后续:
    • 打印 await 后微任务
  3. 执行 queueMicrotask
    • 打印 queueMicrotask 微任务
  4. 执行 MutationObserver
    • 打印 MutationObserver 微任务

此阶段结束后,控制台追加输出:

Promise.then 1
await 后微任务
queueMicrotask 微任务
MutationObserver 微任务

3. 渲染与宏任务阶段

微任务队列清空后,浏览器有机会进行 UI 渲染。随后,事件循环从宏任务队列中取出第一个任务执行。

  1. 第一个宏任务:最开始的 setTimeout (0ms)。

    • 打印 setTimeout 1
    • 内部遇到 Promise.resolve().then:这会产生一个新的微任务。
    • 关键点:在当前宏任务执行完毕后,会再次检查微任务队列。
    • 执行内部产生的微任务:打印 setTimeout 1 内部微任务
  2. 第二个宏任务:在 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 的时间。如果同步代码或微任务执行时间过长,会阻塞主线程,导致掉帧。 建议:将耗时计算拆分为多个宏任务(使用 setTimeoutrequestIdleCallback),让出主线程给渲染。

2. 微任务不要无限嵌套

虽然微任务优先级高,但如果在微任务中不断生成新的微任务,会导致宏任务(如 setTimeout、UI 渲染、用户交互)一直无法执行,造成页面无响应。 场景:递归调用 Promise.resolve().then(...) 可能导致栈溢出或界面卡死。

3. 合理使用 requestAnimationFrame

对于动画相关的逻辑,不要依赖 setTimeoutsetInterval,因为它们无法保证与浏览器的刷新率同步。应使用 requestAnimationFrame,它会在下一次重绘之前调用,保证动画流畅。

六、总结

JavaScript 的事件循环机制是单线程模型下实现高并发、非阻塞 I/O 的基石。

  1. 同步代码 优先执行,占据调用栈。
  2. 微任务 在同步代码结束后、渲染前立即清空,适合处理状态联动和 DOM 监听。
  3. 宏任务 用于处理耗时操作和定时任务,每次循环只执行一个。
  4. 渲染 穿插在微任务清空和宏任务执行之间。

掌握这一机制,不仅能帮助我们准确预测代码执行顺序,更能让我们在面对页面卡顿、交互延迟等性能问题时,找到正确的优化方向。希望这篇文章能帮你建立起对 Event Loop 更立体、更务实的认知。