JavaScript async/await:事件循环与执行顺序详解 · 第 3 / 5 节

第 3 课:微任务队列与执行优先级

本课目标:

  • 区分微任务队列和任务队列。
  • 解释 promise 的 .then() 回调是微任务,不是任务。
  • 预测整个微任务队列会在下一个任务之前清空,所以 .then() 先于 setTimeout(fn, 0) 运行。

先修条件:第 2 课(调用栈、任务队列、事件循环) | 上一篇 << 02 | 下一篇 04 >>

.then() 写在后面,为什么先于定时器运行?

击穿多数人模型的正是这个顺序。你在某一行安排了 setTimeout(fn, 0),几行之后(在它后面)又给一个已解决的 promise 挂了 .then()。两者都「被推迟」,看起来都该等同步代码之后运行。按第 2 课的任务队列规则,你可能预期先登记的定时器赢。它没有。.then() 先运行,每次都是。存在第二个优先级更高的等候室,promise 就住在里面。看见它之后,promise 对定时器的顺序就不再是抛硬币。

讲解

队列有两个,不是一个。第 2 课认识的任务队列装的是 setTimeout 的回调。它旁边还有一个独立的微任务队列,而且正式地说,微任务队列不是任务队列,它是独立的一种东西1。promise 用微任务队列来运行自己的回调:.then().catch().finally() 的处理函数都被安排成微任务2

差别在于两个队列各自何时被清空,这就是本课的全部内容。当前同步代码运行结束、栈空之后,事件循环并不会立刻抓下一个任务。它先运行所有微任务,把微任务队列彻底清空,然后才从任务队列取一个任务3。微任务优先级更高,微任务队列会在碰任务队列之前先被清空3

对比这两个回调。setTimeout(fn, 0) 的回调进任务队列。.then() 的回调进微任务队列。同步脚本结束时,微任务队列先清空,所以 .then() 先于 setTimeout 运行,无论哪一行写在前面2。另外,.then() 的处理函数永远异步运行,哪怕 promise 已经落定;它是被安排的,从不原地执行4

带着两个队列走一遍。console.log("1") 同步运行,打印 1setTimeout 那一行把回调排进任务队列。Promise.resolve().then(...) 那一行把回调排进微任务队列。console.log("4") 同步运行,打印 4。现在脚本结束、栈空了。事件循环先清空微任务队列,打印 3。然后才从任务队列取一个任务,最后打印 2。顺序是 1432

从「先把微任务队列整个清空」还能推出两件事。如果一个微任务又安排了新的微任务,新的那个也会在下一个任务之前运行:事件循环会一直清下去,直到微任务队列真正为空,哪怕微任务不断添加更多2。而链式 promise(.then().then())的每个处理函数都被安排成一个独立的微任务,所以一条很长的 promise 链可以在一个 setTimeout(fn, 0) 运行之前完整走完。

完整示例(跟着做)

一个已解决的 promise 带两步链,与一个定时器赛跑:

追踪两个队列。startend 是同步的,先打印:start,然后 endsetTimeout 的回调进任务队列。第一个 .then() 进微任务队列。栈空之后,微任务队列开始清空:then 1 运行并打印,它的完成又把 then 2 安排成新的微任务,而事件循环在碰任何任务之前会先运行它,于是 then 2 也打印了。到这时微任务队列才空,事件循环取出那个任务,打印 timer。顺序是 startendthen 1then 2timer。整条两步 promise 链在零延迟定时器得到机会之前就走完了。

你来试试(填空示例)

预测输出,给每个被推迟的行标上「任务」或「微任务」。把顺序填出来:

答案:顺序是 aecdb。同步打印 ae 先出,按源码顺序。setTimeout 的回调(b)是任务;两个 .then() 回调(cd)是微任务。栈空之后,微任务队列按登记顺序先清空:先 c,后 d。微任务队列空了,事件循环才运行那个任务:b。所以两个 promise 回调都先于定时器,哪怕定时器登记得更早:微任务永远在下一个任务之前清空。

小结 + 下节课预告

等候室有两个。setTimeout 的回调进任务队列;promise 的 .then()/.catch()/.finally() 回调进微任务队列。栈空时,事件循环先把整个微任务队列清空,才运行一个任务:所以 promise 回调先于零延迟定时器,与源码顺序无关。你现在拥有完整的顺序机器:栈,然后所有微任务,然后一个任务,循环往复。最后一块拼图,是即便懂了这些也仍会绊倒人的那个问题:await 到底对它下面那行做了什么?答案是「它把函数剩下的部分变成一个微任务」,这就是第 4 课。

Footnotes

  1. HTML Living Standard: Event loops — https://html.spec.whatwg.org/multipage/webappapis.html#event-loops

  2. MDN: Using microtasks in JavaScript with queueMicrotask() — https://developer.mozilla.org/en-US/docs/Web/API/HTML_DOM_API/Microtask_guide 2 3

  3. MDN: JavaScript execution model — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model 2

  4. MDN: Promise.prototype.then() — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/then

练习

01

把讲解里的 1 / 2 / 3 / 4 代码片段粘进 micro.mjs(或控制台)。先预测顺序,再用 node micro.mjs 运行检查。

第 1 级(热身)
完成标准 · 本地勾选
02

加一个 queueMicrotask(() => console.log("mt")),再在一个已有的 .then() 里嵌一个 .then()。先预测它们与 setTimeout(fn, 0) 怎么交错,再验证。

第 2 级(进阶)
完成标准 · 本地勾选