第 1 课:调用栈与运行至完成
本课目标:
- 把调用栈描述为 JavaScript 此刻正在运行的函数列表。
- 说出运行至完成规则:所有同步代码先执行完,任何被推迟的回调才会运行。
- 预测
setTimeout(fn, 0)的回调会在它后面的同步行之后打印。先修条件:你会写 JavaScript 函数,会调用
console.log和setTimeout| 下一篇 02 >>
写在中间的那行,为什么最后才打印?
正是这样的时刻让人开始不信任 JavaScript。你从上到下写了三行代码,中间那行是 setTimeout(fn, 0),它的消息却排在最后打印,哪怕你要求的是零毫秒。感觉像是语言无视了你的顺序。它没有。底下有一条精确的规则,而且它是这门课其余一切的地基:JavaScript 只有一个线程,它先把所有普通同步代码执行完,任何「被推迟」的东西都要排队等。抓住这一条规则,剩下的顺序谜题就都能读懂了。
讲解
先从调用栈说起。它就是 JavaScript 正在运行中的函数列表,按后进先出堆叠:调用一个函数就把一帧压进栈顶,返回就把它弹出1。运行一个文件时,整个脚本是最底下的那一帧,你调用的每个 console.log(...) 都会压栈、运行,然后在下一行之前弹出。这样的栈只有一个,处理它的线程也只有一个:JavaScript 一次只做一件事1。
这个单线程直接引出解释今天这个意外的规则:运行至完成。每个任务都会完整运行结束,其他任务才能开始1。说白了:JavaScript 不会打断你的同步代码去插进一个回调。它先把你的脚本从头到尾跑完,只有在那之后,才去看任何被推迟的东西。
那 setTimeout 到底做了什么?它不会现在就运行它的回调。它把回调交给运行时,等一段延迟之后再运行,而 0 的延迟并不表示「立刻」,而是「在之后的某一轮,等当前工作做完」2。回调被推迟了,于是运行至完成保证每一行同步代码都先于它运行,无论你把 setTimeout 写在文件的哪个位置。
像引擎那样去读这段代码。console.log("A") 是同步的,运行并打印 A。setTimeout 那一行本身也是同步运行的,但它做的只是把箭头函数登记为稍后运行,箭头函数本身还没有运行。console.log("C") 是同步的,打印 C。现在同步脚本结束、栈已经空了,被推迟的回调这才轮到自己,打印 B。顺序是 A、C、B。
注意这里延迟的数值几乎不重要。哪怕是 0,回调也被推迟了,而被推迟的永远排在同步的后面。这就是为什么靠调低超时时间来「让它更快」从来修不好顺序 bug:回调从一开始就不可能抢在同步代码前面。
完整示例(跟着做)
你在给一段同步循环计时,前后各有一条打印,中间夹着一条被推迟的打印:
一步步看定时器的打印为什么排在最后。console.log("start") 是同步的,打印 start。setTimeout 那一行运行后把回调登记为稍后触发,此刻它什么都不打印。for 循环是同步的,所以现在就运行到底,接着 console.log("loop done, total = 3") 打印。只有当整个脚本结束、栈空了之后,被推迟的回调才运行,打印 timer done。输出是 start、loop done, total = 3、timer done:定时器等完了所有同步工作,和运行至完成承诺的一样。
你来试试(填空示例)
在运行之前预测这段代码的输出。把顺序填出来:
答案:顺序是 hi、bye、deferred。走一遍:setTimeout 那一行把回调登记为稍后运行,然后继续。greet() 是同步调用:压栈、打印 hi、弹出。console.log("bye") 是同步的,打印 bye。脚本至此结束、栈已空,被推迟的回调终于运行,打印 deferred。setTimeout 在源码里排第二,却最后才打印,因为被推迟的工作永远要等同步工作先做完。
小结 + 下节课预告
JavaScript 只有一个线程和一个调用栈,并遵守运行至完成:每一行同步代码都先执行完,任何被推迟的回调才会运行。setTimeout(fn, 0) 不会现在运行它的回调,而是把它安排到稍后,所以无论你写在哪里,回调总是在同步代码之后打印。但我们略过了一件事:被推迟的回调在「登记」和「运行」之间到底在哪里等?又是谁决定什么时候运行它?那个等候室叫任务队列,从里面取任务的机制叫事件循环,这就是第 2 课的主题。
Footnotes
-
MDN: JavaScript execution model — https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Execution_model ↩ ↩2 ↩3
-
MDN: setTimeout() — https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout ↩