Skip to content

事件循环 (Event Loop) 深度解析:浏览器与 Node.js


核心概念:进程与线程

在深入事件循环之前,必须先理解 进程 (Process)线程 (Thread) 的概念。常说 JavaScript 是单线程语言,指的是执行 JS 代码的核心线程只有一个,但这并不意味着浏览器或 Node.js 环境本身是单线程的。

  • 进程 (Process):是操作系统进行 资源分配 的最小单位。可以看作是一个正在运行的应用程序的实例,比如你打开的浏览器、代码编辑器或音乐播放器。每个进程都拥有独立的内存空间。
  • 线程 (Thread):是 CPU 进行 任务调度 的最小单位。它隶属于进程,一个进程可以包含一个或多个线程。

形象比喻

  • 进程 就像一个 工厂,拥有独立的土地、电力、原材料等资源。
  • 线程 就像工厂里的 工人,工人们共享工厂内的所有资源,分工协作完成生产任务。

多个工厂(进程)之间是相互独立的。一个工厂(进程)内可以有多名工人(线程)。

多进程与多线程

  • 多进程 (Multi-process):允许计算机同时运行两个或以上的进程。例如,你可以一边用 VS Code 写代码,一边用浏览器查资料,它们分属不同进程,互不影响。

    • Chrome 浏览器 就是一个典型的多进程架构。每个标签页(Tab)通常都对应一个独立的进程。这样做的好处是,当一个标签页崩溃时,不会影响到其他标签页。
  • 多线程 (Multi-thread):允许在一个进程(程序)中同时执行多个不同的任务。这使得程序可以更高效地处理并发操作。


浏览器中的事件循环

虽然执行 JavaScript 的是单线程,但浏览器内核本身是 多线程 的。打开一个网页(即一个进程)后,内部会有多个常驻线程协同工作:

  1. GUI 渲染线程
    • 负责解析 HTML、CSS,构建 DOM 树和 Render 树,并最终将页面绘制到屏幕上。
  2. JS 引擎线程
    • 负责解析和执行 JavaScript 代码。
    • 注意:GUI 渲染线程与 JS 引擎线程是 互斥 的。当 JS 引擎执行时,GUI 线程会被挂起。这就是为什么耗时过长的 JS 运算会导致页面卡顿或“冻结”。为了避免阻塞页面初次渲染,通常建议将 <script> 标签放在 <body> 的末尾。
  3. 定时器触发线程
    • 负责为 setTimeoutsetInterval 计时。当计时结束,它会将对应的回调函数放入任务队列中,等待 JS 引擎线程执行。JS 引擎本身不负责计时。
  4. 事件触发线程
    • 负责监听和处理用户的交互事件(如 click, keyup)以及其他异步事件。当事件被触发时,它会将事件的回调函数放入任务队列。
  5. 异步 HTTP 请求线程
    • 负责处理网络请求(如 Ajax)。当请求完成后,如果设置了回调函数,它会把这个回调函数放入任务队列。

宏任务 (Macro-task) 与 微任务 (Micro-task)

在浏览器中,异步任务被分为两种类型,它们分别进入不同的队列。

  • 宏任务 (Macro-task)
    • script (整体代码)
    • setTimeout
    • setInterval
    • requestAnimationFrame
    • I/O, UI rendering
  • 微任务 (Micro-task)
    • Promise.prototype.then/catch/finally
    • MutationObserver

执行流程

浏览器的事件循环机制可以用以下流程来概括:

核心循环规则:执行 一个 宏任务 → 执行 所有 微任务 → (可选)UI 渲染 → 执行下一个宏任务...

  1. 开始:首先,将整个 <script> 代码块作为一个宏任务放入宏任务队列并立即执行。
  2. 同步与异步:在执行过程中,同步代码直接运行。遇到异步任务时,将其回调函数交给对应的线程(如定时器线程、HTTP 请求线程)处理。
  3. 入队:当这些异步操作完成后(例如定时器到时、请求成功),其回调函数被放入对应的任务队列:宏任务放入宏任务队列,微任务放入微任务队列。
  4. 宏任务结束:当前宏任务(最开始是 script 块)执行完毕。
  5. 清空微任务:立即检查微任务队列。如果队列不为空,则 一次性执行所有 的微任务,直到微任务队列被清空。如果在执行微任务的过程中又产生了新的微任务,新任务会继续被添加到队列尾部,并在此轮一并执行。
  6. 下一轮循环:微任务队列清空后,从宏任务队列中取出一个任务开始执行,重复步骤 4 和 5。

代码示例分析

示例 1:基础

javascript
console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

分析过程:

  1. 宏任务队列: [script]
  2. 微任务队列: []
  3. 执行 script 宏任务:
    • 打印 script start
    • 遇到 setTimeout,将其回调推入 宏任务 队列。宏任务队列变为: [script, setTimeout]
    • 遇到 Promise.resolve().then(),第一个 .then 的回调被推入 微任务 队列。微任务队列: [promise1]
    • 打印 script end
  4. script 宏任务执行完毕
  5. 检查并清空微任务队列:
    • 执行 promise1 回调,打印 promise1
    • 第一个 .then 返回一个新的 Promise,因此第二个 .then 的回调被推入微任务队列。微任务队列: [promise2]
    • 继续执行,执行 promise2 回调,打印 promise2
    • 微任务队列现在为空。
  6. UI 渲染 (如果有)
  7. 执行下一个宏任务:
    • 从宏任务队列中取出 setTimeout 的回调并执行。
    • 打印 setTimeout

最终输出:

script start
script end
promise1
promise2
setTimeout

Node.js 中的事件循环

Node.js 的事件循环基于 libuv 库,其机制比浏览器更为复杂。它不只有两个队列,而是将事件循环分为了 六个阶段 (Phases)

事件循环的六个阶段

Node.js 的事件循环会按顺序反复执行以下六个阶段:

  1. timers (计时器): 执行 setTimeout()setInterval() 的回调。
  2. pending callbacks (待定回调): 执行上一轮循环中少数未执行的 I/O 回调。
  3. idle, prepare: 仅供内部使用。
  4. poll (轮询): 核心阶段。执行 I/O 相关回调(如网络请求、文件读写),并在此阶段适当阻塞以等待新的 I/O 事件。
  5. check (检查): 执行 setImmediate() 的回调。
  6. close callbacks (关闭回调): 执行如 socket.on('close', ...) 的回调。

我们主要关心 timerspollcheck 这三个阶段。

宏任务与微任务在 Node.js 中

Node.js 同样有宏任务和微任务的概念。

  • 宏任务: setTimeout, setInterval, setImmediate, I/O 操作。
  • 微任务: Promise.then/catch/finally, process.nextTick

执行流程

Node.js 的事件循环与浏览器的主要区别在于微任务的执行时机。

核心规则:Node.js 在 每个阶段 执行完毕后,都会去清空微任务队列,而不是等一个宏任务执行完。

  1. 执行顺序: 事件循环从一个阶段移动到下一个阶段。
  2. 微任务清空: 在进入下一个阶段 之前,会立即检查并清空当前的微任务队列。
  3. process.nextTick: 这是一个特殊的微任务,它有自己的独立队列,并且其优先级 高于 PromisenextTick 队列会在 Promise 队列之前被清空。

阶段详解与代码示例

setTimeout vs setImmediate

  • setTimeout(fn, 0)timers 阶段执行。
  • setImmediate(fn)check 阶段执行。

场景 1:在主模块中直接调用

javascript
setTimeout(() => {
  console.log('timeout');
}, 0);

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

这里的执行顺序是 不确定 的。因为 Node.js 启动和准备事件循环需要少量时间。如果准备时间超过 1ms,事件循环开始时 setTimeout 的计时器可能已经到期,进入 timers 阶段,timeout 会先输出。如果准备时间很短,事件循环直接进入 poll 阶段,发现队列为空,然后进入 check 阶段,immediate 会先输出。

场景 2:在 I/O 回调中调用

javascript
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  
  setImmediate(() => {
    console.log('immediate');
  });
});

这里的执行顺序是 确定 的:永远是 immediate 先于 timeout

原因:

  1. fs.readFile 的回调函数在 poll 阶段执行。
  2. 当这个回调执行完毕后,事件循环的下一个阶段是 check 阶段。因此,setImmediate 的回调会立即被执行。
  3. check 阶段结束后,事件循环才会进入下一轮的 timers 阶段,此时 setTimeout 的回调才会被执行。

process.nextTick vs Promise

两者都是微任务,但 process.nextTick 的优先级更高。

javascript
setTimeout(() => {
  console.log('timer1');
  
  Promise.resolve().then(function() {
    console.log('promise1');
  });
  
  process.nextTick(() => {
    console.log('nextTick1');
  });
}, 0);

setTimeout(() => {
  console.log('timer2');
}, 0);

分析过程:

  1. 两个 setTimeout 的回调被加入 timers 队列。
  2. 事件循环进入 timers 阶段,执行第一个 setTimeout 的回调。
    • 打印 timer1
    • Promise.then 被加入微任务的 Promise 队列
    • process.nextTick 被加入微任务的 nextTick 队列
  3. 第一个 setTimeout 回调(宏任务)执行完毕。在执行下一个宏任务(timer2)之前,清空所有微任务
    • 先清空优先级更高的 nextTick 队列,打印 nextTick1
    • 再清空 Promise 队列,打印 promise1
  4. 微任务队列已空。继续在 timers 阶段执行下一个任务。
    • 执行第二个 setTimeout 的回调,打印 timer2

最终输出:

timer1
nextTick1
promise1
timer2

总结:浏览器 vs Node.js 事件循环差异

特性浏览器事件循环Node.js 事件循环
底层实现由 HTML5 规范定义,各浏览器厂商实现。基于 libuv 库,分为 6 个明确的阶段。
任务队列主要是 宏任务队列微任务队列任务队列分布在 6 个阶段中,外加独立的 nextTick 队列和 Promise 队列(微任务)。
微任务执行时机单个宏任务 执行完毕后,立即 清空所有 微任务。在事件循环的 每个阶段 完成后,都会去清空微任务队列(nextTick 优先于 Promise)。
代表性 APIsetTimeout, PromisesetTimeout, setImmediate, process.nextTick, Promise, I/O 操作 (fs, net)