事件循环是前端面试的常青树,不是因为刁钻,而是它真的决定了你写的回调什么时候跑。核心结论一句话:每执行完一个宏任务,先把微任务队列清空,再取下一个宏任务。

一道经典题

console.log('1');

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

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

console.log('5');

输出是 1 5 3 4 2。逐个拆解:

  • 15:同步代码,在调用栈里直接执行,永远最先;
  • 34:Promise 的 then 回调是微任务,当前宏任务(整段脚本)结束后立刻清空,而且一个 then 链会依次入队;
  • 2:setTimeout 回调是宏任务,即使延时为 0,也要等微任务队列清空后才轮到它。

两张清单

微任务:Promise.then/catch/finally、queueMicrotask、MutationObserver、await 之后的代码。

宏任务:setTimeout / setInterval、setImmediate(Node)、I/O、UI 渲染事件。

await 只是 then 的语法糖

async function foo() {
  console.log('a');
  await bar();          // bar() 返回后,后面的代码进微任务队列
  console.log('b');     // 相当于 then(() => console.log('b'))
}

所以 await 之后的代码不会被“阻塞”,它只是被安排到了微任务里——函数本身立刻返回,主线程该干嘛干嘛。

一个实践提醒

微任务会在每个宏任务后清空,这意味着一段失控的微任务链(比如递归 then)会让页面永远得不到渲染机会,页面表现为“卡死但 CPU 不高”。反过来,重计算想“不阻塞渲染”,光用 Promise 包一层是没用的——得拆成宏任务(setTimeout)或丢给 Web Worker。