JavaScript 老大的叠盘子游戏

执行大厅刚换上一摞新盘子,递归函数就抱着盘子冲了进来:“再加一层!”盘子越堆越高,事件大厅在门外急得直拍门——最上面那只到底什么时候才能拿走?

分享
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 进入调用栈,又叫来了参数为 2countDown。接着是 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 反应任务从旁边的小门探出脑袋,冲老大挥了挥手。

“看来明天也清静不了。”