很多人第一次接触 PromisesetTimeout 时,都会对「谁先执行」感到困惑。答案几乎总是落在事件循环(Event Loop)上。本文用尽量少的术语,把这条执行链路讲清楚。

一张简化的执行图

可以把运行时想成三块协作:

  • 调用栈(Call Stack):正在执行的同步代码。
  • 任务队列:等待进入栈的异步回调,又分为宏任务与微任务。
  • 事件循环:不断检查「栈是否空了」,空了就取下一批任务。

同步代码跑完 → 清空微任务队列 →(可能渲染)→ 取下一个宏任务 → 再清空微任务……如此循环。

宏任务与微任务

常见宏任务setTimeoutsetInterval、I/O、UI 事件回调等。

常见微任务Promise.then/catch/finallyqueueMicrotaskMutationObserver

关键差异:一个宏任务结束后,引擎会把当前积压的微任务全部跑完,再考虑渲染和下一个宏任务。

一段经典例子

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// 输出顺序:1 → 4 → 3 → 2

原因很直接:

  1. 14 是同步,立刻打印。
  2. setTimeout 把回调放进宏任务队列。
  3. Promise.then 把回调放进微任务队列。
  4. 同步结束,先清空微任务 → 打印 3
  5. 再取宏任务 → 打印 2

和页面渲染的关系

浏览器通常在「宏任务之间」找机会做样式计算与绘制。如果你在微任务里写了很长的循环,可能阻塞渲染,页面会卡住——因为微任务队列必须清空才轮得到下一帧。

经验法则:短逻辑可以放微任务;重计算、批量 DOM 更新要拆开,避免一口气占满微任务队列。

实践建议

  • 需要「当前同步之后立刻」执行:用 queueMicrotaskPromise.resolve().then
  • 需要「让出主线程、稍后再做」:用 setTimeout / requestAnimationFrame
  • 调试顺序问题时,先画「同步 → 微任务 → 宏任务」三条线,再对号入座。

小结

事件循环不是玄学:栈空了就调度;微任务优先于下一个宏任务;渲染夹在宏任务缝隙里。把这三点记住,大部分「异步打印顺序」题都能稳稳答出来,业务里的竞态排查也会更顺手。