浏览器和 Node.js 的 EventLoop,原来差别这么大

在 JavaScript 中,Event Loop 是处理异步操作的核心机制,但不同运行环境下的实现存在显著差异。

浏览器的 EventLoop 是跟着渲染引擎走的,它得兼顾 JavaScript 执行和页面渲染(比如重排重绘),所以设计上要考虑视觉呈现的流畅性。

浏览器的EventLoop流程如下:

1.一开始整段脚本作为第一个宏任务执行

2.执行过程中同步代码直接执行,宏任务 进入宏任务队列,微任务进入微任务队列

3.当前宏任务执行完出队,检查微任务队列,如果有则依次执行,直到微任务队列为空

4.执行浏览器 UI 线程的渲染工作

5.检查是否有Web worker任务,有则执行

6.执行队首新的宏任务,回到2,依此循环,直到宏任务和微任务队列都为空

而 Node.js 是跑在 V8 引擎上的后端环境,没有渲染压力,但多了很多 I/O 操作(比如读写文件、网络请求),所以它的 EventLoop 更侧重高效处理异步 I/O

plaintext 复制代码
     ┌───────────────────────┐
 ------->│        timers         │ ◄── 执行到期的 setTimeout / setInterval
 |   └──────────┬────────────┘
 |              │
 |   ┌──────────▼────────────┐
 |   │    I/O callbacks      │ ◄── 执行系统级回调(如 TCP 错误)
 |   └──────────┬────────────┘
 |              │
 |   ┌──────────▼────────────┐
 |   │    idle, prepare      │ ◄── Node.js 内部使用(忽略细节)
 |   └──────────┬────────────┘
 |              │
 |   ┌──────────▼────────────┐
 |   │         poll          │ ◄── ★ 核心阶段:处理 I/O 回调
 |   │                       │     - 如果 poll 队列为空:
 |   │      (等待或跳转)      │       1. 有 setImmediate → 跳转 check 阶段
 |   │                       │       2. 无 → 等待新 I/O 事件(或执行到期 timer)
 |   └──────────┬────────────┘
 |              │
 |   ┌──────────▼────────────┐
 |   │        check          │ ◄── 执行 setImmediate 回调
 |   └──────────┬────────────┘
 |              │
 |   ┌──────────▼────────────┐
 ------- │    close callbacks    │ ◄── 执行关闭事件回调(如 socket.close)
     └──────────┬────────────┘
     

从图表中可以看出,Nodede的事件循环分为 6 个阶段 ,每个阶段都有一个 FIFO(先进先出)队列 来执行回调函数:

Node.js 的 Event Loop 分为六个主要阶段,每个阶段都有其特定的功能和任务队列:

  1. timers 阶段:这个阶段主要执行那些设置了定时器(setTimeout和setInterval)的回调函数。不过要注意,定时器的时间并不是绝对准确的,它只是表示在指定时间后,将回调函数放入任务队列,具体执行时间还要看 Event Loop 的调度。

  2. I/O callbacks 阶段:此阶段会执行一些系统底层的 I/O 操作的回调,比如网络请求、文件读取等操作完成后的回调。但这里执行的 I/O 回调,并不是所有 I/O 操作的回调,像setTimeout和setInterval的回调就不在此列。

  3. idle, prepare 阶段:这两个阶段主要是 Node.js 内部使用的,对我们开发者来说,一般不需要过多关注。

  4. poll 阶段:这可是 Event Loop 的核心阶段。在这个阶段,Event Loop 会检查新的 I/O 事件,如果有新的 I/O 操作请求,它将在此阶段被处理。当调用栈为空且没有被调度的任务时,Event Loop 将会处于该阶段,直到有新的请求到来或者达到特定条件。如果 poll 队列中有任务,Event Loop 会依次执行这些任务;如果 poll 队列中没有任务,且有setImmediate的任务在等待,Event Loop 会进入到 check 阶段。

  5. check 阶段:这个阶段会执行setImmediate的回调函数。setImmediate是一种特殊的异步方法,它的回调函数会在 poll 阶段之后、下一轮定时器检查之前执行。

  6. close callbacks 阶段:此阶段会处理一些关闭事件的回调,比如socket.on('close', ...)的回调。当一个 socket 连接关闭时,对应的回调函数就会在这个阶段执行。

先看一段代码

js 复制代码
setTimeout(() => console.log('timeout'), 0);

setImmediate(() => console.log('immediate'));

在 Node 里跑,你可能得到两种结果:有时候timeout在前,有时候immediate在前。因为setTimeout(0)实际会被转为 1ms(Node 的最小延迟),如果 EventLoop 刚好在 timers 阶段检查时还没到 1ms,就会先去 poll 阶段,这时setImmediate就会在 check 阶段先执行;如果刚好到了 1ms,timeout就会先执行。

但在浏览器里,setImmediate根本不存在,所以只会输出timeout。

虽然两者都有宏任务和微任务,但具体分类和处理方式有细节差异。

浏览器里的宏任务比较好记:setTimeout、setInterval、script整体代码、I/O 操作、UI 渲染等。微任务主要是Promise.then/catch/finally、MutationObserver、queueMicrotask这些。

Node.js 里的宏任务分得更细,而且有明确的执行阶段(前面讲过的六个阶段)。除了和浏览器共有的setTimeout、setInterval,还多了setImmediate(Node 独有的)、I/O 回调、关闭回调等。微任务方面,除了Promise相关,还有个特殊的process.nextTick------ 这货优先级比普通微任务还高,上面的六个阶段不包含 process.nextTick(),会在所有微任务执行前先跑,而且是 "插队狂魔",如果在里面循环调用,能把后面的任务全堵死。

再看下面这一段代码

js 复制代码
console.log('start')
setTimeout(()=>{
    console.log('timer1')
    Promise.resolve().then(function() {
        console.log('promise1')
    })
}, 0)
setTimeout(()=>{
    console.log('timer2')
    Promise.resolve().then(function() {
        console.log('promise2')
    })
}, 0)
console.log('end')

浏览器和node打印出来的都是:

sql 复制代码
start
end
timer1
promise1
timer2
promise2

虽然上面的代码在浏览器和 Node 环境下输出一致,但背后的执行逻辑却大相径庭:

先看浏览器的执行过程:

  • 整段脚本作为第一个宏任务执行,同步打印start和end

  • 两个setTimeout回调进入宏任务队列,此时队列顺序是timer1、timer2

  • 第一个宏任务执行完毕,微任务队列为空,直接取队首宏任务(timer1)执行

  • 打印timer1后,Promise.then进入微任务队列,当前宏任务执行完毕

  • 清空微任务队列,打印promise1

  • 取第二个宏任务(timer2)执行,打印timer2后,Promise.then进入微任务队列

  • 清空微任务队列,打印promise2

而 Node 的执行路径要绕得多:

  • 同步代码执行完,两个setTimeout回调被放入 timers 阶段的队列

  • 进入 timers 阶段,先执行第一个定时器回调,打印timer1

  • 此时Promise.then被加入微任务队列,当前阶段任务执行完毕

  • 按照规则,先清空微任务队列(这里只有promise1),打印promise1

  • 进入下一个阶段(I/O callbacks),但这里没有任务,一路走到 poll 阶段

  • poll 阶段检查到没有新 I/O 事件,且有定时器回调待执行,于是跳回 timers 阶段

  • 执行第二个定时器回调,打印timer2,Promise.then进入微任务队列

  • 阶段任务执行完毕,清空微任务队列,打印promise2

简单来说,浏览器的 EventLoop 是 "单线程 + 渲染优先" 的模式,流程相对简单,重点是协调 JavaScript 执行和页面渲染;Node.js 的 EventLoop 是 "单线程 + 多 I/O 线程" 的模式,流程更复杂,分阶段处理不同类型的异步任务,还多了process.nextTicksetImmediate这些特殊角色。

个人认为,写浏览器代码时不用太纠结阶段划分,记住 "微任务先于宏任务,每次宏任务后可能渲染" 就行;但写 Node 代码时,一定要注意阶段顺序和process.nextTick的特殊性,不然很容易掉坑里。

相关推荐
小样还想跑12 分钟前
axios无感刷新token
前端·javascript·vue.js
Java水解22 分钟前
一文了解Blob文件格式,前端必备技能之一
前端
用户38022585982443 分钟前
vue3源码解析:响应式机制
前端·vue.js
bo521001 小时前
浏览器渲染机制详解(包含渲染流程、树结构、异步js)
前端·面试·浏览器
普通程序员1 小时前
Gemini CLI 新手安装与使用指南
前端·人工智能·后端
山有木兮木有枝_1 小时前
react受控模式和非受控模式(日历的实现)
前端·javascript·react.js
流口水的兔子1 小时前
作为一个新手,如果让你去用【微信小程序通过BLE实现与设备通讯】,你会怎么做,
前端·物联网·微信小程序
多啦C梦a1 小时前
🪄 用 React 玩转「图片识词 + 语音 TTS」:月影大佬的 AI 英语私教是怎么炼成的?
前端·react.js
呆呆的心1 小时前
大厂面试官都在问的 WEUI Uploader,源码里藏了多少干货?🤔
前端·微信·面试
heartmoonq1 小时前
深入理解 Vue 3 响应式系统原理:Proxy、Track 与 Trigger 的协奏曲
前端