浏览器 vs Node.js:同一个 JavaScript,截然不同的事件循环

120 阅读4分钟

事件循环是现代 JavaScript 并发模型的核心机制,它使得单线程的 JavaScript 能够高效地处理异步任务、I/O 操作和用户交互。尽管浏览器和 Node.js 都基于事件循环,但二者在实现机制、任务调度和阶段划分上存在显著差异。深入理解事件循环的运作原理,对于编写高性能、可预测的异步代码至关重要。

1 浏览器中的事件循环

1.1 基本模型与执行流程

浏览器的事件循环模型负责协调执行栈(Call Stack)、宏任务(Macrotask / Task)队列和微任务(Microtask)队列之间的协作。其核心流程可概括为以下步骤: image-20211015121213384

  1. 初始状态

    • 执行栈为空;
    • 微任务队列为空;
    • 宏任务队列中包含唯一的初始任务:<script> 标签内的全局代码。
  2. 全局执行上下文入栈

    • 同步代码按顺序执行。执行过程中,异步任务根据类型被分发到对应的任务队列:
  3. 宏任务出队与执行

    • 当前宏任务(即初始脚本)执行完毕,出队;
    • 执行过程中产生的微任务被加入微任务队列。
  4. 微任务队列清空

    • 每执行完一个宏任务后,事件循环会连续清空微任务队列中的所有任务,直到微任务队列为空。
  5. 循环继续

  • 从宏任务队列中取出下一个任务,重复上述过程。

注意:微任务队列的清空发生在每一个宏任务之后、渲染之前,这意味着微任务可以阻塞渲染,应避免在微任务中执行过长操作。

1.2 常见的宏任务与微任务

宏任务微任务
setTimeout / setIntervalPromise.then / catch / finally
setImmediate (非标准)MutationObserver
I/O 操作queueMicrotask
UI 事件(如 clickprocess.nextTick (Node.js)
requestAnimationFrame

2 Node.js 中的事件循环

2.1 架构概述

Node.js 基于 libuv 库实现事件循环,其设计目标是为 I/O 密集型应用提供非阻塞的异步 I/O 操作。Node.js 的事件循环比浏览器更为复杂,分为六个阶段,每个阶段专门处理特定类型的回调。 image-20211029160543365

  1. JavaScript 解析与执行

    • V8 引擎解析并执行 JavaScript 代码;
    • 调用 Node.js 提供的 API(如 fs.readFilehttp.createServer)。
  2. libuv 调度与执行

    • libuv 将不同类型的任务分配给不同的线程池(如文件 I/O、DNS 解析),并在完成后将回调推入事件循环的相应阶段。
  3. 事件循环阶段执行

    • 循环依次进入各阶段,执行该阶段队列中的回调函数。
  4. 结果返回

    • 执行结果通过回调函数返回给 V8,最终传递给用户代码。

2.2 六个阶段的详细说明

node
  1. timers 阶段

    • 执行 setTimeoutsetInterval 的回调;
    • 注意:定时器回调的实际执行时间可能晚于预设时间,受系统调度和前一阶段执行时间影响。
  2. I/O callbacks 阶段

    • 执行系统操作(如 TCP 错误)的回调;
    • 处理上一轮循环未执行的少数 I/O 回调。
  3. idle, prepare 阶段

    • 仅供 Node.js 内部使用,开发者通常无需关注。
  4. poll 阶段(核心阶段)

    • 检索新的 I/O 事件,执行 I/O 回调(如文件读取、网络请求);
    • 计算应阻塞并等待 I/O 的时间;
    • 若队列为空,则检查是否有 setImmediate 回调,如有则进入 check 阶段;否则等待新事件。
  5. check 阶段

    • 执行 setImmediate 的回调。
  6. 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.nextTickPromise.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支持,用于动画不支持