从零理解 JavaScript 事件循环

下面这段代码,输出顺序和大多数人的第一直觉并不一致。问题不在 Promise,而在 JavaScript 到底是怎么安排任务的。

console.log('start');

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

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

console.log('end');

// 实际输出:start → end → promise → timeout

setTimeout 的延迟写的是 0,却排在 Promise 后面;而 Promise 明明也是异步的, 却比同步的 console.log('end') 还晚。要解释这一切,需要把「调用栈」「任务队列」 和「事件循环」三件事分开来看。

一、调用栈:同步代码的执行现场

JavaScript 引擎只有一个调用栈(Call Stack)。函数被调用时压入栈顶,执行完毕后弹出。 同步代码就是在这条栈上一路执行到底的,栈空之前,别的代码没有机会插进来。

所以当主线程执行到 setTimeout 时,它并不会原地等待 0 毫秒, 而是把回调交给宿主环境(浏览器或 Node.js)的定时器模块,然后立刻继续往下执行。 等到定时器到期,宿主环境才把回调放进任务队列,等待主线程空闲。

二、任务队列:宏任务与微任务

任务队列并不是只有一个。规范里至少有两种不同优先级的队列,理解它们的差别是看懂事件循环的关键。

类型 常见来源 执行时机
宏任务(task) setTimeout、setInterval、I/O、UI 事件、MessageChannel 每轮事件循环取出一个执行
微任务(microtask) Promise.then、queueMicrotask、MutationObserver 当前宏任务结束后立刻全部清空

注意「全部清空」这四个字。微任务队列不是取一个就停,而是一直执行到队列为空; 如果微任务里又产生了新的微任务,新的微任务也会在同一轮里被执行。这也是为什么 在微任务中无限地 Promise.resolve().then(...) 会让页面彻底卡死——渲染永远等不到机会。

三、一轮事件循环做了什么

  1. 从宏任务队列中取出一个任务,压入调用栈执行。
  2. 该任务执行完毕后,清空微任务队列(包括执行过程中新产生的微任务)。
  3. 如果需要,进行样式计算、布局与绘制。
  4. 进入下一轮,取下一个宏任务。

回到开头的例子:整段脚本本身就是一个宏任务,因此 start 和 end 先输出;脚本执行完后微任务队列里躺着 Promise 的回调,于是 promise 先输出; 定时器回调留在下一个宏任务里,最后才轮到 timeout。

四、async/await 只是 Promise 的语法糖

await 右边的表达式会被求值,如果结果不是 Promise 就包装成 Promise; 然后函数在此处让出控制权,await 之后的代码被放进微任务队列。 看下面这段代码:

async function foo() {
  console.log('foo start');
  await bar();
  console.log('foo end');
}

async function bar() {
  console.log('bar');
}

console.log('script start');
foo();
console.log('script end');

// script start → foo start → bar → script end → foo end

foo() 被调用后同步执行到 await,先打印 foo start, 再执行 bar() 打印 bar;随后 foo 暂停, 把剩余代码注册为微任务。主线程继续打印 script end,宏任务结束后清空微任务, 才打印 foo end。

await 之后的代码等价于写在 .then() 里, 因此它一定晚于当前同步代码,也一定早于下一个 setTimeout。

五、渲染时机:微任务清空之后

浏览器通常会在一个宏任务及其微任务全部执行完后,判断是否需要渲染。 这带来两个实践结论:

  • 想在 DOM 更新后立即拿到布局信息,微任务里是可以读到的,但会触发强制同步布局,慎用。
  • 想把一个长任务拆开让页面有机会响应,需要用 setTimeout 或 scheduler.postTask 让出宏任务,而不是微任务。

六、几个常见误区

1. setTimeout(fn, 0) 真的是 0 毫秒吗

不是。规范规定嵌套层级超过 5 层的定时器最小延迟为 4 毫秒,而且回调必须等到当前任务与微任务都清空后才有机会执行。 所以它更准确的含义是「尽快,但排在这一轮之后」。

2. requestAnimationFrame 属于哪一类

requestAnimationFrame 不属于宏任务或微任务队列,它挂在渲染步骤之前执行, 每帧最多调用一次。做动画时优先用它,而不是 setTimeout。

3. 微任务一定比渲染先执行

是的,在同一轮里微任务会先清空。但如果微任务里不断产生新微任务,渲染就会被一直推迟, 表现为页面「假死」。这类问题在排查时很容易被误判成死循环。

七、写在最后

事件循环并不复杂,它只是把「同步代码优先、微任务插队、宏任务排队、渲染择机进行」这四句话落到了实现上。 真正需要记忆的不是顺序表,而是每个 API 属于哪一类队列,以及它会不会阻塞渲染。

下一篇我们会从布局出发,聊一聊 CSS 容器查询如何让组件摆脱视口宽度的束缚。