从浏览器到 Node.js,这一次彻底搞懂 Event Loop 与异步模型

0 阅读7分钟

引言

很多前端同学在向全栈(BFF层)或者 Node.js 进阶时,都会遇到一个绕不开的坎——Event Loop(事件循环)

面试时,面对一段穿插着 setTimeoutPromiseasync/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. 任务分类

  • 宏任务队列setTimeoutsetInterval、事件绑定回调、Ajax 回调等。
  • 微任务队列Promise.then/catch/finallyasync/await 的后续代码、queueMicrotask、以及前端特有的 DOM 监听类微任务 MutationObserver

2. 执行机制(核心运转规律)

浏览器的 Event Loop 遵循以下严格的顺序:

  1. 执行并清空当前宏任务(一开始是整个 script 标签内的同步代码)。
  2. 清空整个微任务队列(如果执行微任务时又产生了新的微任务,会继续在当前阶段清空)。
  3. 检查是否需要进行页面渲染(GUI 渲染线程介入,重排重绘)。
  4. 开始下一轮 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. 同步代码一路推平

    先打印 同步代码 1。遇到 setTimeout 放入宏任务队列。遇到 new Promise(注意:构造函数内部是同步执行的),依次打印 Promise 构造函数Promise 构造函数内 resolve 后,并将它的 .then 推入微任务队列。遇到 asyncFn 执行,打印 async 函数同步部分,并将 await 后的代码推入微任务队列。接着打印 同步代码 2。最后触发 MutationObserver 进入微任务队列。

  2. 第一波微任务清空

    依次打印 Promise.then 1await 后微任务queueMicrotask 微任务MutationObserver 微任务。需要注意的是,在此执行期间,Promise.then 1 内部产生了一个新的 setTimeout,它会被放入宏任务队列等待。

  3. 开启下一轮宏任务

    拿出首个宏任务 setTimeout 1 执行并打印,同时将其内部的 Promise 推入微任务队列。当前宏任务结束后,立刻清空刚刚产生的微任务,打印 setTimeout 1 内部微任务

  4. 最后的宏任务

    执行剩余的宏任务,打印 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 会按顺序经过以下核心阶段(我们主要关注标粗的三个):

  1. Timers(定时器阶段) :执行 setTimeoutsetInterval 的回调。
  2. Pending Callbacks:执行系统级别操作的回调(如 TCP 错误)。
  3. Idle, Prepare:内部使用。
  4. Poll(轮询阶段) :检索新的 I/O 事件,执行与 I/O 相关的回调(比如读取文件、网络请求返回)。这是 Node.js 最重要的阶段。
  5. Check(检查阶段) :专门执行 setImmediate 的回调。
  6. 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 先执行?

  1. 同步先行:打印 startend。注册各个异步任务。

  2. 清空首次微任务:先看 VIP,打印 nextTick,再看 Promise,打印 promise

  3. 进入事件循环

    • Timers 阶段setTimeout(..., 0) 到期,打印 timeout
    • Poll 阶段:此时文件可能还没读完,跳过。
    • Check 阶段:执行外层的 setImmediate,打印 immediate
  4. 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+ 后与浏览器一致,每个回调结束后
特有 APIMutationObserver, requestAnimationFrameprocess.nextTick, setImmediate
微任务优先级正常队列 (Promise, queueMicrotask)process.nextTick 绝对优先于 Promise

六、 总结

1. 单线程高并发的秘密

相比于 Java、Go 传统的多线程阻塞模型,Node.js 借助事件循环实现了异步非阻塞 I/O。这意味着,当 Node.js 处理网络请求、查询 MySQL/PostgreSQL 数据库、或者读写文件时,线程不会卡在那里等待。它会把任务扔给底层,立刻切回去处理下一个用户的 HTTP 请求。

这种特性,使得服务器开销极低,少量线程就能扛住成千上万的并发连接。

无论你是沉浸在 Vue/React 的前端开发者,还是在使用 Nestjs 探索后端的全栈工程师,深刻理解 Event Loop 都是一次思维的跨越:

  1. 在前端,你要关注宏任务和微任务的交替,警惕长任务阻塞渲染导致的页面掉帧。
  2. 在 Node.js,你要关注各个阶段(Timers、Poll、Check)的流转,善用异步流和缓冲,发挥其高并发 I/O 的优势。