关不掉的房间
函数早已执行结束,临时工作室却死活关不掉。清理员刚碰门把手,警报立刻响起:“该环境仍可访问,禁止清理!”到底哪把钥匙还没交回来?
打烊
本文承接上一篇:《闹心的变量》。
执行大厅终于安静了下来。
JavaScript 老大放下手里的类型转换器,端起早已凉透的咖啡,一饮而尽。
“今天就到这儿吧,清场!”
话音刚落,几个内存清理员推着小车走了进来。他们负责检查已经完成使命的变量,把不再使用的东西送出变量大厅。
“老大,白幕上还有最后一段代码。” 一名清理员提醒道。
“差点忘了。那就干完这一票再下班!”
老大按下按钮,白幕上浮现出一段代码:
function createCounter() {
let count = 0;
return function counter() {
count += 1;
return count;
};
}
let counterA = createCounter();
console.log(counterA());
console.log(counterA());
“一个函数里又住着一个函数。” 老大揉了揉眼睛,“看起来也没什么特别的,开工!”
新房间
JavaScript 老大先执行了这一句:
let counterA = createCounter();
createCounter 被唤醒后,执行大厅里出现了一间临时工作室。门牌上写着:
createCounter 的词法环境
“新房间已经准备好了!” 管房间的小弟喊道。
老大走进房间,执行函数里的第一句代码:
let count = 0;
一个名叫 count 的变量随即出现。
“你好,我是 count,现在的值是 0。” 它乖巧地说道。
“就在这里待着,等会儿可能要用到你。” 老大给它安排好位置,又看向下一句。
return function counter() {
count += 1;
return count;
};
一个名叫 counter 的函数走了出来。
“你就是这次要返回的结果?” 老大问道。
“是我。”
“那跟我出去吧。createCounter 马上就执行完了,这间临时工作室也该关门了。”
counter 却没有动。它转身从墙上取下一把钥匙,小心地装进口袋。
“你拿人家钥匙干什么?” 老大不解地问道。
“我是诞生在这间房里的,里面的环境以后可能还有用。”
“函数都要执行完了,你还回来干什么?”
“代码里写得很清楚,我以后还要找 count。”
counter 指了指自己保存的代码:
count += 1;
“行吧,先带上。反正等会儿清理房间时,他们自然会把没用的东西收走。” 老大没有多想,带着 counter 离开了房间。
离开后,counter 被交给变量 counterA 保管。createCounter 的这次调用也正式结束了。
拒绝关门
“轮到我们了!”
内存清理员推着小车来到临时工作室门前。按照往常的经验,函数执行结束后,工作现场不再被需要,他们便可以清理其中的东西。
“里面还有一个 count。” 一名清理员透过窗户看了看,“函数已经执行完成,它应该没用了,带走!”
两人刚准备开门,警报突然响了起来:
该环境仍可访问,禁止清理!
“怎么回事?” 清理员反复核对房间号码,“createCounter 明明已经执行完了。”
“执行结束,不代表它创建的词法环境一定没人使用。” 管房间的小弟赶来解释道,“刚刚出去的 counter 还拿着这里的钥匙。”
“一个函数为什么会有这间房的钥匙?”
“函数在创建时,会记住自己诞生时所在的词法环境。你可以把这份联系想成函数内部保存的 [[Environment]]。那把钥匙就是它与外部环境之间的联系。”
“也就是说,只要 counter 还活着,就能顺着这把钥匙回来找到 count?”
“对。”
两名清理员对视一眼,只好在门上贴了一张纸:
暂不清理
“真是奇怪。” 其中一人嘟囔道,“执行都结束了,房间却关不掉。”
回来取数
另一边,JavaScript 老大继续执行:
console.log(counterA());
变量 counterA 立即喊来了它保管的 counter 函数。
“又见面了。” 老大说道,“开始执行你的代码吧。”
counter 看了一眼自己的第一句代码:
count += 1;
“我这里没有 count。” 它翻遍了自己的工作区,说道。
“没有就报错?” 老大问。
“先别急。按规矩,我要沿着作用域链向外找。”
counter 从口袋里掏出钥匙,回到了自己诞生的房间。
“找到了!这里有一个 count,值是 0。”
“它又不是你这次调用时创建的变量,你也能用?”
“当然。我查找变量时,看的是自己在代码中定义的位置,而不是这次从哪里被调用。这就是词法作用域。”
counter 将 count 的值增加到 1,然后把结果交给 console.log。屏幕上随即出现:
1
第一次调用结束后,清理员又来到门口。
“这回能关了吧?”
警报再次响起:
该环境仍可访问,禁止清理!
“怎么还不能关?”
“counterA 还保存着 counter,counter 也还拿着钥匙。” 管房间的小弟摊了摊手。
清理员叹了口气,又默默离开。
不久,第二次调用开始。counter 再次回到房间,找到上次留下的 count,把它从 1 增加到了 2。
2
“原来如此。” JavaScript 老大摸了摸下巴,“两次调用使用的是同一个 count,所以它能记住上一次的结果。”
你从哪里来
JavaScript 老大还是有些疑惑,于是在白幕上补了一段代码:
function runSomewhereElse(fn) {
let count = 100;
return fn();
}
console.log(runSomewhereElse(counterA));
“这一次,我把 counter 放到 runSomewhereElse 里面调用。那里也有一个 count,值还是 100。” 老大笑道,“它应该会使用离调用位置更近的这个吧?”
“不会。” counter 坚定地摇了摇头。
“你还没过去,怎么就知道?”
“我在哪里查找变量,早在我被创建时就决定了,不会因为被带到别处调用而改变。”
说完,counter 进入 runSomewhereElse。面对值为 100 的 count,它连招呼都没打,径直掏出原来的钥匙,回到 createCounter 的词法环境中。
那里的 count 仍然是 2。
counter 将它增加到 3,屏幕上出现了新的结果:
3
runSomewhereElse 里的 count 站在一旁,满脸不解。
“它为什么不选我?我明明离得更近!”
“你只是离它的调用位置近。” JavaScript 老大解释道,“但你不在它定义时形成的作用域链上。变量查找不看运行时谁站得近,而是看代码写在哪里。”
闭包
“所以,这就是传说中的闭包?” 一名清理员问道。
“可以这么说。” JavaScript 老大拿出了说明书:
函数会连同它创建时所在的词法环境一起形成闭包。即使外层函数已经执行完毕,内部函数仍然可以访问那个环境中的变量。
“等等。” counter 打断了他,“别说得好像必须等外层函数结束后,闭包才突然出现。”
“不对吗?”
“我在被创建时,就已经记住了外部词法环境。外层函数结束后仍然能访问变量,只是闭包最显眼的表现,不是闭包诞生的时刻。”
“有道理。” 老大点点头,在说明书旁补充了几句:
- JavaScript 使用词法作用域,函数能访问哪些外部变量,取决于函数定义的位置。
- 内部函数会保存与外部词法环境之间的联系。
- 外层函数执行结束后,只要这份联系仍然可达,相关环境就不能被整体清理。
- 闭包保存的是变量绑定所在的环境,不是创建那一刻对值拍下的一张照片。
“最后一句是什么意思?” 清理员问道。
“如果保存的只是 count 当时的值,那 counter 每次拿到的都应该是 0。实际上,它每次找到的是同一个变量,所以才会依次得到 1、2、3。”
两把钥匙
这时,白幕上又出现了一句:
const counterB = createCounter();
createCounter 再次被唤醒。执行大厅里随即出现了另一间临时工作室,里面也住着一个名叫 count、值为 0 的变量。
“怎么又来一个 count?” 清理员问道。
“每次调用 createCounter,都会建立新的词法环境。” 老大回答,“名字可以相同,但它们住在不同的房间里。”
不一会儿,新的 counter 带着新房间的钥匙走了出来,并被交给 counterB 保管。
老大依次执行:
console.log(counterA());
console.log(counterB());
第一个 counter 打开旧房间,把里面的 count 从 3 增加到了 4。
第二个 counter 打开新房间,把另一个 count 从 0 增加到了 1。
屏幕上出现:
4
1
“它们执行相同的代码,却各自保存了一份独立状态。” 老大说道,“这也是闭包常见的用途:隐藏状态,并只通过指定的函数访问它。”
两个 count 隔着房间的窗户互相看了一眼。虽然有着相同的名字,它们却知道彼此并不是同一个变量。
最后一把钥匙
“那这些房间岂不是永远关不掉了?” 清理员有些担忧。
“当然不是。” 老大在白幕上写下:
counterA = null;
当 counterA 不再保存原来的 counter,其他地方也没有引用它时,函数和那把钥匙终于变得不可到达。
清理员再次来到旧房间门口。这一次,警报没有响起。
“可以清理了!”
房间里的 count 朝窗外挥了挥手,随后和整个环境一起进入等待回收的区域。
“只是可以回收,不代表一定会在这一刻立刻回收。” 管房间的小弟提醒道,“具体什么时候行动,由垃圾回收机制决定。”
“明白。我们的判断标准不是外层函数有没有执行完,而是这些东西是否仍然可达。”
“对。如果一个事件监听器、定时器或其他长期存在的对象一直引用着闭包,那把钥匙就可能被保留很久。闭包本身不是内存泄漏,但不再需要的引用若迟迟没有解除,房间就会一直占着空间。”
清理员关上房门,郑重地贴上封条。
“终于关掉了。”
收工
执行大厅真正安静了下来。
JavaScript 老大关掉白幕,准备离开。经过大厅门口时,他发现角落里还摞着一叠写有函数名字的工作牌。
“这些又是什么?怎么一层压着一层?”
管事的小弟回答道:“这是每次函数调用留下的工作记录。最后放上去的,要最先拿下来。”
“今天太晚了,明天再收拾。”
老大关上灯,执行大厅陷入黑暗。只有那叠工作牌还立在角落里,摇摇晃晃,仿佛随时会倒下来。