← 中文资源

简体中文版本

JavaScript 事件循环

事件循环是 JavaScript 单线程并发模型的核心机制。它检查调用栈是否为空,并按优先级取出微任务或宏任务执行。本文解释执行顺序、常见误解与阻塞风险。

阅读英文原文

什么是事件循环

事件循环是 JavaScript 在不使用多线程的情况下处理异步操作的核心机制。尽管 JavaScript 是单线程的,运行时(例如 Chrome 中的 V8、Node.js)仍通过事件循环把 I/O 操作交给操作系统,并管理一个等待执行的回调队列。

事件循环由调用栈、微任务队列(Promise、MutationObserver)、宏任务队列(也叫任务队列,包含 setTimeout、DOM 事件、I/O)以及一个持续运行的循环组成。当调用栈为空时,事件循环先清空所有微任务(先进先出),再处理一个宏任务,然后再次清空微任务,如此循环。

工作方式

  1. 调用栈执行:同步代码立即在调用栈上运行。
  2. 派发异步操作:setTimeout、fetch、DOM 事件被交给浏览器或操作系统。
  3. 回调入队:异步操作完成后,其回调进入相应的队列。
  4. 循环调度:当调用栈为空时,事件循环会:
    • 先清空所有微任务(Promise 回调、MutationObserver);
    • 再处理一个宏任务(定时器、事件回调、I/O);
    • 然后再次清空微任务。

示例

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 拥有独立的事件循环)