浏览器的事件循环机制

浏览器的每个页面都有一个单线程的 JavaScript 引擎(可参考我的另一篇文章:浏览器是单线程的?),这意味着它只有一个主线程来处理所有任务。这个主线程身兼数职,既要渲染页面、执行 JavaScript 代码,又要处理用户交互。

如果一个任务需要很长时间才能完成,比如网络请求、文件读写或者数据库查询,它就会阻塞主线程,导致程序在这段时间内没有任何响应,用户界面也会卡住。

事件循环机制就是为了解决这个问题而设计的。它将耗时的 I/O 操作变成了异步的,避免了因等待耗时操作而导致的“卡顿”和无响应,从而大大提高了程序的性能和用户体验。

事件循环的核心组成

事件循环主要由三个核心部分构成:

1. 调用栈(Call Stack

这是一个后进先出的数据结构,用来追踪正在执行的函数。每当一个函数被调用时,它就会被推入栈中;当函数执行完毕,它就会被弹出。

2. Web APIs

浏览器提供的一系列非主线程的异步功能,比如:

  • setTimeoutsetInterval
  • AJAX 请求
  • DOM 事件监听
  • requestAnimationFrame

当主线程遇到这些异步任务时,它会将它们交给 Web APIs 处理,然后立即继续执行栈中的下一个任务,而不会等待异步任务完成。

3. 任务队列(Task Queue)

Web APIs 中的异步任务执行完成后,比如一个定时器时间到了,或者一个网络请求返回了数据,它会把对应的回调函数放入任务队列中等待。

任务队列的分类

宏任务队列

代表一个相对完整的、独立的代码块

包括:

  • script(整体代码块)
  • setTimeoutsetInterval
  • I/O 操作(如文件读写、网络请求)
  • UI 渲染、DOM 事件
  • requestAnimationFrame(浏览器环境)
  • MessageChannelReact 实现时间切片)

微任务队列

代表优先级更高任务

包括:

  • Promise.then().catch().finally()
  • MutationObserver
  • queueMicrotask()

事件循环的执行规则

核心规则:执行完一个宏任务后,立即清空所有微任务,然后再进行 UI 渲染(如有必要),最后再开启下一个宏任务。

详细执行流程:

  1. 首先,整个 <script> 标签里的代码作为一个宏任务开始执行
  2. 在执行过程中,同步代码直接在调用栈上运行
  3. 如果遇到一个宏任务(如 setTimeout),就把它交给对应的 Web API,并在指定时间后将其回调函数放入宏任务队列
  4. 如果遇到一个微任务(如 Promise.then),就将其回调函数放入微任务队列
  5. 当前这个宏任务(即主脚本)执行完毕,调用栈变空
  6. 关键时刻来了!事件循环会立即检查微任务队列,将里面所有的微任务依次取出并执行,直到微任务队列被清空
  7. 微任务清空后,浏览器会进行一次 UI 渲染(如果页面有变化的话)
  8. 渲染完成后,会继续执行下一个宏任务
  9. 重复以上步骤,直到任务队列中没有宏任务为止

简单来说,宏任务是“大块”的任务,每次循环只执行一个;而微任务是“小而急”的任务,会在当前宏任务执行完后被立即“插队”全部执行完,然后再开始下一个宏任务。

实际应用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 示例:事件循环执行顺序
console.log('1'); // 同步代码,立即执行

setTimeout(() => {
console.log('2'); // 宏任务
}, 0);

Promise.resolve().then(() => {
console.log('3'); // 微任务,当前宏任务执行完成后执行所有的微任务
});

console.log('4'); // 同步代码,立即执行

// 输出顺序:1 → 4 → 3 → 2

这个示例展示了事件循环的执行顺序:

  1. 先执行同步代码(1, 4)
  2. 然后执行所有微任务(3)
  3. 最后执行宏任务(2)

复杂示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
console.log('start');

setTimeout(() => {
console.log('setTimeout1');
Promise.resolve().then(() => {
console.log('promise1');
});
}, 0);

Promise.resolve().then(() => {
console.log('promise2');
setTimeout(() => {
console.log('setTimeout2');
}, 0);
});

console.log('end');

// 输出顺序:start → end → promise2 → setTimeout1 → promise1 → setTimeout2

扩展

React 时间切片为什么用宏任务实现而不用微任务实现

首先时间切片的目标不是尽快完成任务,而是在不阻塞主线程的前提下,将一个大的计算任务分解成小的、可中断的单元。

如果 React 使用微任务来实现时间切片,它会把所有待处理的更新任务都放入微任务队列。由于微任务会在当前宏任务执行完后全部执行,React 就会一次性霸占主线程,直到所有更新计算完毕。这和“时间切片”的初衷——让出主线程——完全背道而驰。UI 依然会因为长时间的计算而卡死,用户无法进行任何交互。

使用宏任务,React 可以在执行完一小部分更新后,主动暂停,将剩余的工作放入一个新的宏任务(通过 MessageChannelsetTimeout)。这样,事件循环就可以在两次宏任务之间运行,浏览器就能在中间插入执行 UI 渲染任务和响应用户的点击、键盘输入。这种“暂停-恢复”的机制正是时间切片的核心所在。