相比过去的网页,今天流行的 SPA 需要开发人员更加关注程序中的内存泄漏情况。因为以前的网站在浏览时会不断刷新页面,可是 SPA 网站往往只有少数几个页面,很少完全重新加载。这篇文章主要探讨 JS 代码中容易导致内存泄漏的模式,并给出改进对策。

什么是内存泄漏?

内存泄漏指由于疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并非指内存在物理上的消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制,因而造成了内存的浪费。

JavaScript 是一个有垃圾回收机制的语言,我们不需要手动回收内存。当本应在垃圾回收周期中清理的内存中的对象,通过另一个对象的无意引用从根保持可访问状态时,就会发生内存泄漏,并可能导致性能下降的后果。

内存泄漏通常很难发现和定位。泄漏的 JavaScript 代码从任何层面都不会被认为是无效的,并且浏览器在运行时不会引发任何错误。

检查内存使用情况的最快方法是查看浏览器的 任务管理器(不是操作系统的那个任务管理器)。在 Linux 和 Windows 上按 Shift+Esc 来访问 Chrome 的任务管理器;Firefox 则在地址栏中键入 about:performance。我们能用它查看每个选项卡的 JavaScript 内存占用量。如果发现异常的内存使用量持续增长,就很可能出现了泄漏。

开发工具 提供了更高级的内存管理方法。通过 Chrome 的性能工具,我们可以直观地分析页面在运行时的性能。像下面这种模式就是内存泄漏的典型表现:

js哪些操作会内存泄漏(JavaScript 中内存泄漏的原因以及对策)(1)

除此之外,Chrome 和 Firefox 的开发工具都可以用“内存”工具进一步探索内存使用情况。

JS 代码中常见的几个内存泄漏源

全局变量

全局变量总是从根可用,并且永远不会回收垃圾。在非严格模式下,一些错误会导致变量从本地域泄漏到全局域:

function createGlobalVariables() {
    leaking1 = 'I leak into the global scope'; // assigning value to the undeclared variable
    this.leaking2 = 'I also leak into the global scope'; // 'this' points to the global object
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'

预防措施:使用严格模式("use strict")。

闭包

函数作用域内的变量将在函数退出调用栈后清除,并且如果函数外部没有其他指向它们的引用,则将清理它们。但闭包将保留引用的变量并保持活动状态。

function outer() {
    const potentiallyHugeArray = [];
    return function inner() {
        potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
        console.log('Hello');
    };
};
const sayHello = outer(); // contains definition of the function inner
function repeat(fn, num) {
    for (let i = 0; i < num; i++){
        fn();
    }
}
repeat(sayHello, 10); // each sayHello call pushes another 'Hello' to the potentiallyHugeArray

// now imagine repeat(sayHello, 100000)

在此示例中,从任何一个函数都不会返回 potentialHugeArray,并且无法到达它,但它的大小可以无限增加,具体取决于我们调用函数 inner() 的次数。

预防措施:闭包是肯定会用到的,所以重要的是:

计时器

如果我们在代码中设置了递归计时器(recurring timer),则只要回调可调用,计时器回调中对该对象的引用就将保持活动状态。

在下面的示例中,由于我们没有对 setInterval 的引用,因此它永远不会被清除,并且 data.hugeString 会一直保留在内存中。

function setCallback() {
    const data = {
        counter: 0,
        hugeString: new Array(100000).join('x')
    };
    return function cb() {
        data.counter++; // data object is now part of the callback's scope
        console.log(data.counter);
    }
}
setInterval(setCallback(), 1000); // how do we stop it?

预防措施:尤其是在回调的生命周期不确定或 undefined 的情况下:

function setCallback() {
    // 'unpacking' the data object
    let counter = 0;
    const hugeString = new Array(100000).join('x'); // gets removed when the setCallback returns
    return function cb() {
        counter++; // only counter is part of the callback's scope
        console.log(counter);
    }
}
const timerId = setInterval(setCallback(), 1000); // saving the interval ID
// doing something ...
clearInterval(timerId); // stopping the timer i.e. if button pressed

事件侦听器

添加后,事件侦听器将一直保持有效,直到:

对于某些类型的事件,应该一直保留到用户离开页面为止。但是,有时我们希望事件侦听器执行特定的次数。

const hugeString = new Array(100000).join('x');
document.addEventListener('keyup', function() { // anonymous inline function - can't remove it
    doSomething(hugeString); // hugeString is now forever kept in the callback's scope
});

在上面的示例中,用一个匿名内联函数作为事件侦听器,这意味着无法使用 removeEventListener() 将其删除。同样,该文档也无法删除,因此即使我们只需要触发它一次,它和它域中的内容就都删不掉了。

预防措施:我们应该始终创建指向事件侦听器的引用并将其传递给 removeEventListener(),来注销不再需要的事件侦听器。

function listener() {
    doSomething(hugeString);
}
document.addEventListener('keyup', listener); // named function can be referenced here...
document.removeEventListener('keyup', listener); // ...and here

如果事件侦听器仅执行一次,则 addEventListener() 可以使用第三个参数。假设{once: true}作为第三个参数传递给 addEventListener(),则在处理一次事件后,将自动删除侦听器函数。

document.addEventListener('keyup', function listener() {
    doSomething(hugeString);
}, {once: true}); // listener will be removed after running once

缓存

如果我们不删除未使用的对象且不控制对象大小,那么缓存就会失控。

let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const mapCache = new Map();
function cache(obj){
    if (!mapCache.has(obj)){
        const value = `${obj.name} has an id of ${obj.id}`;
        mapCache.set(obj, value);
        return [value, 'computed'];
    }
    return [mapCache.get(obj), 'cached'];
}
cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_1); // ['Peter has an id of 12345', 'cached']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321")
user_1 = null; // removing the inactive user
// Garbage Collector
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // first entry is still in cache

在上面的示例中,缓存仍保留在 user_1 对象上。因此,我们还需要清除不会再重用的条目的缓存。

可能的解决方案:我们可以使用 WeakMap。它的数据结构中,键名是对象的弱引用,它仅接受对象作为键名,所以其对应的对象可能会被自动回收。当对象被回收后,WeakMap 自动移除对应的键值对。在以下示例中,在使 user_1 对象为空后,下一次垃圾回收后关联的条目会自动从 WeakMap 中删除。

let user_1 = { name: "Peter", id: 12345 };
let user_2 = { name: "Mark", id: 54321 };
const weakMapCache = new WeakMap();
function cache(obj){
    // ...same as above, but with weakMapCache
    return [weakMapCache.get(obj), 'cached'];
}
cache(user_1); // ['Peter has an id of 12345', 'computed']
cache(user_2); // ['Mark has an id of 54321', 'computed']
console.log(weakMapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321"}
user_1 = null; // removing the inactive user
// Garbage Collector
console.log(weakMapCache); // ((…) => "Mark has an id of 54321") - first entry gets garbage collected

分离的 DOM 元素

如果 DOM 节点具有来自 JavaScript 的直接引用,则即使从 DOM 树中删除了该节点,也不会对其垃圾回收。

在以下示例中,我们创建了一个 div 元素并将其附加到 document.body。removeChild() 无法正常工作,并且由于仍然存在指向 div 的变量,所以堆快照将显示分离的 HTMLDivElement。

function createElement() {
    const div = document.createElement('div');
    div.id = 'detached';
    return div;
}
// this will keep referencing the DOM element even after deleteElement() is called
const detachedDiv = createElement();
document.body.appendChild(detachedDiv);
function deleteElement() {
    document.body.removeChild(document.getElementById('detached'));
}
deleteElement(); // Heap snapshot will show detached div#detached

怎么预防呢?一种方案是将 DOM 引用移入本地域。在下面的示例中,在函数 appendElement() 完成之后,将删除指向 DOM 元素的变量。

function createElement() {...} // same as above
// DOM references are inside the function scope
function appendElement() {
    const detachedDiv = createElement();
    document.body.appendChild(detachedDiv);
}
appendElement();
function deleteElement() {
     document.body.removeChild(document.getElementById('detached'));
}
deleteElement(); // no detached div#detached elements in the Heap Snapshot