深入理解 JavaScript 事件循环

从调用栈、宏任务、微任务到渲染时机,彻底搞懂 JS 单线程下的异步执行顺序,并解释为什么 Promise 比 setTimeout 先执行。

深入理解 JavaScript 事件循环

JavaScript 是单线程的:同一时刻只能做一件事。但我们在浏览器里写 setTimeout、发请求、监听事件,明明都是”异步”的,为什么页面不会卡死?

答案就是事件循环(Event Loop)。它把”单线程执行”和”异步回调”这两件看似矛盾的事缝合在了一起。

三个核心角色

  • 调用栈(Call Stack):正在执行的函数排队的地方。后进先出,函数执行完就出栈。
  • 任务队列(Task Queue):异步回调排队的候车室。setTimeout、I/O、UI 事件都在这里等。
  • 事件循环:不停检查”调用栈空了没”,空了就把队列里最早的任务推进栈里执行。
console.log('1');
setTimeout(() => console.log('2'), 0);
console.log('3');
// 输出顺序:1 → 3 → 2

1 和 3 是同步代码,直接进栈执行;setTimeout 的回调被丢进任务队列,等栈清空后才轮到它。

宏任务 vs 微任务

这是最容易踩坑的地方。任务其实分两类:

类型例子优先级
微任务(microtask)Promise.thenqueueMicrotaskMutationObserver高,插队
宏任务(macrotask)setTimeoutsetInterval、I/O、UI 事件、requestAnimationFrame低,排队

规则:每执行完一个宏任务,会先把当前所有微任务清空,再取下一个宏任务。

console.log('start');

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

Promise.resolve().then(() => {
  console.log('promise1');
  Promise.resolve().then(() => console.log('promise2'));
});

console.log('end');

// 输出:start → end → promise1 → promise2 → timeout

关键在于:setTimeout 回调是宏任务,要等到”下一轮”;而 Promise.then 是微任务,在当前同步代码结束后立即清空。所以即使 setTimeout(..., 0),也跑不过 Promise

渲染时机

微任务清空之后、下一个宏任务之前,浏览器可能会进行一次渲染(约每 16.6ms 一次,对应 60fps)。

这意味着:如果你在一帧里塞了一堆微任务,它们会在渲染前全部跑完——页面要等它们结束才更新。所以别在微任务里写死循环或超长计算,否则页面假死。

requestAnimationFrame 的回调属于”渲染前”的宏任务,适合做动画;requestIdleCallback 则是”浏览器空闲时”才执行,适合做非紧急的后台活儿。

一个面试常客

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}
async function async2() {
  console.log('async2');
}
console.log('script start');
setTimeout(() => console.log('setTimeout'), 0);
async1();
new Promise((resolve) => {
  console.log('promise3');
  resolve();
}).then(() => console.log('promise3 then'));
console.log('script end');

await 后面的代码会被包成微任务。整体顺序:

script start → async1 start → async2 → promise3 → script end
→ async1 end → promise3 then → setTimeout

记住一条主线就够了:同步代码 → 清空微任务 → 渲染(可能)→ 取一个宏任务 → 再清空微任务……

理解了它,Promiseasync/awaitsetTimeout 的执行顺序就再也不会乱。