事件循环是现代 JavaScript 并发模型的核心机制,它使得单线程的 JavaScript 能够高效地处理异步任务、I/O 操作和用户交互。尽管浏览器和 Node.js 都基于事件循环,但二者在实现机制、任务调度和阶段划分上存在显著差异。深入理解事件循环的运作原理,对于编写高性能、可预测的异步代码至关重要。
1 浏览器中的事件循环
1.1 基本模型与执行流程
浏览器的事件循环模型负责协调执行栈(Call Stack)、宏任务(Macrotask / Task)队列和微任务(Microtask)队列之间的协作。其核心流程可概括为以下步骤:
-
初始状态:
- 执行栈为空;
- 微任务队列为空;
- 宏任务队列中包含唯一的初始任务:
<script>标签内的全局代码。
-
全局执行上下文入栈:
- 同步代码按顺序执行。执行过程中,异步任务根据类型被分发到对应的任务队列:
-
宏任务出队与执行:
- 当前宏任务(即初始脚本)执行完毕,出队;
- 执行过程中产生的微任务被加入微任务队列。
-
微任务队列清空:
- 每执行完一个宏任务后,事件循环会连续清空微任务队列中的所有任务,直到微任务队列为空。
-
循环继续:
- 从宏任务队列中取出下一个任务,重复上述过程。
注意:微任务队列的清空发生在每一个宏任务之后、渲染之前,这意味着微任务可以阻塞渲染,应避免在微任务中执行过长操作。
1.2 常见的宏任务与微任务
| 宏任务 | 微任务 |
|---|---|
setTimeout / setInterval | Promise.then / catch / finally |
setImmediate (非标准) | MutationObserver |
| I/O 操作 | queueMicrotask |
UI 事件(如 click) | process.nextTick (Node.js) |
requestAnimationFrame |
2 Node.js 中的事件循环
2.1 架构概述
Node.js 基于 libuv 库实现事件循环,其设计目标是为 I/O 密集型应用提供非阻塞的异步 I/O 操作。Node.js 的事件循环比浏览器更为复杂,分为六个阶段,每个阶段专门处理特定类型的回调。
-
JavaScript 解析与执行:
- V8 引擎解析并执行 JavaScript 代码;
- 调用 Node.js 提供的 API(如
fs.readFile、http.createServer)。
-
libuv 调度与执行:
- libuv 将不同类型的任务分配给不同的线程池(如文件 I/O、DNS 解析),并在完成后将回调推入事件循环的相应阶段。
-
事件循环阶段执行:
- 循环依次进入各阶段,执行该阶段队列中的回调函数。
-
结果返回:
- 执行结果通过回调函数返回给 V8,最终传递给用户代码。
2.2 六个阶段的详细说明
-
timers 阶段:
- 执行
setTimeout和setInterval的回调; - 注意:定时器回调的实际执行时间可能晚于预设时间,受系统调度和前一阶段执行时间影响。
- 执行
-
I/O callbacks 阶段:
- 执行系统操作(如 TCP 错误)的回调;
- 处理上一轮循环未执行的少数 I/O 回调。
-
idle, prepare 阶段:
- 仅供 Node.js 内部使用,开发者通常无需关注。
-
poll 阶段(核心阶段):
- 检索新的 I/O 事件,执行 I/O 回调(如文件读取、网络请求);
- 计算应阻塞并等待 I/O 的时间;
- 若队列为空,则检查是否有
setImmediate回调,如有则进入 check 阶段;否则等待新事件。
-
check 阶段:
- 执行
setImmediate的回调。
- 执行
-
close callbacks 阶段:
- 执行关闭事件的回调(如
socket.on('close', ...))。
- 执行关闭事件的回调(如
2.3 关键 API 行为差异
2.3.1 setTimeout vs setImmediate
setTimeout:在 timers 阶段执行,受 CPU 负载和前一阶段执行时间影响,可能存在延迟。setImmediate:在 check 阶段执行,通常在同一事件循环的 poll 阶段之后立即执行。
若两者在主模块中调用,其执行顺序不确定(受进程性能影响);但在 I/O 回调内部调用时,setImmediate 总是先于 setTimeout 执行。
2.3.2 process.nextTick 与 Promise.then
process.nextTick:不属于事件循环的任何阶段,在每个阶段结束后立即执行,优先级最高。Promise.then:同属微任务,但优先级低于process.nextTick。
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 输出顺序不确定
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 总是先输出 immediate,后输出 timeout
});
3 浏览器与 Node.js 事件循环的差异总结
| 特性 | 浏览器 | Node.js |
|---|---|---|
| 阶段划分 | 宏任务 + 微任务 | 6 个阶段 + 微任务 |
| 微任务执行时机 | 每个宏任务之后 | 每个阶段结束后 |
setImmediate | 不支持 | 支持,在 check 阶段执行 |
process.nextTick | 不支持 | 支持,优先级最高 |
requestAnimationFrame | 支持,用于动画 | 不支持 |