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

第 1 课:调用栈与运行至完成

本课目标:

  • 把调用栈描述为 JavaScript 此刻正在运行的函数列表。
  • 说出运行至完成规则:所有同步代码先执行完,任何被推迟的回调才会运行。
  • 预测 setTimeout(fn, 0) 的回调会在它后面的同步行之后打印。

先修条件:你会写 JavaScript 函数,会调用 console.logsetTimeout | 下一篇 02 >>

写在中间的那行,为什么最后才打印?

正是这样的时刻让人开始不信任 JavaScript。你从上到下写了三行代码,中间那行是 setTimeout(fn, 0),它的消息却排在最后打印,哪怕你要求的是零毫秒。感觉像是语言无视了你的顺序。它没有。底下有一条精确的规则,而且它是这门课其余一切的地基:JavaScript 只有一个线程,它先把所有普通同步代码执行完,任何「被推迟」的东西都要排队等。抓住这一条规则,剩下的顺序谜题就都能读懂了。

讲解

先从调用栈说起。它就是 JavaScript 正在运行中的函数列表,按后进先出堆叠:调用一个函数就把一帧压进栈顶,返回就把它弹出1。运行一个文件时,整个脚本是最底下的那一帧,你调用的每个 console.log(...) 都会压栈、运行,然后在下一行之前弹出。这样的栈只有一个,处理它的线程也只有一个:JavaScript 一次只做一件事1

这个单线程直接引出解释今天这个意外的规则:运行至完成。每个任务都会完整运行结束,其他任务才能开始1。说白了:JavaScript 不会打断你的同步代码去插进一个回调。它先把你的脚本从头到尾跑完,只有在那之后,才去看任何被推迟的东西。

setTimeout 到底做了什么?它不会现在就运行它的回调。它把回调交给运行时,等一段延迟之后再运行,而 0 的延迟并不表示「立刻」,而是「在之后的某一轮,等当前工作做完」2。回调被推迟了,于是运行至完成保证每一行同步代码都先于它运行,无论你把 setTimeout 写在文件的哪个位置。

像引擎那样去读这段代码。console.log("A") 是同步的,运行并打印 AsetTimeout 那一行本身也是同步运行的,但它做的只是把箭头函数登记为稍后运行,箭头函数本身还没有运行。console.log("C") 是同步的,打印 C。现在同步脚本结束、栈已经空了,被推迟的回调这才轮到自己,打印 B。顺序是 ACB

注意这里延迟的数值几乎不重要。哪怕是 0,回调也被推迟了,而被推迟的永远排在同步的后面。这就是为什么靠调低超时时间来「让它更快」从来修不好顺序 bug:回调从一开始就不可能抢在同步代码前面。

完整示例(跟着做)

你在给一段同步循环计时,前后各有一条打印,中间夹着一条被推迟的打印:

一步步看定时器的打印为什么排在最后。console.log("start") 是同步的,打印 startsetTimeout 那一行运行后把回调登记为稍后触发,此刻它什么都不打印。for 循环是同步的,所以现在就运行到底,接着 console.log("loop done, total = 3") 打印。只有当整个脚本结束、栈空了之后,被推迟的回调才运行,打印 timer done。输出是 startloop done, total = 3timer done:定时器等完了所有同步工作,和运行至完成承诺的一样。

你来试试(填空示例)

在运行之前预测这段代码的输出。把顺序填出来:

答案:顺序是 hibyedeferred。走一遍:setTimeout 那一行把回调登记为稍后运行,然后继续。greet() 是同步调用:压栈、打印 hi、弹出。console.log("bye") 是同步的,打印 bye。脚本至此结束、栈已空,被推迟的回调终于运行,打印 deferredsetTimeout 在源码里排第二,却最后才打印,因为被推迟的工作永远要等同步工作先做完。

小结 + 下节课预告

JavaScript 只有一个线程和一个调用栈,并遵守运行至完成:每一行同步代码都先执行完,任何被推迟的回调才会运行。setTimeout(fn, 0) 不会现在运行它的回调,而是把它安排到稍后,所以无论你写在哪里,回调总是在同步代码之后打印。但我们略过了一件事:被推迟的回调在「登记」和「运行」之间到底在哪里等?又是谁决定什么时候运行它?那个等候室叫任务队列,从里面取任务的机制叫事件循环,这就是第 2 课的主题。

Footnotes

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

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

练习

01

在你自己的编辑器里创建文件 order.mjs,把讲解里的 A / B / C 代码片段粘进去。用 node order.mjs 运行(或粘进浏览器 DevTools 控制台)。先确认打印顺序,再继续往下读。

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

在 console.log("C") 之后加第二个 setTimeout(() => console.log("D"), 0)。先预测完整顺序,再运行验证。

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