第 2 课:任务队列与事件循环
本课目标:
- 说出三个核心部件:调用栈、任务队列、事件循环。
- 解释
setTimeout的回调在任务队列里等待,直到被取走。- 解释事件循环只在栈空时才把排队的回调搬上栈。
「稍后」不是一个地方:回调到底去了哪里?
第 1 课里你看到被推迟的回调最后才运行。但「被推迟」不是解释,只是一句含糊话。从 setTimeout 登记回调的那一刻到它运行的那一刻之间,回调实际在哪里等?又是谁把它叫醒?没有答案,你仍然在信任一个黑盒。这一课把盒子打开。零件一共只有三个,当你能把三个都叫出名字,「它稍后运行」就变成了「它进这里,等那个条件满足时,这个机制把它取出来」。
讲解
第一个零件你已经认识:调用栈,同步代码在这里运行,一次一帧1。第二个零件是任务队列(你也会听到它被叫作回调队列或宏任务队列)。当 setTimeout 的延迟走完,它的回调不会跳上调用栈,而是被放到任务队列的队尾排队等候1。队列是先进先出的:回调按加入的顺序被取走1。
第三个零件是事件循环。它的职责简单得近乎朴素:盯着调用栈,一旦栈空了,就从任务队列里取出等得最久的任务,压上栈去运行1。整个循环就这么多:一个任务只有在栈空时才算完成,也只有在那之后,下一个任务才会被取出1。
现在第 1 课的谜团有了机制。setTimeout(fn, 0) 的回调不可能在同步代码还在栈上时运行,因为事件循环只在栈空的时候才伸手进任务队列2。运行至完成并不是额外钉上去的特殊规则,它是「事件循环等栈空」的自然结果。
把这张图当作一个循环来读。同步代码在栈上运行;途中 setTimeout 往任务队列里放了一个回调,它在那里等着(虚线)。当脚本结束、栈空了,事件循环取出等得最久的任务,放上栈运行。如果那个回调又安排了新的定时器,循环就再来一遍。虚线是关键:同步代码运行的整段时间里,回调一直闲在队列里。
还有一个你会遇到的正式说法:浏览器和 Node 用一个带有一个或多个任务队列的事件循环来正式描述这套机制3。推断顺序并不需要读规范,但知道这一点有帮助:「任务队列」是你的 setTimeout 回调真实落进去的、有名字的东西,不是比喻。
完整示例(跟着做)
你登记了两个定时器,并在它们之间做同步工作:
用三个零件把它走一遍。同步行先在栈上运行:A、C、E 依次打印。途中,两行 setTimeout 各往任务队列放了一个回调:B 的回调比 D 的先入队。脚本结束、栈空,事件循环开始按先进先出从任务队列取任务:先 B,后 D。输出是 A、C、E、B、D。三条同步打印按源码顺序出来,因为它们在栈上运行;两条被推迟的打印按登记顺序出来,因为队列先进先出。
你来试试(填空示例)
用「栈对队列」的思路预测输出。把顺序填出来:
答案:顺序是 two、four、one、three。两个同步的 console.log 先在栈上运行,所以 two 和 four 按源码顺序打印。与此同时,两个 setTimeout 回调都在任务队列里等;事件循环要到栈空才碰它们,然后按先进先出取:one 比 three 先登记,所以 one 先打印。写在第一行的 setTimeout 仍然排在最后一批打印,因为在源码里靠前不等于先上栈,只等于先进队列。
小结 + 下节课预告
三个零件撑起全场:调用栈(同步代码,一次一帧)、任务队列(setTimeout 这类被推迟的回调在这里等)、事件循环(只在栈空时把排队的任务搬上栈)。「它稍后运行」现在的意思是「它在任务队列里等,直到事件循环把它取出」。但还有第二个等候室我们没提,优先级更高,promise 用的就是它。这也是为什么写在 setTimeout(fn, 0) 之后的 .then() 仍然在它之前运行。那就是微任务队列,也就是下一课。
Footnotes
-
MDN: JavaScript execution model — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model ↩ ↩2 ↩3 ↩4 ↩5
-
MDN: setTimeout() — https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout ↩
-
HTML Living Standard: Event loops — https://html.spec.whatwg.org/multipage/webappapis.html#event-loops ↩