什么是事件循环
事件循环是 JavaScript 在不使用多线程的情况下处理异步操作的核心机制。尽管 JavaScript 是单线程的,运行时(例如 Chrome 中的 V8、Node.js)仍通过事件循环把 I/O 操作交给操作系统,并管理一个等待执行的回调队列。
事件循环由调用栈、微任务队列(Promise、MutationObserver)、宏任务队列(也叫任务队列,包含 setTimeout、DOM 事件、I/O)以及一个持续运行的循环组成。当调用栈为空时,事件循环先清空所有微任务(先进先出),再处理一个宏任务,然后再次清空微任务,如此循环。
工作方式
- 调用栈执行:同步代码立即在调用栈上运行。
- 派发异步操作:
setTimeout、fetch、DOM 事件被交给浏览器或操作系统。 - 回调入队:异步操作完成后,其回调进入相应的队列。
- 循环调度:当调用栈为空时,事件循环会:
- 先清空所有微任务(Promise 回调、
MutationObserver); - 再处理一个宏任务(定时器、事件回调、I/O);
- 然后再次清空微任务。
- 先清空所有微任务(Promise 回调、
示例
console.log('1: sync');
setTimeout(() => console.log('2: macrotask'), 0);
Promise.resolve().then(() => console.log('3: microtask'));
console.log('4: sync');
// 输出顺序:1 → 4 → 3 → 2
运行时内部
现代引擎使用分层队列:
- 微任务队列:Promise 决议、
queueMicrotask、MutationObserver回调。每个宏任务之间会被完全清空。 - 宏任务队列(任务队列):
setTimeout、setInterval、I/O、UI 渲染、MessageChannel。 - Node.js 额外机制:
setImmediate(check 阶段)、process.nextTick(类似微任务,但优先级更高)。
常见误解
setTimeout(fn, 0)会在当前行之后立即执行。 实际上它要等当前宏任务结束,并且所有微任务清空后,才会进入下一个宏任务。- Promise 是宏任务。 Promise 产生的是微任务,优先级更高,并且会在每个宏任务之间被完全清空。
setTimeout(callback, 1000)保证精确延迟 1000ms。 延迟只是最小值;如果主线程被阻塞,实际执行时间会更晚。
常见陷阱
阻塞事件循环:一个持续 2 秒的同步循环会阻塞所有 I/O、定时器和用户交互。
// ❌ 错误:阻塞事件循环 2 秒
function blockingLoop() {
const end = Date.now() + 2000;
while (Date.now() < end) { /* 空转 */ }
}
修复方式:使用 setTimeout、setImmediate 或 requestAnimationFrame 把工作拆分成多个片段。
无限微任务:不断产生微任务,使宏任务永远没有机会执行。
// ❌ 错误:饥饿——定时器永远不会触发
function starveLoop() {
Promise.resolve().then(() => starveLoop());
}
何时需要事件循环模型
- 排查回调为何比预期更晚执行
- 理解
async/await的执行顺序 - 让主线程保持空闲,从而优化 UI 响应性
- 设计任务调度器与协作式多任务
相关概念
- 调用栈:同步执行上下文
- 堆:对象分配所在的内存区域
- Web Worker:真正的并行执行(每个 Worker 拥有独立的事件循环)