0%

Node事件循环与浏览器事件循环的差异

javascript 是一门单线程语言,为了协调同步任务与异步任务,它引入了事件循环(Event Loop)的机制。然而,javascript 的运行环境主要有两种——浏览器和 Node.js,它们的事件循环实现方式有显著差异。理解这些差异对于编写可靠的跨环境代码至关重要。

宏观架构差异

浏览器事件循环

浏览器的事件循环由宿主环境(浏览器)实现,主要协调以下组成部分:

  • 调用栈(Call Stack):执行同步代码。
  • 宏任务队列(MacroTask Queue):存放 setTimeout、setInterval、I/O、UI 渲染等回调 。
  • 微任务队列(MicroTask Queue):存放 Promise.then、MutationObserver 等回调 。
  • 渲染引擎:负责页面绘制,与事件循环协同工作 。

Node.js 事件循环

Node.js 的事件循环由 libuv 库 实现,它包含多个阶段(phase),每个阶段都有自己的任务队列 。Node.js 的事件循环结构更复杂,因为它需要处理底层操作系统的 I/O 操作。

维度 浏览器事件循环 Node.js 事件循环
核心实现 浏览器宿主环境 libuv 库
阶段划分 简化为宏任务队列 + 微任务队列 细分为 6 个阶段(timers、I/O、poll、check 等)
任务粒度 每个宏任务队列执行一个任务就切换 每个阶段会清空当前阶段的整个队列
微任务执行时机 每个宏任务执行后清空微任务 每个阶段之后清空微任务(Node 11+ 与浏览器对齐)

Node.js 事件循环的六个阶段

Node.js 的事件循环按照以下顺序循环执行 :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
   ┌───────────────────────────┐
┌─>│ timers │ 执行 setTimeout、setInterval 的回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ I/O callbacks │ 执行延迟到下一轮的 I/O 回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ idle, prepare │ 系统内部使用
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ poll │ 检索新的 I/O 事件,执行 I/O 回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ check │ 执行 setImmediate 回调
│ └───────────┬───────────────┘
│ ┌───────────▼───────────────┐
│ │ close callbacks │ 执行 socket 等关闭事件的回调
│ └───────────────────────────┘
└──────────────┘

每个阶段结束后,Node.js 会先清空微任务队列(process.nextTick 和 Promise.then),然后进入下一阶段 。

核心差异详解

任务队列结构

  • 浏览器:只有两个任务队列(宏任务、微任务)。
  • Node.js:每个阶段都有独立的队列,外加两个微任务队列(nextTick 队列和 Promise 队列)。

任务执行粒度

  • 浏览器:从宏任务队列中取出第一个任务执行,然后清空微任务队列,接着检查是否需要渲染,再取下一个宏任务 。
  • Node.js:进入一个阶段后,会清空该阶段队列中的所有任务,然后才执行微任务,再进入下一阶段 。

微任务执行时机

  • 浏览器:每个宏任务执行完毕后清空微任务 。
  • Node.js:
    – Node 11 之前:在阶段切换时才清空微任务,可能导致一个阶段内积累大量微任务 。
    – Node 11 及之后:在每个宏任务执行后也会清空微任务,行为与浏览器基本对齐 。

特殊 API

  • 浏览器独有:requestAnimationFrame、MutationObserver 。
  • Node.js 独有:setImmediate、process.nextTick 。
    – setImmediate 在 check 阶段执行 。
    – process.nextTick 不属于事件循环的任何阶段,而是在每个阶段结束后立即执行,优先级高于 Promise 。

代码示例验证差异

示例 1:setTimeout 与 setImmediate 的执行顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- Node.js 环境 -->
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));

--> 执行结果可能不确定 :
--> 1. 如果事件循环进入 timers 阶段时,定时器时间已到 → setTimeout 先执行。
--> 2. 如果准备时间较短,定时器还没到期 → 跳过 timers,进入 poll → check → setImmediate 先执行。

<!-- 固定顺序的技巧:将代码放在 I/O 回调内 -->
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate')); // 总是先于 setTimeout
}); // 输出:setImmediate → setTimeout

--> 因为在 I/O 回调中,事件循环当前处于 poll 阶段,下一阶段是 check,所以 setImmediate 总是先执行 。

示例 2:process.nextTick 与 Promise 的优先级

1
2
3
4
5
6
7
8
9
10
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('promise'));
setTimeout(() => console.log('setTimeout'), 0);

<!-- Node.js 输出顺序: -->
nextTick
promise
setTimeout

--> process.nextTick 优先级高于 Promise,且在所有阶段切换时都会执行 。

示例 3:Node 11 前后的行为差异

1
2
3
4
5
6
7
8
9
10
11
12
13
setTimeout(() => {
console.log('timeout1');
Promise.resolve().then(() => console.log('promise1'));
}, 0);

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

--> Node 10:timeout1 → timeout2 → promise1 → promise2(每个阶段内先清空所有宏任务,再执行微任务)
--> Node 11+ 及浏览器:timeout1 → promise1 → timeout2 → promise2(每个宏任务后执行微任务)

总结与建议

对比维度 浏览器事件循环 Node.js 事件循环
核心模型 宏任务 + 微任务 多阶段 + 微任务
微任务时机 每个宏任务后 每个阶段后(Node 11+ 也支持每个宏任务后)
特殊 API requestAnimationFrame setImmediate, process.nextTick
复杂程度 相对简单 更复杂,涉及系统 I/O

编写跨环境代码的建议

  1. 避免依赖执行顺序:尽量不要编写依赖 setTimeout 与 setImmediate 执行顺序的业务代码。
  2. 使用 queueMicrotask:如果需要微任务,优先使用标准 API queueMicrotask 而不是 process.nextTick。
  3. 了解版本差异:Node 11+ 的事件循环与浏览器行为更接近,但仍需注意 nextTick 等特有 API 的存在 。
  4. 利用 I/O 回调稳定顺序:在 Node.js 中,如果需要在 I/O 之后立即执行任务,setImmediate 是最可靠的选择 。