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

第 2 课:任务队列与事件循环

本课目标:

  • 说出三个核心部件:调用栈、任务队列、事件循环。
  • 解释 setTimeout 的回调在任务队列里等待,直到被取走。
  • 解释事件循环只在栈空时才把排队的回调搬上栈。

先修条件:第 1 课(调用栈与运行至完成) | 上一篇 << 01 | 下一篇 03 >>

「稍后」不是一个地方:回调到底去了哪里?

第 1 课里你看到被推迟的回调最后才运行。但「被推迟」不是解释,只是一句含糊话。从 setTimeout 登记回调的那一刻到它运行的那一刻之间,回调实际在哪里等?又是谁把它叫醒?没有答案,你仍然在信任一个黑盒。这一课把盒子打开。零件一共只有三个,当你能把三个都叫出名字,「它稍后运行」就变成了「它进这里,等那个条件满足时,这个机制把它取出来」。

讲解

第一个零件你已经认识:调用栈,同步代码在这里运行,一次一帧1。第二个零件是任务队列(你也会听到它被叫作回调队列或宏任务队列)。当 setTimeout 的延迟走完,它的回调不会跳上调用栈,而是被放到任务队列的队尾排队等候1。队列是先进先出的:回调按加入的顺序被取走1

第三个零件是事件循环。它的职责简单得近乎朴素:盯着调用栈,一旦栈空了,就从任务队列里取出等得最久的任务,压上栈去运行1。整个循环就这么多:一个任务只有在栈空时才算完成,也只有在那之后,下一个任务才会被取出1

现在第 1 课的谜团有了机制。setTimeout(fn, 0) 的回调不可能在同步代码还在栈上时运行,因为事件循环只在栈的时候才伸手进任务队列2。运行至完成并不是额外钉上去的特殊规则,它是「事件循环等栈空」的自然结果。

把这张图当作一个循环来读。同步代码在栈上运行;途中 setTimeout 往任务队列里放了一个回调,它在那里等着(虚线)。当脚本结束、栈空了,事件循环取出等得最久的任务,放上栈运行。如果那个回调又安排了新的定时器,循环就再来一遍。虚线是关键:同步代码运行的整段时间里,回调一直闲在队列里。

还有一个你会遇到的正式说法:浏览器和 Node 用一个带有一个或多个任务队列事件循环来正式描述这套机制3。推断顺序并不需要读规范,但知道这一点有帮助:「任务队列」是你的 setTimeout 回调真实落进去的、有名字的东西,不是比喻。

完整示例(跟着做)

你登记了两个定时器,并在它们之间做同步工作:

用三个零件把它走一遍。同步行先在栈上运行:ACE 依次打印。途中,两行 setTimeout 各往任务队列放了一个回调:B 的回调比 D 的先入队。脚本结束、栈空,事件循环开始按先进先出从任务队列取任务:先 B,后 D。输出是 ACEBD。三条同步打印按源码顺序出来,因为它们在栈上运行;两条被推迟的打印按登记顺序出来,因为队列先进先出。

你来试试(填空示例)

用「栈对队列」的思路预测输出。把顺序填出来:

答案:顺序是 twofouronethree。两个同步的 console.log 先在栈上运行,所以 twofour 按源码顺序打印。与此同时,两个 setTimeout 回调都在任务队列里等;事件循环要到栈空才碰它们,然后按先进先出取:onethree 先登记,所以 one 先打印。写在第一行的 setTimeout 仍然排在最后一批打印,因为在源码里靠前不等于先上,只等于先进队列

小结 + 下节课预告

三个零件撑起全场:调用栈(同步代码,一次一帧)、任务队列(setTimeout 这类被推迟的回调在这里等)、事件循环(只在栈空时把排队的任务搬上栈)。「它稍后运行」现在的意思是「它在任务队列里等,直到事件循环把它取出」。但还有第二个等候室我们没提,优先级更高,promise 用的就是它。这也是为什么写在 setTimeout(fn, 0) 之后.then() 仍然在它之前运行。那就是微任务队列,也就是下一课。

Footnotes

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

  2. MDN: setTimeout() — https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout

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

练习

01

在文件 queue.mjs(或浏览器控制台)里粘贴完整示例那段五行的 A / B / C / D / E 代码。先预测顺序,再用 node queue.mjs 运行对照。

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

让一个定时器再安排另一个:在第一个 setTimeout 回调内部加 setTimeout(() => console.log("nested"), 0)。先预测 nested 相对第二个顶层定时器落在哪里,再验证。

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