深入理解浏览器事件循环:从宏任务到微任务

为什么我们需要理解事件循环?

JavaScript 是一门单线程语言,这意味着它在任意时刻只能执行一个任务。然而,我们在日常开发中却经常写出 setTimeoutPromiserequestAnimationFrame 等看似“并行”的代码。浏览器究竟是如何在单线程下协调这些任务的?答案就是事件循环(Event Loop)

理解事件循环不仅是面试中的高频考点,更是写出高性能、可预测的前端代码的基础。本文将从浏览器进程模型出发,逐步拆解宏任务与微任务的执行机制,并通过代码示例帮助你彻底掌握这一核心概念。

浏览器中的进程与线程

在深入事件循环之前,有必要先了解浏览器是多进程架构。每一个标签页通常对应一个渲染进程,而渲染进程中又包含多个线程:

  • 主线程(Main Thread):负责执行 JavaScript、解析 HTML/CSS、计算布局与绘制。
  • 合成线程(Compositor Thread):负责将图层合成并交给 GPU 渲染。
  • 定时器线程(Timer Thread):负责计时,时间到了将回调推入任务队列。
  • 网络线程(Network Thread):负责处理网络请求。

事件循环就运行在主线程中。它的核心职责是:不断地从任务队列中取出任务并执行,同时在适当的时候进行渲染更新。

事件循环的基本模型

事件循环可以简化为一个无限循环,每一轮循环(tick)大致包含以下步骤:

  1. 宏任务队列中取出一个任务并执行。
  2. 执行完毕后,清空微任务队列中的所有任务。
  3. 判断是否需要进行渲染(如页面有变化),如果需要则执行渲染流程。
  4. 进入下一轮循环。

这里有两个关键队列:宏任务队列微任务队列。它们的执行优先级和时机不同,正是理解事件循环的关键所在。

宏任务(MacroTask)

宏任务是由宿主环境(浏览器)提供的任务,常见的宏任务包括:

  • script(整体代码)
  • setTimeout / setInterval
  • setImmediate(Node.js 环境)
  • I/O 操作
  • UI rendering
  • MessageChannel
  • postMessage

每次事件循环的第一件事就是执行一个宏任务。注意,页面首次加载时,整个 <script> 代码本身就是一个宏任务。

微任务(MicroTask)

微任务是由 JavaScript 引擎自身发起的任务,常见的微任务包括:

  • Promise.then / catch / finally
  • queueMicrotask
  • MutationObserver
  • process.nextTick(Node.js 环境,优先级高于 Promise)

微任务的执行时机是:在当前宏任务执行结束后、下一个宏任务开始之前,清空整个微任务队列。 如果在微任务执行过程中又产生了新的微任务,这些新微任务也会在本次循环中被一并执行,直到微任务队列为空。

一个经典的代码示例

console.log('script start');

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

Promise.resolve()
  .then(() => {
    console.log('promise1');
  })
  .then(() => {
    console.log('promise2');
  });

console.log('script end');

输出结果:

script start
script end
promise1
promise2
setTimeout

执行过程分析:

  1. 整体 script 作为第一个宏任务开始执行,输出 script start
  2. 遇到 setTimeout,将其回调交给定时器线程,时间到后推入宏任务队列。
  3. 遇到 Promise.resolve().then,将回调推入微任务队列。
  4. 输出 script end,宏任务执行完毕。
  5. 清空微任务队列:依次输出 promise1promise2
  6. 微任务清空后,进行渲染(如果有需要)。
  7. 下一轮事件循环,取出宏任务队列中的 setTimeout 回调并执行,输出 setTimeout

微任务与渲染的关系

微任务的清空发生在渲染之前。这意味着如果你在微任务中不断产生新的微任务,页面将永远没有机会渲染,造成界面卡死。例如:

function loop() {
  Promise.resolve().then(loop);
}
loop();

这段代码会无限循环执行微任务,浏览器无法进行渲染,页面会完全冻结。相比之下,使用 setTimeout 递归则不会阻塞渲染,因为每次宏任务之间都会给渲染留出机会。

宏任务与微任务的常见误区

误区一:setTimeout(fn, 0) 会立即执行。

实际上,setTimeout 的最小延迟通常为 4ms(HTML 规范规定),而且它必须等待当前宏任务和所有微任务执行完毕,还要等待渲染完成后才会执行。因此 setTimeout(fn, 0) 并不代表立即执行。

误区二:微任务比宏任务优先级高。

更准确的说法是:微任务在每一个宏任务结束后立即执行,而宏任务之间需要等待下一轮事件循环。 它们不在同一个层级上比较优先级,而是执行时机不同。

误区三:Promise 的回调是异步的,所以和 setTimeout 一样。

Promise.then 的回调是微任务,而 setTimeout 的回调是宏任务。在同一个宏任务中产生的微任务,会在该宏任务结束后立刻执行,远早于下一个宏任务。

实际开发中的建议

  1. 避免在微任务中无限递归,否则会导致页面无法渲染。
  2. 需要尽快执行但又不想阻塞渲染时,可以使用 requestAnimationFramesetTimeout
  3. 需要批量更新 DOM 时,可以利用微任务在渲染前统一处理,例如 Vue 的 nextTick 就是基于微任务实现的。
  4. 理解 async/await 的本质await 后面的代码相当于放在 Promise.then 中执行,属于微任务。

总结

浏览器事件循环是 JavaScript 异步编程的基石。它的核心规则可以概括为:

  • 每轮事件循环执行一个宏任务
  • 宏任务结束后,清空所有微任务
  • 微任务清空后,进行渲染
  • 然后进入下一轮循环。

掌握宏任务与微任务的执行顺序,不仅能帮助你准确预测代码的输出结果,还能让你在性能优化和架构设计中做出更明智的决策。希望本文能为你深入理解浏览器运行机制提供清晰的指引。

未经允许不得转载:任鹏个人博客 » 深入理解浏览器事件循环:从宏任务到微任务

赞 (0) 打赏

评论 0

取消
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

微信扫一扫打赏