在 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 分为六个主要阶段,每个阶段都有其特定的功能和任务队列:
-
timers 阶段:这个阶段主要执行那些设置了
定时器
(setTimeout和setInterval)的回调函数
。不过要注意,定时器的时间并不是绝对准确的,它只是表示在指定时间后,将回调函数放入任务队列,具体执行时间还要看 Event Loop 的调度。 -
I/O callbacks 阶段:此阶段会执行一些系统底层的
I/O 操作的回调
,比如网络请求、文件读取等操作完成后的回调。但这里执行的 I/O 回调,并不是所有 I/O 操作的回调,像setTimeout和setInterval的回调就不在此列。 -
idle, prepare 阶段:这两个阶段主要是 Node.js 内部使用的,对我们开发者来说,一般不需要过多关注。
-
poll 阶段:这可是 Event Loop 的核心阶段。在这个阶段,Event Loop 会检查新的 I/O 事件,如果有新的 I/O 操作请求,它将在此阶段被处理。当
调用栈为空且没有被调度的任务
时,Event Loop 将会处于该阶段,直到有新的请求到来或者达到特定条件。如果 poll 队列中有任务,Event Loop 会依次执行这些任务;如果 poll 队列中没有任务,且有setImmediate
的任务在等待,Event Loop 会进入到 check 阶段。 -
check 阶段:这个阶段会执行s
etImmediate的回调函数
。setImmediate是一种特殊的异步方法,它的回调函数会在 poll 阶段之后、下一轮定时器检查之前执行。 -
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.nextTick
和setImmediate
这些特殊角色。
个人认为,写浏览器代码时不用太纠结阶段划分,记住 "微任务先于宏任务,每次宏任务后可能渲染" 就行;但写 Node 代码时,一定要注意阶段顺序和process.nextTick的特殊性,不然很容易掉坑里。