JavaScript 老大的叠盘子游戏
执行大厅刚换上一摞新盘子,递归函数就抱着盘子冲了进来:“再加一层!”盘子越堆越高,事件大厅在门外急得直拍门——最上面那只到底什么时候才能拿走?
新玩具
本文承接上一篇:《关不掉的房间》。
执行大厅打烊失败后的第二天,JavaScript 老大顶着两个黑眼圈来上班了。
“以后谁再把没交回来的钥匙藏在闭包里,谁就负责给清理员解释!”
老大一边抱怨,一边拆开门口的新包裹。纸箱里没有新咖啡机,也没有更快的解释器,只有一摞圆盘和一根几乎顶到屋顶的立柱。
“这是什么?”
管事小弟翻了翻说明书:“调用栈。每调用一个函数,就给它准备一只工作盘;后来的盘子放在上面,只有最上面的盘子干完,才能把它取下来。”
“不就是叠盘子嘛,这还需要说明书?”
JavaScript 老大随手把说明书扔到一旁。执行大厅的白幕立刻亮了起来:
function checkTemperature() {
const temperature = 80;
return `${temperature}℃`;
}
function heatSoup() {
const result = checkTemperature();
return `汤已加热到 ${result}`;
}
function prepareDinner() {
const message = heatSoup();
console.log(message);
}
prepareDinner();
“来得正好,开工!”
第一只盘子
整段脚本开始执行时,管事小弟先在立柱底部放了一只大盘子。
“这是谁的?” 老大问。
“这是全局代码的工作盘。现在执行到 prepareDinner(),得在上面再放一只。”
一只写着 prepareDinner 的盘子“咔嗒”一声落了下来。盘子里摆着函数本次执行要用的东西:执行到了哪里、参数是什么、局部绑定如何查找,以及做完后该回到哪儿。
“这只盘子就叫栈帧。” 管事小弟解释道,“每次调用函数,都会创建对应的执行上下文,并把它压入调用栈。工作结束后,再从栈顶弹出。”
“知道了,知道了。” 老大盯着 prepareDinner 的代码,“先执行这一句:”
const message = heatSoup();
“等一下!” prepareDinner 盘子突然喊道,“要得到 message,得先把 heatSoup() 的结果带回来。”
老大只好暂停 prepareDinner,又拿来一只写着 heatSoup 的盘子,压在它上面。
调用栈变成了这样:
栈顶
┌──────────────────┐
│ heatSoup │ ← 正在执行
├──────────────────┤
│ prepareDinner │ ← 等待返回值
├──────────────────┤
│ 全局代码 │
└──────────────────┘
栈底
“现在可以继续了吧?”
heatSoup 看了看自己的代码,摇摇头:
const result = checkTemperature();
“我也得等 checkTemperature()。”
“怎么一个比一个会使唤人!”
老大嘴上抱怨,手里还是多拿了一只盘子。
只能拿最上面
checkTemperature 的盘子刚落稳,立刻占据了栈顶。
栈顶
┌──────────────────┐
│ checkTemperature │ ← 正在执行
├──────────────────┤
│ heatSoup │ ← 暂停等待
├──────────────────┤
│ prepareDinner │ ← 暂停等待
├──────────────────┤
│ 全局代码 │
└──────────────────┘
栈底
“这回总没人要继续叫函数了吧?”
checkTemperature 创建了局部绑定 temperature,算出字符串 "80℃",把结果递给老大:
return `${temperature}℃`;
“任务完成,取盘!”
最上面的盘子被弹出,下面的 heatSoup 重新露了出来。老大把 "80℃" 交给它,result 得到了值,函数继续从刚才暂停的位置往下执行。
不一会儿,heatSoup 返回 "汤已加热到 80℃",它的盘子也被取走了。
接着轮到 prepareDinner。它拿到 message,调用 console.log(message)。等 console.log 的工作盘放上去又取下来,prepareDinner 也完成了任务。
最后,全局代码执行结束,最底下的盘子也离开了立柱。
“原来如此!” 老大拍了拍手,“后调用的函数先完成,先调用的函数后完成。这就是后进先出。”
“对。压入叫 push,弹出叫 pop。而且老大一次只能处理栈顶的工作,不能隔着 checkTemperature,先把下面的 prepareDinner 做完。”
“难怪每个函数都老老实实等着。”
变量住在盘子里吗?
老大捡起刚才的 checkTemperature 盘子,左看右看。
“奇怪,temperature 去哪儿了?不是说盘子里放着局部变量吗?”
管事小弟赶紧把被扔掉的说明书捡了回来。
“盘子只是为了帮助理解。更准确地说,调用栈保存函数调用的执行状态;变量名如何找到对应的值,还要看执行上下文关联的词法环境。至于具体数据放在机器栈、堆还是寄存器里,是 JavaScript 引擎自己的实现和优化,不能简单地说所有局部变量都塞在调用栈里。”
“那上一篇里被闭包保住的 count 呢?”
“只要闭包仍能访问对应的词法环境,即使创建它的函数已经返回、栈帧已经弹出,那份环境也不能仅仅因为函数离栈就被清理。调用栈负责的是‘现在执行到哪儿’,可不等于全部内存。”
老大若有所思地点点头,把盘子放了回去。
门外的敲门声
白幕又出现了一段代码:
function keepBusy() {
const start = Date.now();
while (Date.now() - start < 3000) {
// 一直忙三秒
}
}
keepBusy();
“一个函数而已,三秒后就结束。”
keepBusy 的盘子被压入栈顶。JavaScript 老大随即在 while 循环里飞快地看时间、再看时间、继续看时间,根本腾不出手做别的事。
“咚咚咚!”
执行大厅的门突然被敲得震天响。
“老大!任务队列里来了点击事件!” 事件大厅的小弟在门外喊道。
“先放着!”
“还有一个定时器到点了!”
“排队!”
“页面上的按钮按不动了,用户开始疯狂点鼠标啦!”
“都——排——队——”
三秒终于过去,keepBusy 的盘子被取走。等当前这段同步代码执行完、调用栈回到可以领取新任务的状态,JavaScript 老大才有机会去事件大厅处理等待中的工作。
老大扶着立柱喘气:“原来同步代码不肯结束,后面的任务就只能干等。”
“没错。浏览器不只有一个线程,但执行页面 JavaScript 的主线程同一时刻只能沿着当前调用栈往下工作。长时间运行的同步代码会占住它,让事件回调、渲染等工作迟迟得不到机会,页面看起来就像卡住了。”
“下次谁再写这种空转循环,就让谁来听门外的投诉!”
自己叫自己
午后,执行大厅总算恢复了平静。
一个名叫 countDown 的函数抱着盘子走了进来:
function countDown(number) {
if (number === 0) {
return;
}
console.log(number);
countDown(number - 1);
}
countDown(3);
“我会递归。” countDown 得意地说,“执行过程中还会再次调用自己。”
“自己叫自己?那岂不是只需要一只盘子?”
“当然不是!” 管事小弟连忙拦住老大,“每次函数调用都是一次独立的执行,都要创建新的栈帧。参数也各有各的值。”
第一只 countDown 盘子带着参数 3 进入调用栈,又叫来了参数为 2 的 countDown。接着是 1,最后是 0。
栈顶
┌──────────────────┐
│ countDown(0) │
├──────────────────┤
│ countDown(1) │
├──────────────────┤
│ countDown(2) │
├──────────────────┤
│ countDown(3) │
├──────────────────┤
│ 全局代码 │
└──────────────────┘
栈底
参数为 0 的那一层碰到了终止条件,立刻返回。盘子开始一只接一只地从栈顶取下,直到最初那次 countDown(3) 也执行完成。
“递归也没什么可怕的嘛。” 老大得意地叉起腰,“只要知道什么时候停下来就行。”
堆到屋顶
角落里,一个没听清后半句话的函数突然冲了出来。
function addPlate() {
addPlate();
}
addPlate();
“再加一只!” addPlate 喊道。
老大放上一只盘子。
“再加一只!”
老大又放上一只。
“再加一只!”
“等等,你什么时候返回?”
“再加一只!”
盘子很快越过老大的头顶,朝执行大厅的屋顶逼近。每一层 addPlate 都在等待下一层返回,可代码里既没有终止条件,也没有任何一层愿意先结束。
“停!别加了!” 管事小弟抱住立柱,“调用栈的空间是有限的!”
“再加一只!”
“轰——”
红色警报瞬间照亮整个大厅,白幕上只剩下一行错误:
RangeError: Maximum call stack size exceeded
清理员戴着安全帽冲了进来,开始收拾满地的盘子。
“在 Chrome 等使用 V8 的环境里,通常会看到这个错误。” 管事小弟灰头土脸地说,“不同引擎的错误名称和文字可能不完全一样,但原因相同:嵌套调用太深,调用栈装不下了。”
老大从盘子堆里探出脑袋:“所以递归必须保证能走到终止条件,数据规模特别大时,还得考虑改成循环或其他不会不断加深调用栈的写法。”
“终于把说明书看进去了。”
插队者
忙到傍晚,执行大厅才恢复原样。
JavaScript 老大擦干净最后一只盘子,正准备下班,门外又传来争吵声。
“明明是我先来的!”
“工作制度写得很清楚,当前任务结束后要先处理微任务!”
“插队还有理了?”
老大推开门,只见任务队列和微任务队列已经在事件大厅吵成一团。一个 Promise 反应任务从旁边的小门探出脑袋,冲老大挥了挥手。
“看来明天也清静不了。”