/
Update
9 min read
中文 事件循环
一、为什么需要事件循环?#
JavaScript 是单线程语言,意味着它一次只能执行一个任务。如果所有操作都是同步的,一个耗时的网络请求就会阻塞整个页面,导致界面”卡死” 。
事件循环(Event Loop)就是 JavaScript 处理异步任务的调度机制。它让 JS 能够在等待 I/O 操作(网络请求、定时器、用户输入)的同时,继续执行其他代码,实现非阻塞的并发效果 。
二、核心组件架构#
┌─────────────────────────────────────────────────────────────┐
│ JavaScript 引擎 │
│ ┌──────────────┐ │
│ │ 调用栈 │ ← 同步代码在这里执行(LIFO:后进先出) │
│ │ Call Stack │ │
│ └──────┬───────┘ │
│ │ 同步代码执行完毕 │
│ ▼ │
│ ┌──────────────┐ ┌─────────────────┐ │
│ │ 微任务队列 │ │ 宏任务队列 │ │
│ │ Microtask │ │ Macrotask │ │
│ │ (VIP 通道) │ │ (普通通道) │ │
│ │ │ │ │ │
│ │ Promise.then │ │ setTimeout │ │
│ │ queueMicrotask│ │ setInterval │ │
│ │ MutationObserver│ │ DOM 事件 │ │
│ └──────┬───────┘ └────────┬────────┘ │
│ │ │ │
│ └─────────┬───────────┘ │
│ │ │
│ 事件循环(Event Loop) │
│ 不断检查:调用栈空了吗?微任务队列有任务吗? │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Web APIs │ ← 浏览器/Node.js 提供的异步能力
│ (定时器、网络请求)│
└─────────────────┘plaintext三、执行顺序铁律#
事件循环遵循严格的优先级规则 :
1. 执行同步代码(全部压入调用栈执行)
2. 调用栈清空后,检查微任务队列 → 执行所有微任务
3. 微任务队列清空后,执行一个宏任务
4. 回到步骤 2,循环往复plaintext关键规则 :
- 微任务 = VIP 通道:每次调用栈清空后,先清空全部微任务队列,才会执行下一个宏任务
- 宏任务 = 普通通道:每次只执行一个,然后回到检查微任务
- 微任务可以插队:即使宏任务已经排队,新加入的微任务也会优先执行
四、任务类型分类#
| 类型 | 包含内容 | 优先级 |
|---|---|---|
| 同步任务 | 普通 JavaScript 代码 | 最高,立即执行 |
| 微任务(Microtask) | Promise.then/catch/finally、queueMicrotask()、MutationObserver | 高,当前宏任务结束后立即执行 |
| 宏任务(Macrotask) | setTimeout、setInterval、setImmediate(Node)、I/O 操作、UI 渲染 | 低,每次循环执行一个 |
| 渲染 | 浏览器重绘/重排 | 微任务之后、下一个宏任务之前 |
五、经典代码分析#
基础示例:Promise vs setTimeout#
console.log('1. Start');
setTimeout(() => {
console.log('4. Timeout'); // 宏任务
}, 0);
Promise.resolve().then(() => {
console.log('3. Promise'); // 微任务
});
console.log('2. End');javascript输出顺序:1 → 2 → 3 → 4
执行过程解析 :
步骤 1: console.log('1. Start') → 同步,立即输出 "1. Start"
步骤 2: setTimeout(..., 0) → 交给 Web API,0ms 后回调进入宏任务队列
步骤 3: Promise.resolve().then() → Promise 回调进入微任务队列
步骤 4: console.log('2. End') → 同步,立即输出 "2. End"
─── 同步代码执行完毕,调用栈清空 ───
步骤 5: 检查微任务队列 → 有 Promise 回调 → 输出 "3. Promise"
步骤 6: 微任务队列已空 → 检查宏任务队列 → 输出 "4. Timeout"plaintext为什么 setTimeout(0) 比 Promise 慢? 因为 Promise 回调进入微任务队列(VIP 通道),而 setTimeout 进入宏任务队列(普通通道)。微任务总是优先执行 。
进阶示例:嵌套微任务#
console.log('A');
setTimeout(() => {
console.log('B');
Promise.resolve().then(() => console.log('C'));
}, 0);
Promise.resolve()
.then(() => {
console.log('D');
setTimeout(() => console.log('E'), 0);
return Promise.resolve();
})
.then(() => console.log('F'));
setTimeout(() => console.log('G'), 0);
Promise.resolve().then(() => {
console.log('H');
Promise.resolve().then(() => console.log('I'));
});
console.log('J');javascript输出顺序:A → J → D → H → F → I → B → C → G → E
解析 :
第 1 轮:
同步:A, J
微任务队列:Promise1(D→F), Promise2(H→I) → 输出 D, H, F, I
宏任务队列:setTimeout1(B→C), setTimeout2(G), setTimeout3(E)
第 2 轮(取第一个宏任务 setTimeout1):
执行 B,产生新微任务 C
微任务队列:C → 输出 C
宏任务队列:setTimeout2(G), setTimeout3(E)
第 3 轮(取 setTimeout2):
输出 G
第 4 轮(取 setTimeout3):
输出 Eplaintext六、async/await 与事件循环#
async/await 是 Promise 的语法糖,本质上还是通过微任务实现 。
console.log('1');
async function foo() {
console.log('2');
await Promise.resolve(); // await 后面的代码变为微任务
console.log('3'); // 进入微任务队列
}
foo();
console.log('4');
// 输出:1 → 2 → 4 → 3javascript解析:
await之前的代码同步执行(输出 2)await表达式返回的 Promise 被挂起await之后的代码被包装成微任务,等待当前同步代码执行完毕后执行
七、Node.js 的特殊情况#
Node.js 的事件循环比浏览器更复杂,分为 6 个阶段 :
| 阶段 | 作用 |
|---|---|
| timers | 执行 setTimeout/setInterval 回调 |
| pending callbacks | 执行系统操作的回调(如 TCP 错误) |
| idle, prepare | Node 内部使用 |
| poll | 获取新的 I/O 事件,执行 I/O 回调 |
| check | 执行 setImmediate 回调 |
| close callbacks | 执行 close 事件回调 |
Node.js 特有的”超级 VIP” :
process.nextTick(() => {
console.log('nextTick'); // 比 Promise 还快!
});
Promise.resolve().then(() => {
console.log('Promise');
});
// 输出:nextTick → Promisejavascript⚠️
process.nextTick不属于事件循环的正式阶段,它在当前操作完成后立即执行,如果滥用可能导致 I/O 饥饿 。
八、常见面试题#
题目 1#
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => {
console.log('3');
return Promise.resolve();
}).then(() => console.log('4'));
console.log('5');javascript答案:1 → 5 → 3 → 4 → 2
题目 2#
async function async1() {
console.log('async1 start');
await async2();
console.log('async1 end');
}
async function async2() {
console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise(resolve => {
console.log('promise1');
resolve();
}).then(() => console.log('promise2'));
console.log('script end');javascript答案:script start → async1 start → async2 → promise1 → script end → async1 end → promise2 → setTimeout
九、性能优化建议#
1. 避免微任务过多导致阻塞#
// ❌ 危险:微任务无限递归,阻塞宏任务和渲染
function loop() {
Promise.resolve().then(loop);
}
loop(); // 页面卡死,setTimeout 永远不会执行javascript2. 大量数据处理使用宏任务分片#
// ❌ 阻塞主线程
items.forEach(item => heavyComputation(item));
// ✅ 使用 setTimeout 分片,让 UI 有机会渲染
async function processData(items) {
for (let item of items) {
heavyComputation(item);
if (needToYield()) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}javascript3. 优先使用微任务处理紧急更新#
// 推荐:使用 queueMicrotask 或 Promise 处理需要尽快执行的逻辑
queueMicrotask(() => {
// 比 setTimeout(..., 0) 更快执行
});javascript十、核心口诀#
同步代码最先走,微任务排队 VIP,
宏任务一个一个来,渲染穿插在中间。
await 挂起变微任务,nextTick 是超级 VIP。plaintext理解事件循环是掌握 JavaScript 异步编程的关键。记住:微任务优先于宏任务,同步优先于异步,await 后面的代码是微任务。