讲解

JavaScript 是单线程的:同一时刻只执行一段代码。那异步回调(定时器、网络响应、用户点击)是怎么被调度的?答案是事件循环模型。引擎维护一个调用栈(正在执行的同步代码)和两类队列:宏任务队列(setTimeout、setInterval、I/O、事件回调)和微任务队列(Promise 的 then/catch/finally 回调、queueMicrotask)。规则一句话:同步代码全部执行完 → 清空所有微任务 → 取一个宏任务执行 → 再清空微任务 → 下一个宏任务……如此往复。

「微任务在每两个宏任务之间全部清空」是最关键的规则,它解释了经典输出顺序题:同步代码最先;Promise.then 永远先于 setTimeout(即使延时 0);then 里又产生的微任务插在本轮微任务队尾,仍然会先于下一个宏任务执行。await 后面的代码本质上就是一个微任务——await 之后的部分等价于写在 then 回调里。

这个模型的工程启示:长时间运行的同步代码会卡死整个页面(按钮点不动、动画停住),因为事件循环腾不出手处理其他任务;CPU 密集的大计算要切片(setTimeout 分批)或放进 Web Worker。想让一段代码「在当前任务后尽早执行」用 queueMicrotask,「下一轮再执行」用 setTimeout(fn, 0)。

示例

沙盒是同步窗口,异步调度用一个小型模拟器演示事件循环的执行规则:

// 用两个队列模拟事件循环的调度顺序
function simulateEventLoop() {
  const log = [];
  const microQueue = [];
  const macroQueue = [];

  // 「同步代码」先入栈执行
  log.push('1. 同步代码');
  macroQueue.push(() => log.push('4. setTimeout 回调(宏任务)'));
  microQueue.push(() => {
    log.push('3. Promise.then(微任务)');
    microQueue.push(() => log.push('3.1 then 里又排的微任务,仍在本轮'));
  });
  log.push('2. 同步代码结束');

  // 事件循环:先清空微任务,再取一个宏任务
  while (microQueue.length > 0) microQueue.shift()();
  while (macroQueue.length > 0) {
    macroQueue.shift()();
    while (microQueue.length > 0) microQueue.shift()(); // 每个宏任务后再清微任务
  }
  return log;
}
simulateEventLoop().forEach((line) => console.log(line));

真实环境的输出顺序题(浏览器控制台或 Node 中运行验证):

console.log('A:同步');

setTimeout(() => console.log('D:宏任务 setTimeout'), 0);

Promise.resolve()
  .then(() => console.log('B:微任务 then'))
  .then(() => console.log('C:微任务 then 的 then'));

queueMicrotask(() => console.log('B+:queueMicrotask 也是微任务'));

// 输出顺序:A → B → B+ → C → D
// 同步先行;微任务按入队顺序全部清空(含队尾新增的 C);宏任务最后

常见坑

  • 以为 setTimeout(fn, 0) 立即执行:0 毫秒只是「尽快」,它仍要排到当前同步代码和所有微任务之后。
  • 微任务里无限递归排微任务:Promise.resolve().then(loop) 循环排微任务会饿死宏任务,页面卡死但 CPU 占满——和死循环一样危险却更隐蔽。
  • 期待 await 让出后顺序可控:await 之后是微任务,多个 await 链交错时执行顺序要靠推理而不是直觉,复杂编排画出任务队列再写。
  • 长任务阻塞交互:几秒的同步计算让所有事件回调排队,用户以为页面死了。大计算分片或交给 Web Worker。

小结

单线程 + 调用栈 + 宏/微任务队列构成事件循环;每轮先清空微任务再取宏任务。Promise.then 先于 setTimeout,长同步任务会冻结页面。下一章进入浏览器 API:fetch 网络请求。