很多人第一次接触 Promise 和 setTimeout 时,都会对「谁先执行」感到困惑。答案几乎总是落在事件循环(Event Loop)上。本文用尽量少的术语,把这条执行链路讲清楚。
一张简化的执行图
可以把运行时想成三块协作:
- 调用栈(Call Stack):正在执行的同步代码。
- 任务队列:等待进入栈的异步回调,又分为宏任务与微任务。
- 事件循环:不断检查「栈是否空了」,空了就取下一批任务。
同步代码跑完 → 清空微任务队列 →(可能渲染)→ 取下一个宏任务 → 再清空微任务……如此循环。
宏任务与微任务
常见宏任务:setTimeout、setInterval、I/O、UI 事件回调等。
常见微任务:Promise.then/catch/finally、queueMicrotask、MutationObserver。
关键差异:一个宏任务结束后,引擎会把当前积压的微任务全部跑完,再考虑渲染和下一个宏任务。
一段经典例子
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出顺序:1 → 4 → 3 → 2
原因很直接:
1和4是同步,立刻打印。setTimeout把回调放进宏任务队列。Promise.then把回调放进微任务队列。- 同步结束,先清空微任务 → 打印
3。 - 再取宏任务 → 打印
2。
和页面渲染的关系
浏览器通常在「宏任务之间」找机会做样式计算与绘制。如果你在微任务里写了很长的循环,可能阻塞渲染,页面会卡住——因为微任务队列必须清空才轮得到下一帧。
经验法则:短逻辑可以放微任务;重计算、批量 DOM 更新要拆开,避免一口气占满微任务队列。
实践建议
- 需要「当前同步之后立刻」执行:用
queueMicrotask或Promise.resolve().then。 - 需要「让出主线程、稍后再做」:用
setTimeout/requestAnimationFrame。 - 调试顺序问题时,先画「同步 → 微任务 → 宏任务」三条线,再对号入座。
小结
事件循环不是玄学:栈空了就调度;微任务优先于下一个宏任务;渲染夹在宏任务缝隙里。把这三点记住,大部分「异步打印顺序」题都能稳稳答出来,业务里的竞态排查也会更顺手。