四种常见的 JavaScript 内存泄漏
1: 全局变量
JavaScript 以一种有趣的方式来处理未声明变量:当引用一个未声明的变量,会在全局对象上创建一个新的变量。在浏览器中,全局对象是 window,这意味着如下代码:
function foo(arg) {
bar = "some text";
}
等同于:
function foo(arg) {
window.bar = "some text";
}
变量 bar 本意是只能在 foo 函数中被引用。但是如果你没有用 var 来声明变量,那么将会创建一个多余的全局变量。在上面的例子中,并不会造成大的事故。但你可以很自然地想象一个更具破坏性的场景。
你也可以使用 this 关键字不经意地创建一个全局变量。
function foo() {
this.var1 = "potential accidental global";
}
// 调用 foo 函数自身,this 会指向全局对象(window)而不是未定义
你可以通过在 JavaScript 文件的顶部添加 'use strict' 来避免以上的所有问题,'use strict' 会切换到更加严格的 JavaScript 解析模式,这样就可以防止创建意外的全局变量。
意外的全局变量的确是个问题,比代码未充满显式全局变量更为频繁,根据定义这些全局变量是不会被内存垃圾回收器所收集的。你需要特别注意的是使用全局变量来临时存储和处理大量的信息。只有在必要的时候使用全局变量来存储数据,记得一旦你不再使用的时候,把它赋值为 null 或者对其再分配。
2:定时器及被遗忘的回调函数
因为经常在 JavaScript 中使用 setInterval,所以让我们以它为例。
框架中提供了观察者和接受回调的其它指令,一旦会确保当他们的实例不可获得的时候,所有对回调的引用都会变成不可获得。很容易找到如下代码:
var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if (renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); // 这将会每隔大约 5 秒钟执行一次
以上代码片段展示了使用定时器来引用不再需要的节点或数据的后果。
renderer 对象会在某些时候被替换或移除,这样就会导致由定时处理程序封装的代码变得冗余。当这种情况发生的时候,不管是定时处理器句柄还是它的依赖都不会被垃圾回收,这是由于需要先停止定时器(记住,定时器仍然处于激活状态)。这可以归结为保存和处理数据加载的 serverData 变量也不会被垃圾回收。
当使用观察者的时候,你需要确保一旦你不再需要它们的时候,显式地移除它们(不再需要观察者或者对象变得不可获得)。
幸运的是,大多数现代浏览器都会替你进行处理:当被观察者对象变得不可获得时,即使你忘记移除事件监听函数,浏览器也会自动回收观察者处理程序。以前,一些老掉牙的浏览器处理不了这些情况(如老旧的 IE6)。
那么,最佳实践是当对象被废弃的时候,移除观察者处理程序。查看如下例子:
var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
counter++;
element.innerHTML = 'text' + counter;
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 现在当元素超出范围
// 即使在不能很好处理循环引用的浏览器中也会回收元素和 onClick 事件
在让一个 DOM 节点不可获得之前,你不再需要调用 removeEventListener,因为现代浏览器支持用内存垃圾回收器来检测这些循环引用并进行适当地处理。
如果你使用 jQuery API(其它的库和框架也支持的 API),你可以在废弃节点之前移除事件监听函数。jQuery 也会确保即使在老旧的浏览器之中,也不会产生内存泄漏。
3:闭包
闭包是 JavaScript 的一个重要功能:嵌套函数可以访问外部(封闭)函数的变量。鉴于 JavaScript 运行时的实现细节,以下方法可能会造成内存泄漏:
var theThing = null
var replaceThing = function () {
var originalThing = theThing;
var unused = function () {
if (originalThing) // 引用 originalThing
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log("message");
}
};
};
setInterval(replaceThing, 1000);
当调用 replaceThing 的时候,theThing 对象由一个大数组和新的闭包(someMethod)所组成。而 originalThing 由 unused 变量创建的闭包所引用(即引用执行 replaceThing 函数前的 theThing 变量)。需要记住的是当一旦为同一个父作用域中的闭包创建闭包作用域的时候,该闭包作用域是共享的。
在这样的情况下,闭包 someMethod 和 unused 共享相同的作用域。unused 引用了 origintalThing。即使 unused 永不使用,也可以在 replaceThing 的作用域外使用 someMethod 函数。然后由于 someMethod 和 unused 共享相同的闭包作用域,unused 变量引用 originalThing 会强迫 unused 保持激活状态(两个闭包共享作用域)。这会阻止内存垃圾回收。
在以上例子中,闭包 someMethod 和 unused 共享作用域,而 unused 引用 origintalThing。可以在 replaceThing 作用域外通过 theThing 使用 someMethod,即使 unused 从未被使用。事实上,由于 someMethod 和 unused 共享闭包作用域,unused 引用 origintalThing 要求 unused 保持激活状态。
所有的这些行为会导致内存泄漏。当你不断地运行如上代码片段,你将会发现飙升的内存使用率。当内存垃圾回收器运行的时候,这些内存使用率不会下降。这里会创建出一份闭包链表(当前情况下,其根变量是 theThing),每个闭包作用域都间接引用了大数组。
4:移除 DOM 引用
有时候,开发者会在数据结构中存储 DOM 节点。
假设你想要快速更新几行表格内容。如果你在一个字典或者数组中保存对每个表格行的引用,这将会造成重复引用相同的 DOM 元素:一个在 DOM 树中而另一个在字典中。如果你想要释放对这些表格行的引用,你需要记得让这些引用变成不可获得。
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image')
};
function doStuff() {
elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
// image 元素是 body 元素的直系后代元素
document.body.removeChild(document.getElementById('image'));
// 这时,我们仍然在 elements 全局对象中引用了 #button 元素
// 换句话说,按钮元素仍然在内存中且不能够被垃圾回收器收集
}
你还需要额外考虑的情况是引用 DOM 树中的内节点或者叶节点。如果你在代码中保存着对一个单元格的引用,这时候当你决定从 DOM 中移除表格,却仍然会保持对该单元格的引用,这就会导致大量的内存泄漏。你可以认为内存垃圾回收器将会释放除了该单元格以外的内存。而这还没完。因为单元格是表格的一个后代元素而后代元素保存着对其父节点的引用,对一个单元格的引用会导致无法释放整个表格所占用的内存。
其他代码优化建议
1、尽可能避免创建对象,非必要情况下避免调用会创建对象的方法,如 Array.slice、Array.map、Array.filter、字符串相加等。
2、不再使用的对象,手动赋为 null,可避免循环引用等问题。
3、使用 Weakmap
4、生产环境勿用 console.log 大对象,包括 DOM、大数组、ImageData、ArrayBuffer 等。因为 console.log 的对象不会被垃圾回收。
5、合理设计页面,按需创建对象/渲染页面/加载图片等。
避免如下问题:
为了省事儿,一次性请求全部数据。
为了省事儿,一次性渲染全部数据,再做隐藏。
为了省事儿,一次性加载/渲染全部图片。
使用重复 DOM 等,如重复使用同一个弹窗而非创建多个。
如 Vue-Element 框架中,PopOver/Tooltip 等组件用于表格内时会创建 m * n 个实例,可优化为只创建一个实例,动态设置位置及数据。
6、ImageData 对象是 JS 内存杀手,避免重复创建 ImageData 对象。
7、重复使用 ArrayBuffer。
8、压缩图片、按需加载图片、按需渲染图片,使用恰当的图片尺寸、图片格式,如 WebP 格式。
图片处理优化
假设渲染一张 100KB 大小,300x500 的透明图片,粗略的可分为三个过程:
1、加载图片
加载图片二进制格式到内存中并缓存,此时消耗了100KB 内存 & 100KB 缓存。
2、解码图片
将二进制格式解码为像素格式,此时占用宽 * 高 * 24(透明为32位)比特大小的内存,即 300 * 500 * 32,约等于 585 KB,这里约定名为像素格式内存。个人猜测此时浏览器会回收加载图片时创建的 100KB 内存。
3、渲染图片
通过 CPU 或 GPU 渲染图片,若为 GPU 渲染,则还需上传到 GPU 显存,该过程较为耗时,由图片尺寸 / 显存位宽决定,图片尺寸越大,上传时间越慢,占用显存越多。
其中,较旧的浏览器如Firefox回收像素内存时机较晚,若渲染了大量图片时会内存占用过高。
PS:浏览器会复用同一份图片二进制内存及像素格式内存,浏览器渲染图片会按以下顺序去获取数据:
显存 >> 像素格式内存 >> 二进制内存 >> 缓存 >> 从服务器获取。我们需控制和优化的是二进制内存及像素内存的大小及回收。
总结一下,浏览器渲染图片时所消耗内存由图片文件大小内存、宽高、透明度等所决定,故建议:
1、使用 CSS3、SVG、IconFont、Canvas 替代图片。展示大量图片的页面,建议使用 Canvas 渲染而非直接使用img标签。
2、适当压缩图片,可减小带宽消耗及图片内存占用。
3、使用恰当的图片尺寸,即响应式图片,为不同终端输出不同尺寸图片,勿使用原图缩小代替 ICON 等,比如一些图片服务如 OSS。
4、使用恰当的图片格式,如使用WebP格式等。详细图片格式对比,使用场景等建议查看web前端图片极限优化策略。
5、按需加载及按需渲染图片。
6、预加载图片时,切记要将 img 对象赋为 null,否则会导致图片内存无法释放。
7、当实际渲染图片时,浏览器会从缓存中再次读取。
8、将离屏 img 对象赋为 null,src 赋为 null,督促浏览器及时回收内存及像素格式内存。
9、将非可视区域图片移除,需要时再次渲染。和按需渲染结合时实现很简单,切换 src 与 v-src 即可。
---@Alexander Zlatkov