Kodokon kodokon.com

事件循环:调用栈、微任务、宏任务

理解引擎实际上是如何调度同步代码、微任务和宏任务的。

10 分钟 · 3 题

在 Kodokon 中打开本课

JavaScript 在单线程上运行你的代码,但事件循环并不是由 ECMAScript 定义的:描述它工作方式的是 HTML 规范(在 Node.js 一侧则是 libuv)。引擎为当前正在运行的代码维护一个调用栈、一个宏任务队列(定时器、I/O、事件)和一个微任务队列(Promise.prototype.thenqueueMicrotaskMutationObserver)。黄金法则:每执行完一个宏任务后,微任务队列会被完全清空,然后才把控制权交还出去。

JAVASCRIPT
console.log("script start");

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

Promise.resolve()
  .then(() => console.log("promise 1"))
  .then(() => console.log("promise 2"));

console.log("script end");
// script start, script end,
// promise 1, promise 2, timeout
微任务总是先于宏任务运行。

初始脚本本身就是一个宏任务:在它执行完之前,其他任何东西都不会运行。一旦调用栈清空,引擎就会清空微任务队列,包括清空过程中新加入的微任务。这个细节解释了为什么两个链式的 then 调用会先于 setTimeout(fn, 0) 运行:每个 then 都会调度另一个微任务,而在队列清空之前,控制权永远不会被交还出去。

JAVASCRIPT
setTimeout(() => console.log("macro 1"), 0);
setTimeout(() => console.log("macro 2"), 0);

queueMicrotask(() => {
  console.log("micro 1");
  queueMicrotask(() => console.log("micro 2"));
});
// micro 1, micro 2, macro 1, macro 2
嵌套的微任务会先于任何宏任务运行。

await 关键字建立在这套机制之上:它会挂起 async 函数,把控制权交还给调用方,然后把恢复执行调度为一个微任务。即使是 await null 也会触发这种挂起,因为该值会被包裹进一个已经 resolve 的 promise 中。这就是为什么放在 await 之后的代码永远不会同步运行。

JAVASCRIPT
async function main() {
  console.log("before await");
  await null;
  console.log("after await");
}

main();
console.log("sync code");
// before await, sync code, after await
await 会挂起函数并重新调度其余部分。

知识检测

确认你已牢记本课的重点内容。

  1. 一个脚本同步打印 C,通过 setTimeout(fn, 0) 调度 A,并通过 Promise.resolve().then 调度 B。这些字母以什么顺序出现?
    • A, B, C
    • C, A, B
    • C, B, A
    • B, C, A
  2. 微任务队列什么时候被清空?
    • 每个宏任务之后,完全清空,包括清空过程中新加入的微任务
    • 两个宏任务之间只运行一个微任务
    • 每隔 16 毫秒,就在页面渲染之前
    • 只有在所有宏任务都处理完之后才清空一次
  3. await 在底层实际上做了什么?
    • 它阻塞线程直到 promise resolve
    • 它挂起 async 函数,并把恢复执行调度为一个微任务
    • 它把函数的其余部分变成一个宏任务
    • 如果被 await 的值不是 promise,它什么都不做