为什么 JavaScript 需要事件循环
JavaScript 从诞生起就是单线程语言。这个设计选择不是偶然,而是有其深刻的历史背景和工程考量。
在浏览器环境中,JavaScript 的主要用途是操作 DOM。如果 JavaScript 支持多线程,当两个线程同时修改同一个 DOM 元素时,浏览器的渲染结果将变得不可预测。浏览器开发者们选择了最简单的解决方案:让 JavaScript 单线程执行,任何时刻只有一个任务在运行。
但单线程意味着所有 I/O 操作都必须异步完成。如果 JavaScript 同步等待网络请求完成,整个浏览器界面就会冻结,用户体验将无法接受。所以 JavaScript 必须有一套机制来处理异步操作——这就是事件循环的核心驱动力。
事件循环的本质是:当主线程(Call Stack)执行完当前所有同步代码后,它会去检查任务队列(Task Queue)中是否有等待执行的任务;如果有,就取出第一个任务执行;如果没有,就继续等待。这个循环永不停止,直到程序结束。
一、调用栈(Call Stack)
1.1 什么是调用栈
调用栈是 JavaScript 引擎维护的一个数据结构,用于跟踪函数调用。当一个函数被调用时,一个**栈帧(stack frame)**被推入栈顶;当函数返回时,这个栈帧被弹出。
function greet(name) {
return `Hello, ${name}`;
}
function sayHello() {
const message = greet('World');
console.log(message);
}
sayHello();
上述代码执行时,调用栈的变化过程是:
// 1. sayHello() 被调用,栈帧 push
[sayHello]
// 2. greet() 被调用,栈帧 push
[sayHello, greet]
// 3. greet() 返回,栈帧 pop
[sayHello]
// 4. sayHello() 返回,栈帧 pop
[]
1.2 栈溢出(Stack Overflow)
调用栈的容量是有限的。如果递归调用没有正确的终止条件,栈会不断增长,最终耗尽内存,这就是栈溢出。
// 这段代码会导致栈溢出
function recursive() {
recursive();
}
recursive(); // RangeError: Maximum call stack size exceeded
浏览器和 Node.js 都会对调用栈深度做出限制,通常在几千到几万层之间。
1.3 同步代码的执行
调用栈的一个关键特性是:它会执行完当前栈中的所有同步代码后,才会处理其他任务。这意味着如果有一段耗时很长的同步计算,它会阻塞整个主线程。
console.log('1');
function blockingCode() {
// 假设这是一个耗时 10 秒的计算
const start = Date.now();
while (Date.now() - start < 1000) {
// 阻塞 1 秒
}
}
setTimeout(() => console.log('2'), 0);
blockingCode(); // 这会阻塞,导致 setTimeout 的回调无法在预期时间执行
console.log('3');
// 输出顺序:1, [阻塞 1 秒], 3, 2
在这个例子中,即使 setTimeout 的延迟是 0 毫秒,它的回调也必须等到调用栈清空后才能执行。
二、任务队列(Task Queue)与事件循环
2.1 基本概念
任务队列(也称为宏任务队列或 macrotask queue)是 JavaScript 引擎用于管理异步回调的队列结构。当异步操作完成时(如定时器到期、网络请求响应到达),对应的回调函数会被放入任务队列等待执行。
事件循环的工作流程可以描述为:
while (true) {
// 1. 执行完当前调用栈中的所有同步代码
while (callStack.isEmpty()) {
// 2. 从微任务队列取出所有微任务执行
while (microtaskQueue.isNotEmpty()) {
executeMicrotask(microtaskQueue.dequeue());
}
// 3. 从任务队列取出一个任务执行
if (taskQueue.isNotEmpty()) {
executeTask(taskQueue.dequeue());
// 4. 循环回到步骤 2(检查微任务)
}
}
}
这个流程图展示了事件循环的核心逻辑:
graph TD
A[事件循环开始] --> B[执行调用栈中的同步代码]
B --> C{调用栈为空?}
C -->|是| D[处理所有微任务]
D --> E{任务队列有任务?}
E -->|是| F[取出并执行一个任务]
F --> B
E -->|否| G[等待任务到来]
G --> A
C -->|否| B
2.2 任务队列的执行时机
事件循环的每次迭代中,最多只会执行一个宏任务。这是非常重要的特性,意味着如果一个宏任务执行时间过长,会延迟后续宏任务的执行。
setTimeout(() => console.log('timeout 1'), 0);
setTimeout(() => console.log('timeout 2'), 0);
Promise.resolve().then(() => console.log('microtask'));
console.log('sync');
// 输出顺序:
// sync
// microtask
// timeout 1
// timeout 2
解释:同步代码先执行,然后检查微任务队列(发现 Promise.then),执行后检查任务队列(发现 setTimeout)。
三、微任务(Microtask)与宏任务(Macrotask)
3.1 核心区别
这是 JavaScript 异步编程中最容易混淆的概念之一。
| 特征 | 微任务(Microtask) | 宏任务(Macrotask) |
|---|---|---|
| 代表 | Promise.then/catch/finally, async/await, queueMicrotask | setTimeout, setInterval, I/O, UI 渲染 |
| 执行时机 | 当前任务执行完毕后、下一个宏任务之前 | 事件循环的每一轮迭代 |
| 执行数量 | 所有微任务都会在本轮执行完 | 一个 |
| 创建的新微任务 | 会在同一微任务阶段执行 | 会进入下一个宏任务阶段 |
3.2 为什么需要微任务
微任务的存在是为了解决异步操作之间的依赖关系。
考虑这个场景:Promise A 完成后,需要立即执行 Promise B 的处理逻辑。如果 Promise A 的回调在任务队列中,Promise B 的回调在同一个队列里,那么 B 必须等 A 执行完后才能被取出。但如果有更高优先级的任务插入,A 和 B 的相对顺序可能被打破。
微任务队列提供了一种确定性:在当前宏任务结束后,所有已完成的微任务都会被处理,不管有多少个。这确保了 Promise 链式调用的顺序性。
3.3 实际执行顺序示例
console.log('1. sync start');
setTimeout(() => console.log('2. setTimeout'), 0);
Promise.resolve()
.then(() => {
console.log('3. Promise.then 1');
Promise.resolve()
.then(() => console.log('4. nested Promise.then'));
})
.then(() => console.log('5. Promise.then 2'));
console.log('6. sync end');
// 完整输出顺序:
// 1. sync start
// 6. sync end
// 3. Promise.then 1
// 4. nested Promise.then
// 5. Promise.then 2
// 2. setTimeout
执行过程详解:
console.log('1')同步执行setTimeout注册回调到任务队列Promise.resolve().then()注册第一个微任务console.log('6')同步执行- 调用栈空,检查微任务队列
- 执行第一个微任务,输出 "3"
- 第一个微任务中注册了新的 Promise.then,进入微任务队列
- 执行新的微任务,输出 "4"
- 第一个微任务的
.then()链后面还有一个.then(),进入微任务队列 - 执行第二个微任务,输出 "5"
- 微任务队列空,检查任务队列
- 执行 setTimeout 回调,输出 "2"
3.4 async/await 与微任务
async 函数返回 Promise,await 后面的代码会作为微任务执行。
async function asyncFunc() {
console.log('a1. in async');
await Promise.resolve();
console.log('a2. after await');
}
console.log('s1. before async');
asyncFunc();
console.log('s2. after async call');
// 输出:
// s1. before async
// a1. in async
// s2. after async call
// a2. after await
await Promise.resolve() 暂停了 async 函数的执行,让出调用栈。当 Promise resolve 后,await 后面的代码作为微任务继续执行。
3.5 queueMicrotask API
除了 Promise,JavaScript 还提供了 queueMicrotask() 函数,允许你将任意函数排队为微任务:
queueMicrotask(() => {
console.log('这是微任务');
});
这在你需要确保某段代码在当前同步代码之后、但在下一个宏任务之前执行时很有用。
四、浏览器中的事件循环
4.1 浏览器事件循环的特点
浏览器环境中的事件循环需要协调多种任务来源:
- 渲染任务:浏览器需要在合适的时机执行样式计算和布局更新
- 用户交互任务:点击、输入等事件
- 定时器任务:setTimeout、setInterval
- 网络任务:XMLHttpRequest、fetch
- 微任务:Promise 回调
sequenceDiagram
participant JS as JavaScript 引擎
participant Micro as 微任务队列
participant Macro as 宏任务队列
participant Render as 渲染引擎
JS->>JS: 执行同步代码
loop 事件循环
JS->>Micro: 执行所有微任务
JS->>Macro: 执行一个宏任务
Note over JS,Render: 检查是否需要渲染
Render-->>JS: 渲染时机检查
JS->>Macro: 取出下一个宏任务
end
4.2 渲染时机
浏览器并不会在每个宏任务后都渲染。渲染通常发生在宏任务执行完毕后、调用栈清空时的特定时机。这个时机由浏览器的调度策略决定,通常与显示器的刷新率同步(60fps 即每 16.67ms 渲染一次)。
// 示例:动画卡顿的原因
function badAnimation() {
for (let i = 0; i < 100000; i++) {
element.style.transform = `translateX(${i}px)`;
}
}
// 更好的做法:使用 requestAnimationFrame
function goodAnimation() {
requestAnimationFrame(() => {
element.style.transform = `translateX(${i}px)`;
});
}
4.3 用户代码与渲染任务的关系
当用户代码执行时间过长(超过 16ms),会挤压渲染可用时间,导致动画卡顿。这就是为什么长任务(Long Task)监控是性能优化的重要指标。
五、Node.js 的事件循环
5.1 libuv 的角色
Node.js 使用 libuv 作为底层事件循环库。libuv 是一个用 C 语言编写的多平台支持库,封装了不同操作系统上的 I/O 多路复用机制(如 Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP)。
Node.js 的事件循环比浏览器复杂,因为它需要处理:
- 文件系统操作
- DNS 查询
- 子进程管理
- 信号处理
- 定时器(timers)
- TCP/UDP socket
5.2 Node.js 事件循环阶段
Node.js 的事件循环分为多个阶段(phase),每个阶段都有自己独立的队列:
graph TD
A[事件循环开始] --> B[timers 阶段]
B --> C[pending callbacks 阶段]
C --> D[idle, prepare 阶段]
D --> E[poll 阶段]
E --> F{有 setImmediate?}
F -->|是| G[check 阶段]
F -->|否| H{有没有定时器?}
H -->|是| B
H -->|否| E
G --> I[close callbacks 阶段]
I --> B
各阶段详解:
-
timers 阶段:执行
setTimeout()和setInterval()的回调。所有在 timer 阈值到期后注册的回调都会在这里执行。 -
pending callbacks 阶段:执行某些系统操作的回调(如 TCP 错误)。通常不需要关心。
-
idle, prepare 阶段:仅供 libuv 内部使用。
-
poll 阶段:这是最重要的阶段。获取新的 I/O 事件;执行与 I/O 相关的回调(除了 close callbacks、setImmediate 和 timers 的回调);如果 poll 队列为空,会在这里阻塞等待。
-
check 阶段:执行
setImmediate()的回调。 -
close callbacks 阶段:执行一些关闭的回调,如
socket.on('close', ...).
5.3 setTimeout vs setImmediate
这是 Node.js 中最容易混淆的一对 API。
setTimeout(() => {
console.log('setTimeout');
}, 0);
setImmediate(() => {
console.log('setImmediate');
});
运行多次,你会发现输出顺序不确定——有时 setTimeout 先输出,有时 setImmediate 先输出。
原因:setTimeout 的回调在 timers 阶段执行,而 setImmediate 的回调在 check 阶段执行。如果 timers 阶段有多个 setTimeout,它们会按过期顺序执行。但如果主模块(main script)没有其他 I/O 操作,事件循环可能直接从 timers 跳到 check 阶段,或者中间经过其他阶段,导致顺序不确定。
但在 I/O 操作的回调中,setImmediate 总是先于 setTimeout:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
});
// 几乎总是输出:
// setImmediate
// setTimeout
因为 I/O 回调在 poll 阶段执行,而 poll 阶段结束后会进入 check 阶段(如果存在 setImmediate)。
5.4 process.nextTick
process.nextTick() 是一个 Node.js 特有的 API,它不是事件循环的一部分。
process.nextTick(() => {
console.log('nextTick');
});
setTimeout(() => {
console.log('setTimeout');
}, 0);
console.log('sync');
// 输出:
// sync
// nextTick
// setTimeout
process.nextTick 的回调会被添加到一个特殊的队列,这个队列会在当前操作完成后、事件循环继续之前立即清空。它优先级高于所有微任务和宏任务。
Promise.resolve()
.then(() => console.log('Promise.then'));
process.nextTick(() => console.log('nextTick'));
// 输出:
// nextTick
// Promise.then
5.5 微任务在 Node.js 中的差异
在 Node.js 中,Promise.then/catch/finally、async/await 都会创建微任务。但 process.nextTick 的优先级高于微任务。
process.nextTick(() => {
console.log('1. nextTick first');
process.nextTick(() => {
console.log('2. nested nextTick');
});
});
Promise.resolve()
.then(() => console.log('3. Promise microtask'));
// 输出:
// 1. nextTick first
// 2. nested nextTick
// 3. Promise microtask
所有 nextTick 回调会在任何微任务之前执行。
5.6 Node.js 与浏览器事件循环的异同
| 方面 | 浏览器 | Node.js |
|---|---|---|
| 底层库 | 无统一底层库,各浏览器自己实现 | libuv(统一实现) |
| 渲染任务 | 有(requestAnimationFrame 等) | 无(服务端无 UI) |
| 事件来源 | 用户交互、网络、定时器 | 文件 I/O、网络 I/O、定时器、子进程 |
| microtask 队列 | Promise 回调、async/await、queueMicrotask | Promise 回调、async/await、queueMicrotask |
| nextTick | 无 | 有,优先级高于微任务 |
| 阶段划分 | 简单(任务队列 + 微任务队列) | 复杂(多个 phase) |
六、实际应用与常见陷阱
6.1 错误处理导致的事件循环阻塞
// 错误示范:同步错误不会进入事件循环
try {
setTimeout(() => {
throw new Error('async error');
}, 0);
} catch (e) {
console.log('这不会捕获异步错误');
}
异步代码中的错误需要通过回调或 Promise 来处理。
6.2 微任务队列饥饿
如果不断向微任务队列添加任务,可能导致宏任务永远得不到执行:
let count = 0;
function recursiveMicrotask() {
Promise.resolve().then(recursiveMicrotask);
count++;
}
recursiveMicrotask();
setTimeout(() => {
console.log('永远不会执行');
}, 1000);
这实际上是一个无限循环,但它在微任务队列中执行,不会阻塞主线程,也不会触发浏览器的"脚本长时间运行"警告。
6.3 Node.js 中 nextTick 与微任务的执行顺序
const promise = Promise.resolve();
process.nextTick(() => {
console.log('1. nextTick');
});
promise.then(() => {
console.log('2. Promise.then');
});
process.nextTick(() => {
console.log('3. nextTick second');
});
// 输出顺序:
// 1. nextTick
// 3. nextTick second
// 2. Promise.then
所有 nextTick 回调会在微任务之前执行,而且是在 nextTick 队列清空后再执行微任务。
6.4 为什么 setImmediate 在 readFile 回调中有时更快
const fs = require('fs');
console.log('=== 第一次执行 ===');
fs.readFile(__filename, () => {
console.log('in readFile callback');
setTimeout(() => console.log('setTimeout'), 0);
setImmediate(() => console.log('setImmediate'));
process.nextTick(() => console.log('nextTick in callback'));
});
console.log('=== 主模块代码 ===');
// 可能的输出顺序:
// === 主模块代码 ===
// === 第一次执行 ===
// in readFile callback
// nextTick in callback
// setImmediate
// setTimeout
在 I/O 回调中,process.nextTick 优先级最高,然后是 setImmediate,最后是 setTimeout。
七、生产环境中的注意事项
7.1 避免长任务阻塞事件循环
生产代码中应避免耗时过长的同步操作。如果必须执行耗时计算,应考虑:
- 使用
requestAnimationFrame分解任务 - 使用 Web Worker(在浏览器)或 Worker Threads(在 Node.js)
- 使用
setImmediate或setTimeout分批执行
7.2 Node.js 性能调优
Node.js 的事件循环是单线程的,但 libuv 维护了一个线程池来处理文件 I/O 和 DNS 查询等操作。如果应用大量使用这些操作,可能需要调整线程池大小:
UV_THREADPOOL_SIZE=16 node app.js
7.3 监控工具
- 浏览器:Chrome DevTools 的 Performance 面板可以可视化事件循环和 Long Task
- Node.js:可以使用
async_hooks模块或第三方工具(如clinic.js)分析事件循环延迟
八、与其他主题的关联
| 关联主题 | 关系说明 |
|---|---|
| async-programming | Promise 和 async/await 的回调通过微任务队列执行,理解事件循环才能准确预测执行顺序 |
| node-streams | stream 的 .pipe() 和事件处理都依赖事件循环机制 |
| nodejs-internals | libuv 是 Node.js 事件循环的底层实现 |
| web-workers | Worker 线程有独立的事件循环,不会阻塞主线程 |
| performance | Long Task 是性能监控的重要指标 |
延展阅读
- Jake Archibald — Tasks, microtasks, queues and schedules — 事件循环最经典的图解文章
- Node.js 官方文档 — The Node.js Event Loop
- Node.js 官方文档 — libuv
- MDN — Event loop
- Philip Roberts — What the heck is the event loop anyway? — 视频讲解
- Venkatraman.R — Understanding Node.js Event Loop — Node.js 事件循环深入