當我們使用“老式”方法編寫網頁時,通常不太需要關注JavaScript內存管理。

但 SPA(單頁應用程序)的興起促使我們需要關注與內存相關的編碼實踐。

在本文中,我們將探討導致JavaScript內存泄漏的編程模式,並說明如何改善內存管理。

JavaScript代碼中常見的內存泄漏

偶然的全局變量

全局變量始終被根結點引用,並且永遠不會被垃圾回收。在非嚴格模式下,一些錯誤會導致變量從本地範圍泄漏到全局:

給未聲明的變量賦值, 使用“ this”指向全局對象。

function createGlobalVariables() {
    leaking1 = 'I leak into the global scope'; // leaking未聲名
    this.leaking2 = 'I also leak into the global scope'; // 'this' 指向全局變量
};
createGlobalVariables();
window.leaking1; // 'I leak into the global scope'
window.leaking2; // 'I also leak into the global scope'

2 Closures 閉包

函數內的變量將在函數退出調用堆棧後清除,如果函數外部沒有指向它們的引用,則將對其進行清理。

儘管函數已完成執行,但閉包仍將保留變量的引用。

function outer() {
    const potentiallyHugeArray = [];
    return function inner() {
        potentiallyHugeArray.push('Hello'); // 返回內部函數並封閉 potentiallyHugeArray 變量
        console.log('Hello');
    };
};
const sayHello = outer(); // 返回內部函數

function repeat(fn, num) {
    for (let i = 0; i < num; i++){
        fn();
    }
}
repeat(sayHello, 10); // 每一次調用都將 'Hello' 加到了 potentiallyHugeArray 
 
// now imagine repeat(sayHello, 100000)

內部變量 potentiallyHugeArray 永遠無法訪問到,並且佔用內存一直在增加。

3 Timers 計時器

下面的例子中因爲計時器一直在執行, data 永遠不會被回收。

··· function setCallback() { const data = { counter: 0, hugeString: new Array(100000).join('x') }; return function cb() { data.counter++; // data 對象在回調範圍可訪問 console.log(data.counter); } } setInterval(setCallback(), 1000); // 如何停止? ···

記得及時清楚計時器:

function setCallback() {
    const data = {
        counter: 0,
        hugeString: new Array(100000).join('x')
    };
    return function cb() {
        data.counter++; // data 對象在回調範圍可訪問
        console.log(data.counter);
    }
}

const timerId = setInterval(setCallback(), 1000); // 保存計時器ID

// doing something ...

clearInterval(timerId); //停止計時器

4 Event listeners 事件綁定

事件綁定的變量永遠不會被回收,除非:

  • 執行 removeEventListener()
  • 相關 DOM 元素被刪除.
const hugeString = new Array(100000).join('x');
document.addEventListener('keyup', function() { // 匿名函數無法被回收
    doSomething(hugeString); // hugeString 被保持在匿名函數的引用中
});

不用的時侯要取消註冊

function listener() {
    doSomething(hugeString);
}
document.addEventListener('keyup', listener); // 使用命名函數註冊事件
document.removeEventListener('keyup', listener); // 取消事件註冊

如果事件只需要執行一次,請使用 once 參數

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

5 Cache 緩存

如果使用了緩存,而沒有相應刪除邏輯,緩存會一直增加。

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; // 刪除了不活躍用戶1

//垃圾回收後
console.log(mapCache); // ((…) => "Peter has an id of 12345", (…) => "Mark has an id of 54321") // 用戶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;  // 刪除了不活躍用戶1

//垃圾回收後
console.log(weakMapCache); // ((…) => "Mark has an id of 54321") - user_1 被回收了

6. 被引用的 DOM 對象

如果 dom 被JS引用了,即使從DOM樹中被刪除,也無法被垃圾回收

function createElement() {
    const div = document.createElement('div');
    div.id = 'detached';
    return div;
}

// 這裏產生了對 DOM 元素的引用
const detachedDiv = createElement();

document.body.appendChild(detachedDiv);

function deleteElement() {
    document.body.removeChild(document.getElementById('detached'));
}

deleteElement(); // 可在泄露工具中查看 div#detached 對象仍然存在

可使用局部變量創建DOM

function createElement() {...} // same as above

// DOM 在函數內部被引用

function appendElement() {
    const detachedDiv = createElement();
    document.body.appendChild(detachedDiv);
}

appendElement();

function deleteElement() {
     document.body.removeChild(document.getElementById('detached'));
}

deleteElement(); // 在泄露工具中看不到 div#detached

7 循環引用

在完美的垃圾回收機制中,循環引用的兩個對象只要不被外部變量引用就可以被回收。但在引用計數實現的垃圾回收中,這兩個對象永遠不會回收,因爲引用計數永遠無法到達0,如下面的情況:

<script type="text/javascript">
document.write("Circular references between JavaScript and DOM!");
var obj;
window.onload = function(){
    obj=document.getElementById("DivElement");
    document.getElementById("DivElement").expandoProperty = obj;   // DOM 引用了它自身
    obj.bigString=new Array(1000).join(new Array(2000).join("XXXXX"));
};
</script>
<div id="DivElement">Div Element</div>

下面的例子同樣有這個問題

function myFunction(element)
{
    this.elementReference = element;
    //循環引用 DOM-->JS-->DOM
    element.expandoProperty = this;
}

function Leak() {
    //這段代碼造成泄露
    new myFunction(document.getElementById("myDiv"));
}

Chrome 內存對象查看

可在Chrome中創建內存快照

然後查看內存中的對象

參考資料:

  1. https://www.ditdot.hr/en/causes-of-memory-leaks-in-javascript-and-how-to-avoid-them
  2. https://www.cnblogs.com/duhuo/p/4760024.html
  3. https://www.lambdatest.com/blog/eradicating-memory-leaks-in-javascript/
相關文章