重提浏览器事件循环

91 阅读6分钟

JavaScript 是一种单线程语言,这意味着它一次只能执行一个任务。然而,浏览器环境(以及 Node.js 等运行时环境)提供了异步操作的能力,例如网络请求、定时器和用户交互。为了协调这些异步操作并确保 JavaScript 引擎不会阻塞主线程,浏览器引入了 事件循环 (Event Loop) 机制。

什么是事件循环 (Event Loop)?

事件循环是 JavaScript 运行时环境中的一个核心机制,它负责协调代码的执行、事件的处理以及 DOM 的渲染。 它可以被看作是一个永无止境的循环,不断地检查是否有任务需要执行,并将其推入 JavaScript 引擎的执行栈中。

事件循环的核心组件

理解事件循环需要了解几个关键组件:

  1. 调用栈 (Call Stack)

    • 这是一个后进先出 (LIFO) 的数据结构,用于存储正在执行的函数。
    • 当 JavaScript 代码执行时,函数调用会被推入栈中,函数执行完毕后会从栈中弹出。
    • JavaScript 引擎一次只能处理调用栈顶部的任务。
  2. 堆 (Heap)

    • 这是一个非结构化的内存区域,用于存储对象和变量。
  3. Web APIs (浏览器提供的 API)

    • 这些是浏览器提供给 JavaScript 引擎的功能,用于处理异步任务,例如:

      • setTimeout()setInterval() (定时器)
      • fetch()XMLHttpRequest (网络请求)
      • addEventListener() (DOM 事件,如点击、键盘输入等)
      • requestAnimationFrame() (动画帧请求)
      • MutationObserver (DOM 变动观察)
    • 当调用这些 API 时,它们会将相应的异步操作交给浏览器处理,而不是在 JavaScript 主线程中执行。

  4. 回调队列 (Callback Queues)

    • 当 Web API 完成其异步操作后,会将对应的回调函数放入一个或多个回调队列中等待执行。
    • 这些队列通常分为两种主要类型:宏任务队列 (Macrotask Queue)微任务队列 (Microtask Queue)

宏任务 (Macrotasks / Tasks)

宏任务是较大粒度的任务。当一个宏任务执行时,它会完整地运行直到结束。

常见的宏任务包括:

  • 整个 script (初始执行的 JavaScript 代码)

  • setTimeout()setInterval() 的回调

  • UI 渲染事件(如解析 HTML、生成 DOM)

  • I/O 操作(如网络请求完成后的回调)

  • 用户交互事件(如 click, mousemove 等)

  • requestAnimationFrame (虽然它与渲染紧密相关,但在某些模型中被视为一种特殊的宏任务,或者在渲染阶段前被处理) 宏任务的特点:

  • 事件循环的每次迭代只会从宏任务队列中取出一个宏任务执行。

  • 一个宏任务执行完毕后,浏览器会检查微任务队列。

微任务 (Microtasks)

微任务是更小粒度、优先级更高的任务。它们通常用于在当前宏任务结束之后、下一个宏任务开始之前,对应用程序状态进行更新。

常见的微任务包括:

  • Promise 的回调 (.then(), .catch(), .finally())
  • MutationObserver 的回调
  • queueMicrotask()

微任务的特点:

  • 微任务队列的优先级高于宏任务队列。
  • 在一个宏任务执行完毕后,事件循环会清空所有微任务队列中的任务,然后才会进入下一个宏任务的执行。
  • 这意味着在同一个宏任务周期内,所有排队的微任务都会被执行。

执行顺序 (事件循环的完整机制)

事件循环的整个流程可以概括为以下步骤:

  1. 执行主线代码 (Initial Macrotask)

    • 当浏览器加载页面时,首先执行 <script> 标签中的所有同步 JavaScript 代码。这些代码被视为第一个宏任务。
    • 同步代码会立即推入调用栈并执行。
  2. 清空调用栈 (Call Stack)

    • 当所有同步代码执行完毕,调用栈变为空。
  3. 执行所有微任务 (Drain Microtask Queue)

    • 一旦调用栈为空,事件循环会立即检查微任务队列。
    • 如果微任务队列中有任务,事件循环会按先进先出 (FIFO) 的顺序,将所有微任务逐个推入调用栈并执行,直到微任务队列为空。
    • 在这个过程中,如果微任务又产生了新的微任务,新的微任务也会被添加到当前微任务队列的末尾,并在当前循环中被执行。
  4. DOM 渲染 (Rendering)

    • 当微任务队列清空后,浏览器可能会进行渲染更新。
    • 渲染通常发生在每个事件循环周期之后(即一个宏任务执行完毕,并且所有微任务也执行完毕之后)。
    • 浏览器通常会以 60 帧每秒 (FPS) 的频率进行渲染,这意味着大约每 16.6 毫秒会尝试更新一次屏幕。
    • 重要提示:在 JavaScript 执行一个宏任务期间,DOM 渲染不会发生。 只有当宏任务及其所有微任务都完成后,浏览器才有机会进行渲染。
  5. 执行下一个宏任务 (Next Macrotask)

    • 渲染完成后,事件循环会从宏任务队列中取出一个宏任务(如果有的话),将其推入调用栈并执行。
    • 这个过程会重复步骤 2-5,形成一个持续的循环。 requestAnimationFrame 的特殊性:
      requestAnimationFrame (rAF) 是专门用于动画的 API。它的回调函数会在浏览器下一次重绘之前执行。 这使得它非常适合进行 DOM 操作和动画,因为它可以确保在渲染之前执行代码,从而避免不必要的重排和重绘,并与浏览器的渲染周期同步。 通常,rAF 的回调会在微任务执行之后、实际渲染之前被执行。

总结流程图 (简化版)

┌───────────────────────────┐
│        Call Stack         │
└───────────────────────────┘
             │
             │ (同步代码执行)
             ▼
┌───────────────────────────┐
│        Web APIs           │  (setTimeout, fetch, DOM Events, etc.)
└───────────────────────────┘
             │
             │ (异步操作完成,回调入队)
             ▼
┌───────────────────────────┐     ┌───────────────────────────┐
│     Microtask Queue       │ <───│     Promise Callbacks     │
│  (Promise, MutationObserver)│     └───────────────────────────┘
└───────────────────────────┘
             │
             │ (微任务清空后)
             ▼
┌───────────────────────────┐
│        DOM Rendering      │ (浏览器重绘)
└───────────────────────────┘
             │
             │ (渲染后)
             ▼
┌───────────────────────────┐     ┌───────────────────────────┐
│     Macrotask Queue       │ <───│    setTimeout Callbacks   │
│ (setTimeout, UI Events, I/O)│     │    User Event Callbacks   │
└───────────────────────────┘     └───────────────────────────┘
             │
             │ (取出一个宏任务)
             ▼
        (回到 Call Stack)

示例

console.log('Script start'); // 1. 同步代码

setTimeout(function() {
  console.log('setTimeout callback'); // 4. 宏任务
}, 0);

Promise.resolve().then(function() {
  console.log('Promise callback 1'); // 3. 微任务
}).then(function() {
  console.log('Promise callback 2'); // 3. 微任务 (新的微任务,在当前微任务批次中执行)
});

// 模拟一个 DOM 操作,它可能会触发渲染
const div = document.createElement('div');
div.textContent = 'Hello';
document.body.appendChild(div);
console.log('DOM operation'); // 2. 同步代码

requestAnimationFrame(function() {
  console.log('requestAnimationFrame callback'); // 渲染前执行
});

console.log('Script end'); // 2. 同步代码

输出顺序分析:

  1. Script start (同步代码,立即执行)

  2. DOM operation (同步代码,立即执行)

  3. Script end (同步代码,立即执行)

    • 此时,调用栈清空。
    • setTimeout 的回调被放入宏任务队列。
    • Promise 的两个回调被放入微任务队列。
    • requestAnimationFrame 的回调被注册到渲染阶段。
  4. Promise callback 1 (微任务队列中的第一个任务,立即执行,因为调用栈已空)

  5. Promise callback 2 (微任务队列中的第二个任务,紧接着 Promise callback 1 执行,因为微任务队列会一次性清空)

    • 此时,微任务队列清空。
  6. requestAnimationFrame callback (在微任务清空后,渲染之前执行)

  7. (浏览器进行 DOM 渲染)

  8. setTimeout callback (微任务和渲染完成后,事件循环从宏任务队列中取出下一个宏任务执行)

实际输出:

Script start
DOM operation
Script end
Promise callback 1
Promise callback 2
requestAnimationFrame callback
setTimeout callback

理解事件循环对于编写高性能、非阻塞的 JavaScript 代码至关重要,尤其是在处理复杂的 UI 交互和大量异步数据时。