事件循环是前端面试的常青树,不是因为刁钻,而是它真的决定了你写的回调什么时候跑。核心结论一句话:每执行完一个宏任务,先把微任务队列清空,再取下一个宏任务。
一道经典题
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。逐个拆解:
1和5:同步代码,在调用栈里直接执行,永远最先;3和4: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。