引言
很多前端同学在向全栈(BFF层)或者 Node.js 进阶时,都会遇到一个绕不开的坎——Event Loop(事件循环) 。
面试时,面对一段穿插着 setTimeout、Promise、async/await 甚至 process.nextTick 的代码,往往容易被绕晕。更别提浏览器和 Node.js 在事件循环的底层实现上还有着本质的区别。
本文将结合我个人的工程经验,带你从零开始,由浅入深地拆解 Event Loop。我们不仅要会做面试题,更要知道这种异步非阻塞的模型,为什么能让 Node.js 在服务器端扛住成千上万的并发。
一、 为什么我们需要 Event Loop?
JavaScript 诞生之初是作为浏览器的脚本语言,为了避免复杂的 DOM 渲染冲突,它被设计成了单线程。也就是说,同一时间只能干一件事。
但是,网页中有大量需要等待的任务:网络请求(Ajax)、定时器、图片加载。如果所有的操作都是同步阻塞的,用户点一个按钮发起请求,整个页面就会卡死,直到请求返回。
为了解决这个问题,消息队列(Message Queue) + Event Loop 诞生了。
它的核心思想是:把耗时的任务先扔到一边(交给宿主环境如浏览器或操作系统的其他线程处理),主线程继续飞速往下跑。等那些耗时任务有了结果,再通知主线程来执行回调。
二、 浏览器的 Event Loop:宏任务与微任务的交响乐
在浏览器的一次工作中,JS 的执行是从一个 script 宏任务开始的。当同步代码执行完后,会产生两种不同的异步任务:宏任务(Macrotask) 和 微任务(Microtask) 。
1. 任务分类
- 宏任务队列:
setTimeout、setInterval、事件绑定回调、Ajax 回调等。 - 微任务队列:
Promise.then/catch/finally、async/await的后续代码、queueMicrotask、以及前端特有的 DOM 监听类微任务MutationObserver。
2. 执行机制(核心运转规律)
浏览器的 Event Loop 遵循以下严格的顺序:
- 执行并清空当前宏任务(一开始是整个
script标签内的同步代码)。 - 清空整个微任务队列(如果执行微任务时又产生了新的微任务,会继续在当前阶段清空)。
- 检查是否需要进行页面渲染(GUI 渲染线程介入,重排重绘)。
- 开始下一轮 Event Loop,取出一个新的宏任务执行。
3. 终极实战拆解
来看一段经典的测试代码:
console.log('同步代码 1');
setTimeout(() => {
console.log('setTimeout 1');
Promise.resolve().then(() => {
console.log('setTimeout 1 内部微任务');
});
}, 0);
const promise1 = new Promise((resolve) => {
console.log('Promise 构造函数');
resolve();
console.log('Promise 构造函数内 resolve 后');
});
promise1.then(() => {
console.log('Promise.then 1');
setTimeout(() => {
console.log('Promise.then 1 内部 setTimeout');
}, 0);
});
async function asyncFn() {
console.log('async 函数同步部分');
await Promise.resolve(); // 异步变同步的语法糖
console.log('await 后微任务');
}
asyncFn();
console.log('同步代码 2');
queueMicrotask(() => {
console.log('queueMicrotask 微任务');
});
// 前端特有微任务
const observer = new MutationObserver(() => {
console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1');
执行脉络分析:
-
同步代码一路推平
先打印
同步代码 1。遇到setTimeout放入宏任务队列。遇到new Promise(注意:构造函数内部是同步执行的),依次打印Promise 构造函数和Promise 构造函数内 resolve 后,并将它的.then推入微任务队列。遇到asyncFn执行,打印async 函数同步部分,并将await后的代码推入微任务队列。接着打印同步代码 2。最后触发MutationObserver进入微任务队列。 -
第一波微任务清空
依次打印
Promise.then 1、await 后微任务、queueMicrotask 微任务、MutationObserver 微任务。需要注意的是,在此执行期间,Promise.then 1内部产生了一个新的setTimeout,它会被放入宏任务队列等待。 -
开启下一轮宏任务
拿出首个宏任务
setTimeout 1执行并打印,同时将其内部的 Promise 推入微任务队列。当前宏任务结束后,立刻清空刚刚产生的微任务,打印setTimeout 1 内部微任务。 -
最后的宏任务
执行剩余的宏任务,打印
Promise.then 1 内部 setTimeout。
三、 Node.js 的 Event Loop:更复杂的阶段调度
如果你觉得浏览器的 Event Loop 已经懂了,那来到 Node.js 的世界,你需要暂时放下前面的“偏见”。
相比于浏览器主要处理 DOM 和交互,Node.js 运行在服务器端,需要处理大量的文件 I/O、网络请求、数据库连接。因此,Node.js 的事件循环基于 libuv 库,被划分为多个阶段(Phases) 。
1. Node.js 事件循环的 6 大阶段
在每次循环中,Node.js 会按顺序经过以下核心阶段(我们主要关注标粗的三个):
- Timers(定时器阶段) :执行
setTimeout和setInterval的回调。 - Pending Callbacks:执行系统级别操作的回调(如 TCP 错误)。
- Idle, Prepare:内部使用。
- Poll(轮询阶段) :检索新的 I/O 事件,执行与 I/O 相关的回调(比如读取文件、网络请求返回)。这是 Node.js 最重要的阶段。
- Check(检查阶段) :专门执行
setImmediate的回调。 - Close Callbacks:执行关闭资源的回调。
2. Node 中的“特权”微任务
在 Node.js 中,微任务不仅有 Promise,还有一个拥有绝对特权的 VIP:process.nextTick。
- 触发时机:同步代码执行完后、或者每个阶段完成后、甚至在 Node 11+ 版本中每个回调执行完后,都会立刻去检查并清空微任务队列。
- 优先级:
process.nextTick的优先级永远高于Promise。
3. 核心实战:I/O 内部的执行顺序反转
这是面试中最容易挂掉的一道题,也是理解 Node.js 调度的分水岭:
const fs = require('fs')
console.log('start')
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
fs.readFile(__filename, () => {
console.log('readFile')
setTimeout(() => {
console.log('timeout in I/O')
}, 0)
setImmediate(() => {
console.log('immediate in I/O')
})
})
Promise.resolve().then(() => { console.log('promise') })
process.nextTick(() => { console.log('nextTick') })
console.log('end')
深度拆解:为什么在 I/O 里 setImmediate 永远比 setTimeout 先执行?
-
同步先行:打印
start和end。注册各个异步任务。 -
清空首次微任务:先看 VIP,打印
nextTick,再看 Promise,打印promise。 -
进入事件循环:
- Timers 阶段:
setTimeout(..., 0)到期,打印timeout。 - Poll 阶段:此时文件可能还没读完,跳过。
- Check 阶段:执行外层的
setImmediate,打印immediate。
- Timers 阶段:
-
I/O 改变战局:
- 当
fs.readFile完成,它的回调会在 Poll 阶段执行!打印readFile。 - 在回调内部,又注册了一个
setTimeout和一个setImmediate。 - 划重点:我们现在处于 Poll 阶段!Event Loop 顺时针往下转,下一个阶段是谁?是 Check 阶段!
- 所以,刚刚注册的
setImmediate会在接下来的 Check 阶段被立刻执行(打印immediate in I/O)。 - 而那个
setTimeout怎么办?它只能苦苦等待这一轮循环跑完,在下一轮的 Timers 阶段才能被执行(打印timeout in I/O)。
- 当
四、 核心对比:浏览器 vs Node.js
| 特性 | 浏览器 (HTML5标准) | Node.js (基于 libuv) |
|---|---|---|
| 底层驱动 | 浏览器内核 (V8 + GUI等) | V8引擎 + libuv |
| 任务模型 | 宏任务 -> 微任务 -> 渲染 | 划分为 6 个阶段,按阶段推进 |
| 微任务清空时机 | 每个宏任务结束后 | 早期为每个阶段结束,Node 11+ 后与浏览器一致,每个回调结束后 |
| 特有 API | MutationObserver, requestAnimationFrame | process.nextTick, setImmediate |
| 微任务优先级 | 正常队列 (Promise, queueMicrotask) | process.nextTick 绝对优先于 Promise |
六、 总结
1. 单线程高并发的秘密
相比于 Java、Go 传统的多线程阻塞模型,Node.js 借助事件循环实现了异步非阻塞 I/O。这意味着,当 Node.js 处理网络请求、查询 MySQL/PostgreSQL 数据库、或者读写文件时,线程不会卡在那里等待。它会把任务扔给底层,立刻切回去处理下一个用户的 HTTP 请求。
这种特性,使得服务器开销极低,少量线程就能扛住成千上万的并发连接。
无论你是沉浸在 Vue/React 的前端开发者,还是在使用 Nestjs 探索后端的全栈工程师,深刻理解 Event Loop 都是一次思维的跨越:
- 在前端,你要关注宏任务和微任务的交替,警惕长任务阻塞渲染导致的页面掉帧。
- 在 Node.js,你要关注各个阶段(Timers、Poll、Check)的流转,善用异步流和缓冲,发挥其高并发 I/O 的优势。