事件循环 (Event Loop) 深度解析:浏览器与 Node.js
核心概念:进程与线程
在深入事件循环之前,必须先理解 进程 (Process) 和 线程 (Thread) 的概念。常说 JavaScript 是单线程语言,指的是执行 JS 代码的核心线程只有一个,但这并不意味着浏览器或 Node.js 环境本身是单线程的。
- 进程 (Process):是操作系统进行 资源分配 的最小单位。可以看作是一个正在运行的应用程序的实例,比如你打开的浏览器、代码编辑器或音乐播放器。每个进程都拥有独立的内存空间。
- 线程 (Thread):是 CPU 进行 任务调度 的最小单位。它隶属于进程,一个进程可以包含一个或多个线程。
形象比喻:
- 进程 就像一个 工厂,拥有独立的土地、电力、原材料等资源。
- 线程 就像工厂里的 工人,工人们共享工厂内的所有资源,分工协作完成生产任务。
多个工厂(进程)之间是相互独立的。一个工厂(进程)内可以有多名工人(线程)。
多进程与多线程
多进程 (Multi-process):允许计算机同时运行两个或以上的进程。例如,你可以一边用 VS Code 写代码,一边用浏览器查资料,它们分属不同进程,互不影响。
- Chrome 浏览器 就是一个典型的多进程架构。每个标签页(Tab)通常都对应一个独立的进程。这样做的好处是,当一个标签页崩溃时,不会影响到其他标签页。
多线程 (Multi-thread):允许在一个进程(程序)中同时执行多个不同的任务。这使得程序可以更高效地处理并发操作。
浏览器中的事件循环
虽然执行 JavaScript 的是单线程,但浏览器内核本身是 多线程 的。打开一个网页(即一个进程)后,内部会有多个常驻线程协同工作:
- GUI 渲染线程
- 负责解析 HTML、CSS,构建 DOM 树和 Render 树,并最终将页面绘制到屏幕上。
- JS 引擎线程
- 负责解析和执行 JavaScript 代码。
- 注意:GUI 渲染线程与 JS 引擎线程是 互斥 的。当 JS 引擎执行时,GUI 线程会被挂起。这就是为什么耗时过长的 JS 运算会导致页面卡顿或“冻结”。为了避免阻塞页面初次渲染,通常建议将
<script>标签放在<body>的末尾。
- 定时器触发线程
- 负责为
setTimeout和setInterval计时。当计时结束,它会将对应的回调函数放入任务队列中,等待 JS 引擎线程执行。JS 引擎本身不负责计时。
- 负责为
- 事件触发线程
- 负责监听和处理用户的交互事件(如
click,keyup)以及其他异步事件。当事件被触发时,它会将事件的回调函数放入任务队列。
- 负责监听和处理用户的交互事件(如
- 异步 HTTP 请求线程
- 负责处理网络请求(如 Ajax)。当请求完成后,如果设置了回调函数,它会把这个回调函数放入任务队列。
宏任务 (Macro-task) 与 微任务 (Micro-task)
在浏览器中,异步任务被分为两种类型,它们分别进入不同的队列。
- 宏任务 (Macro-task):
script(整体代码)setTimeoutsetIntervalrequestAnimationFrame- I/O, UI rendering
- 微任务 (Micro-task):
Promise.prototype.then/catch/finallyMutationObserver
执行流程
浏览器的事件循环机制可以用以下流程来概括:
核心循环规则:执行 一个 宏任务 → 执行 所有 微任务 → (可选)UI 渲染 → 执行下一个宏任务...
- 开始:首先,将整个
<script>代码块作为一个宏任务放入宏任务队列并立即执行。 - 同步与异步:在执行过程中,同步代码直接运行。遇到异步任务时,将其回调函数交给对应的线程(如定时器线程、HTTP 请求线程)处理。
- 入队:当这些异步操作完成后(例如定时器到时、请求成功),其回调函数被放入对应的任务队列:宏任务放入宏任务队列,微任务放入微任务队列。
- 宏任务结束:当前宏任务(最开始是
script块)执行完毕。 - 清空微任务:立即检查微任务队列。如果队列不为空,则 一次性执行所有 的微任务,直到微任务队列被清空。如果在执行微任务的过程中又产生了新的微任务,新任务会继续被添加到队列尾部,并在此轮一并执行。
- 下一轮循环:微任务队列清空后,从宏任务队列中取出一个任务开始执行,重复步骤 4 和 5。
代码示例分析
示例 1:基础
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');分析过程:
- 宏任务队列: [
script] - 微任务队列: []
- 执行
script宏任务:- 打印
script start。 - 遇到
setTimeout,将其回调推入 宏任务 队列。宏任务队列变为: [script,setTimeout] - 遇到
Promise.resolve().then(),第一个.then的回调被推入 微任务 队列。微任务队列: [promise1] - 打印
script end。
- 打印
script宏任务执行完毕。- 检查并清空微任务队列:
- 执行
promise1回调,打印promise1。 - 第一个
.then返回一个新的 Promise,因此第二个.then的回调被推入微任务队列。微任务队列: [promise2] - 继续执行,执行
promise2回调,打印promise2。 - 微任务队列现在为空。
- 执行
- UI 渲染 (如果有)。
- 执行下一个宏任务:
- 从宏任务队列中取出
setTimeout的回调并执行。 - 打印
setTimeout。
- 从宏任务队列中取出
最终输出:
script start
script end
promise1
promise2
setTimeoutNode.js 中的事件循环
Node.js 的事件循环基于 libuv 库,其机制比浏览器更为复杂。它不只有两个队列,而是将事件循环分为了 六个阶段 (Phases)。
事件循环的六个阶段
Node.js 的事件循环会按顺序反复执行以下六个阶段:
- timers (计时器): 执行
setTimeout()和setInterval()的回调。 - pending callbacks (待定回调): 执行上一轮循环中少数未执行的 I/O 回调。
- idle, prepare: 仅供内部使用。
- poll (轮询): 核心阶段。执行 I/O 相关回调(如网络请求、文件读写),并在此阶段适当阻塞以等待新的 I/O 事件。
- check (检查): 执行
setImmediate()的回调。 - close callbacks (关闭回调): 执行如
socket.on('close', ...)的回调。
我们主要关心
timers、poll和check这三个阶段。
宏任务与微任务在 Node.js 中
Node.js 同样有宏任务和微任务的概念。
- 宏任务:
setTimeout,setInterval,setImmediate, I/O 操作。 - 微任务:
Promise.then/catch/finally,process.nextTick。
执行流程
Node.js 的事件循环与浏览器的主要区别在于微任务的执行时机。
核心规则:Node.js 在 每个阶段 执行完毕后,都会去清空微任务队列,而不是等一个宏任务执行完。
- 执行顺序: 事件循环从一个阶段移动到下一个阶段。
- 微任务清空: 在进入下一个阶段 之前,会立即检查并清空当前的微任务队列。
process.nextTick: 这是一个特殊的微任务,它有自己的独立队列,并且其优先级 高于Promise。nextTick队列会在Promise队列之前被清空。
阶段详解与代码示例
setTimeout vs setImmediate
setTimeout(fn, 0)在 timers 阶段执行。setImmediate(fn)在 check 阶段执行。
场景 1:在主模块中直接调用
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});这里的执行顺序是 不确定 的。因为 Node.js 启动和准备事件循环需要少量时间。如果准备时间超过 1ms,事件循环开始时 setTimeout 的计时器可能已经到期,进入 timers 阶段,timeout 会先输出。如果准备时间很短,事件循环直接进入 poll 阶段,发现队列为空,然后进入 check 阶段,immediate 会先输出。
场景 2:在 I/O 回调中调用
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});这里的执行顺序是 确定 的:永远是 immediate 先于 timeout。
原因:
fs.readFile的回调函数在 poll 阶段执行。- 当这个回调执行完毕后,事件循环的下一个阶段是 check 阶段。因此,
setImmediate的回调会立即被执行。 check阶段结束后,事件循环才会进入下一轮的 timers 阶段,此时setTimeout的回调才会被执行。
process.nextTick vs Promise
两者都是微任务,但 process.nextTick 的优先级更高。
setTimeout(() => {
console.log('timer1');
Promise.resolve().then(function() {
console.log('promise1');
});
process.nextTick(() => {
console.log('nextTick1');
});
}, 0);
setTimeout(() => {
console.log('timer2');
}, 0);分析过程:
- 两个
setTimeout的回调被加入 timers 队列。 - 事件循环进入 timers 阶段,执行第一个
setTimeout的回调。- 打印
timer1。 Promise.then被加入微任务的 Promise 队列。process.nextTick被加入微任务的 nextTick 队列。
- 打印
- 第一个
setTimeout回调(宏任务)执行完毕。在执行下一个宏任务(timer2)之前,清空所有微任务。- 先清空优先级更高的
nextTick队列,打印nextTick1。 - 再清空 Promise 队列,打印
promise1。
- 先清空优先级更高的
- 微任务队列已空。继续在 timers 阶段执行下一个任务。
- 执行第二个
setTimeout的回调,打印timer2。
- 执行第二个
最终输出:
timer1
nextTick1
promise1
timer2总结:浏览器 vs Node.js 事件循环差异
| 特性 | 浏览器事件循环 | Node.js 事件循环 |
|---|---|---|
| 底层实现 | 由 HTML5 规范定义,各浏览器厂商实现。 | 基于 libuv 库,分为 6 个明确的阶段。 |
| 任务队列 | 主要是 宏任务队列 和 微任务队列。 | 任务队列分布在 6 个阶段中,外加独立的 nextTick 队列和 Promise 队列(微任务)。 |
| 微任务执行时机 | 在 单个宏任务 执行完毕后,立即 清空所有 微任务。 | 在事件循环的 每个阶段 完成后,都会去清空微任务队列(nextTick 优先于 Promise)。 |
| 代表性 API | setTimeout, Promise | setTimeout, setImmediate, process.nextTick, Promise, I/O 操作 (fs, net) |