关于JavaScript中闭包与内存泄漏
前段时间开例会的时候提到了一个问题,就是在React中,由于每一次render都会产生一个新的作用域,而useCallback返回的函数可能会引用上一次render的作用域,最终导致内存泄漏,当时提到了一个点
当作用域中有一个变量被引用的时候,那么整个作用域都会保留(此处的引用不考虑循环引用,而是指可以通过代码访问到的内容)
例子类似于下图:
import { useState, useCallback } from 'react';
class BigObject {
public readonly data = new Uint8Array(1024 * 1024 * 10);
}
export const App = () => {
const [countA, setCountA] = useState(0);
const [countB, setCountB] = useState(0);
const bigData = new BigObject(); // 10MB of data
const handleClickA = useCallback(() => {
setCountA(countA + 1);
}, [countA]);
const handleClickB = useCallback(() => {
setCountB(countB + 1);
}, [countB]);
// This only exists to demonstrate the problem
const handleClickBoth = () => {
handleClickA();
handleClickB();
console.log(bigData.data.length);
};
return (
<div>
<button onClick={handleClickA}>Increment A</button>
<button onClick={handleClickB}>Increment B</button>
<button onClick={handleClickBoth}>Increment Both</button>
<p>
A: {countA}, B: {countB}
</p>
</div>
);
};
当交替点击Increment A和Increment B时,handleClickA和handleClickB会分别重新得到一个新的函数实例,并且函数实例的闭包会引用当前render函数执行产生的作用域,而另一个函数则因为没有得到新的函数实例,而旧的函数因为引用了上一次render函数执行而产生的作用域,所以就形成了一个类似于左脚踩右脚螺旋升天的现象
其作用域链的关系如上图,而每一次render都生成了一个新的bigData并且保存在各自的作用域,所以即便当前作用域只有一个变量被闭包引用了,也会导致整个作用域无法销毁。也就是回到了文章开头我们提到的
当作用域中有一个变量被引用的时候,那么整个作用域都不会被销毁
但是我今天心血来潮,决定自己试一试这个案例,我没有通过react实现,而是基于上述观点,用js实现了一段类似的代码
function generateBuffer () {
const buffer = new ArrayBuffer(1024 * 1024 * 100);
let i = 0;
return function() {
console.log(i);
};
}
var a = generateBuffer();
理想中的情况应该是:generateBuffer返回的函数被全局变量a所接收,并且该函数中引用了generateBuffer函数中作用域的变量i按照上述的逻辑,此时buffer也应该保留而不被销毁,但我们通过在console.log添加断点,并且通过a()的方式调用函数,可以发现似乎并没有保留buffer
这貌似和我们一开始提到的点不一样,变量i和buffer不是在同一个作用域吗,为什么我们的控制台只显示了i而没有显示buffer,并且在断点的时候,我们在控制台中输出buffer也显示undefined(冷知识:在断点期间,控制台相当于直接在当前作用域和上下文中执行代码)
“是因为这几周浏览器有更新,对内存回收这块做了优化吗?对作用域进行了垃圾回收?”
但在我的求助下,貌似并不是这个原因。
终于,在我多番了解下,看到了另一个例子,在这里我将上述例子更改一下:
function generateBuffer () {
const buffer = new ArrayBuffer(1024 * 1024 * 100);
function outPutBuffer () {
console.log(buffer);
}
let i = 0;
return function() {
console.log(i);
};
}
var a = generateBuffer();
可以看到,上述例子在执行完后,generateBuffer的作用域中能被访问到的变量只有i,按照上一个例子的结果,我们在11行处打上断点,检查闭包的作用域的时候,应该依然只有i但是实际上是如此吗:
貌似并不如我们所愿,这到底是为什么,怎么这一次又没有清除没用到的变量(buffer)了
于是我顺着文章往下看,看到了对这个现象的解释的话:
So even though there’s no way for any code to ever refer to
bufferagain, it never gets garbage collected! Why? Well, the typical way that closures are implemented is that every function object has a link to a dictionary-style object representing its lexical scope. If both functions defined insidegenerateBufferactually usedbuffer, it would be important that they both get the same object, even ifbuffergets assigned to over and over, so both functions share the same lexical environment. Now, Chrome’s V8 JavaScript engine is apparently smart enough to keep variables out of the lexical environment if they aren’t used by any closures: that’s why the first example doesn’t leak.因此,即使任何代码都无法再次引用
buffer,它也永远不会被垃圾收集!为什么?实现闭包的典型方式是,每个函数对象都有一个指向表示其词法作用域的字典样式对象的链接。如果在generateBuffer内部定义的两个函数实际上都使用了buffer,那么重要的是它们都获得相同的对象,即使反复赋值buffer,这样两个函数共享相同的词法环境。现在,Chrome的V8 JavaScript引擎显然足够聪明,如果变量不被任何闭包使用,它可以将其排除在词法环境之外:这就是为什么第一个例子不会泄漏。(来自有道翻译)
上述提到的第一个例子(the first example)应该是指我们用js而不是React实现的例子
第一次看的时候我是真没看懂什么意思,什么叫做获得相同的对象,怎么又来一个对象了。
于是我回到《JavaScript高级程序设计》中查看了一些定义:
作用域
JavaScript中存在三个作用域:全局作用域、函数级作用域、块级作用域。每一个作用域都有一个与其关联的变量对象,这些变量对象之间的关系称为作用域链,它决定了各个作用域执行的顺序,当前作用域可以访问到其上级作用域的方法和变量。 var是函数作用域,let和const是块级作用域,var声明的变量在整个函数中都可使用,而let和const声明的变量只在其声明的花括号{}内有效。
闭包
闭包指的是那些引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。 闭包的作用域链包含它引用的变量所在函数的作用域。
这里有两个点要注意:每一个作用域都有一个与之关联的变量对象以及闭包是一个函数,并且是引用了另一个函数作用域变量的函数,并且它的作用域链包含它引用的变量所在函数的作用域这一下豁然开朗,也就是说我们在控制台上看到的作用域的信息,实际上就是第一个点的表现,而第二个点也解释了为什么我们明明只引用了部分的变量,但是其它没用到的变量也在作用域链中出现了。
所以闭包并没有直接引用变量对应的作用域,而是引用了变量所在的作用域关联的那一个变量对象,而在上一个例子中,outPutBuffer引用了generateBuffer中的变量buffer,此时形成了第一个闭包,然后返回的匿名函数中引用了变量i,此时生成了第二个闭包,将引用了generateBuffer中作用域的变量进行一个并集处理,就可以得到一个对象{buffer, i}这就是作用域的关联的变量对象了
总结:
- 作用域链中的每一级并非我们常规理解的作用域,而是底层中作用域关联的一个对象
- 这个对象实际上是由该函数所有的闭包进行并集计算得到的
- 只要这些闭包还有任意一个可以被访问到,即便这个闭包只引用了作用域的关联对象中的某个属性,也会导致整个关联对象不被销毁
参考资料: