JavaScript 事件循环机制详解
JavaScript 的事件循环(Event Loop)是其并发模型的核心,它解决了单线程语言执行异步操作的难题。理解它对于掌握异步编程、性能优化至关重要。
一、核心基础:单线程与异步
1. 为什么需要事件循环?
JavaScript 是单线程语言,同一时间只能执行一个任务。为了不阻塞 UI 渲染和用户交互,异步操作(如网络请求、定时器)被设计为非阻塞的——异步任务的执行交给浏览器其他线程处理,回调由事件循环调度执行。
2. 关键组件
┌─────────────────────────────────────┐
│ JavaScript 运行时 │
├─────────────────────────────────────┤
│ ┌──────────────┐ ┌────────────┐ │
│ │ 调用栈 │ │ Web APIs │ │
│ │ (Call Stack) │ │ (浏览器提供)│ │
│ └──────────────┘ └────────────┘ │
│ ↑ │ │
│ │ ↓ │
│ ┌──────────────┐ ┌────────────┐ │
│ │ 任务队列 │ │ Event Loop │ │
│ │ (Task Queue) │ │ 调度器 │ │
│ └──────────────┘ └────────────┘ │
└─────────────────────────────────────┘
- 调用栈:后进先出(LIFO),存放同步执行的函数
- Web APIs:浏览器提供的异步 API(DOM、Ajax、setTimeout 等)
- 任务队列:先进先出(FIFO),存放异步回调
- 事件循环:持续检查调用栈是否为空,若空则从任务队列取任务执行
二、事件循环工作流程
基本规则
- 执行所有同步代码,压入调用栈
- 遇到异步任务,交给 Web APIs 处理,主线程继续向下执行
- 异步任务完成后,其回调函数进入任务队列
- 调用栈清空后,事件循环从任务队列取出第一个任务执行
- 重复步骤 4
console.log('1'); // 同步,立即执行
setTimeout(() => { // 异步,交给定时器线程
console.log('2');
}, 0);
console.log('3'); // 同步,立即执行
// 输出顺序:1 → 3 → 2
三、核心进阶:宏任务 vs 微任务
现代事件循环引入优先级队列,任务分为两类:
| 类型 | 常见来源 | 优先级 |
|---|---|---|
| 宏任务 (Macrotask) | setTimeout, setInterval, I/O, UI 渲染, setImmediate (Node.js) | 低 |
| 微任务 (Microtask) | Promise.then/catch/finally, MutationObserver, process.nextTick (Node.js) | 高 |
执行规则(关键!)
- 执行一个宏任务(从调用栈或宏任务队列)
- 执行所有微任务队列中的任务(直到微队列为空)
- 执行 UI 渲染(如果有需要)
- 进入下一轮,执行下一个宏任务
- 重复上述循环
console.log('script start'); // 1. 同步
setTimeout(() => { // 5. 宏任务
console.log('setTimeout');
}, 0);
Promise.resolve().then(() => { // 3. 微任务
console.log('promise1');
}).then(() => { // 4. 微任务(链式)
console.log('promise2');
});
console.log('script end'); // 2. 同步
// 输出顺序:
// script start → script end → promise1 → promise2 → setTimeout
四、经典面试题解析
题目 1:综合异步场景
console.log('1'); // 同步
setTimeout(() => { // 宏任务1
console.log('2');
Promise.resolve().then(() => { // 宏1中的微任务
console.log('3');
});
}, 0);
Promise.resolve().then(() => { // 微任务1
console.log('4');
setTimeout(() => { // 微1中的宏任务(排到后面)
console.log('5');
}, 0);
}).then(() => { // 微任务2
console.log('6');
});
setTimeout(() => { // 宏任务2
console.log('7');
}, 0);
console.log('8'); // 同步
// 执行顺序分析:
// 1. 执行同步代码:1 → 8
// 2. 微任务队列:4 → 6 (微任务2在微1完成后添加)
// 3. 宏任务队列:宏1 → 宏2
// - 执行宏1:2 → (产生微任务3) → 3
// - 执行宏2:7
// 4. 下一个事件循环:检查是否有新宏任务(微1中setTimeout产生的)
// - 执行宏3:5
// 最终输出:1 → 8 → 4 → 6 → 2 → 3 → 7 → 5
题目 2:async/await 本质
async function async1() {
console.log('a');
await async2(); // await 后面的代码相当于 Promise.then(微任务)
console.log('b');
}
async function async2() {
console.log('c');
}
console.log('d');
setTimeout(() => {
console.log('e');
}, 0);
async1();
new Promise(resolve => {
console.log('f');
resolve();
}).then(() => {
console.log('g');
});
console.log('h');
// 执行顺序:
// d → a → c → f → h → b → g → e
关键点:await 会暂停函数执行,但 await 之前的代码是同步的,之后的代码注册为微任务。
五、Node.js 与浏览器差异
浏览器 Event Loop
- 宏任务队列:多个(定时器队列、I/O 队列等),按优先级取
- 微任务队列:统一,全部清空后才继续
Node.js Event Loop(更复杂)
Node.js 将事件循环分为 6 个阶段:
// 阶段顺序
┌───────────────────────────┐
│ timers (setTimeout) │
│ pending callbacks (I/O) │
│ idle, prepare │
│ poll (核心) │
│ check (setImmediate) │
│ close callbacks │
└───────────────────────────┘
关键区别:
process.nextTick不属于任何阶段,优先级高于所有微任务setImmediate在poll阶段后执行,与setTimeout顺序不确定(取决于时机)
// Node.js 中
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 输出顺序不确定!取决于启动耗时
// 但如果在 I/O 回调内:
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
// 一定输出:immediate → timeout
});
六、最佳实践与性能优化
✅ 应该做的
- 优先使用微任务处理高优先级逻辑(如数据状态更新)
- 拆分长任务:用
setTimeout(fn, 0)将耗时任务拆分到多个宏任务,避免阻塞 UI - 合理使用 async/await:代码更清晰,错误处理更方便
❌ 避免做的
- 微任务嵌套死循环:不断创建微任务会阻塞宏任务和 UI 渲染
// 危险!会卡死页面
function loop() {
Promise.resolve().then(loop);
}
loop();
-
滥用 setTimeout 模拟异步:优先使用 Promise
-
忽略错误处理:Promise 和 async 都需要
.catch()或try/catch
七、总结
事件循环的本质是任务调度器,通过单线程 + 任务队列实现非阻塞异步:
- 宏任务:主流程,每轮执行一个
- 微任务:高优先级,每轮全部清空
- async/await:基于 Promise 的语法糖
- 优化关键:合理拆分任务,避免长阻塞
理解事件循环能让你写出更高效、可预测的异步代码,也是攻克高级面试题的必备知识。