owned this note
owned this note
Published
Linked with GitHub
# Causes of Memory Leaks in JavaScript and How to Avoid Them
## What is Memory leaks
**Reference chain**
* Browser 會將 objects 存放在 heap memory 中,從 root(window/global) 藉由 reference chain 可到達這些 objects。
![](https://i.imgur.com/EW6NuKX.png)
**Garbage Collection(簡稱 GC)**
* Garbage Collector 是 JavaScript 引擎中的一種 background process,工作是鑑別無法藉由 reference chain 到達的 objects 將其刪除,並回收相對應的記憶體空間
下圖中 Object 4 會從記憶體中移除
![](https://i.imgur.com/76FvcAw.png)
**Mark and Sweep 回收策略**
JavaScript最常用垃圾回收策略是" 標記清理(mark-and-sweep) "
策略概念:遍歷空間下所有的物件,並標記有被引用的並且最終可以到達 根(window/global) 的物件。在 GC 階段,清除沒有標記的物件。
![](https://i.imgur.com/cJ0R28a.gif)
**Memory Leak**
* 當 object 本該被 garbage collector 被清掉,卻由於疏忽或錯誤,導致本應該被 GC 清除的 object 意外被引用而維持可被 reference chain 訪問的狀態,就會發生記憶體洩漏。
* 當多餘的 object 存在記憶體中,會造成記憶體浪費,嚴重的話可能會導致程式效能變慢甚至網頁 crash。
## JavaScript 導致 Memory Leaks 常見的 6 種情形
### 1.不當存取全域變數
全域變數不會被 GC 回收。在非嚴格模式下,需避免以下錯誤:
* 給未宣告的變數賦值,會讓區域變數變成全域變數
```javascript=
// wrong
function createGlobalVariables() {
leaking1 = '變成全域性變數'; // 如果作用域內沒有宣告變數,卻賦值給該變數,JavaScript 會自動幫我們在全域宣告一個全域變數
};
createGlobalVariables();
window.leaking1; // '變成全域性變量了' => 在瀏覽器下的全域物件是 window
```
* 使用指向全域性物件的 this,會讓區域變數變成全域變數
```javascript=
// wrong
function createGlobalVariables() {
this.leaking2 = '這也是全域性變數';
};
createGlobalVariables(); // => 直接呼叫函式的情況下,this 會指向全域
window.leaking2; // '這也是全域性變數'
```
**如何避免**
採用嚴格模式("use strict").上述例子在嚴格模式下會爆錯,避免 memory leak 產生
### 2.Closures 閉包
一般的函式作用域變數在函式執行完後會被清理.
閉包讓我們可以從 inner 函式訪問 outer 函式 scope 的變數,此特性會讓該變數一直處於被引用狀態,不會被 GC 回收。
```javascript=
// wrong
function outer() {
const potentiallyHugeArray = [];
return function inner() {
potentiallyHugeArray.push('Hello'); // function inner is closed over the potentiallyHugeArray variable
console.log('Hello');
console.log('potentiallyHugeArray', potentiallyHugeArray);
};
};
const sayHello = outer(); // contains definition of the function inner
function repeat(fn, num) {
for (let i = 0; i < num; i++){
fn();
}
}
repeat(sayHello, 10); // 每次呼叫 sayHello 都會新增 'Hello' 到potentiallyHugeArray
// now imagine repeat(sayHello, 100000)
```
**如何避免**
在使用閉包時需要清楚知道
* 何時創建了閉包以及閉包保留了哪些 Objects
* 閉包的預期壽命以及使用情形,尤其是作為 callback 來使用的時候
### 3.Timers 定時器
在 setTimeout 或 setInterval 的 callback 函式中引用某些物件,是防止被 GC 回收的常見做法。
下面的例子中,data 物件只能在 timer 清掉後被 GC 回收。但因為沒有拿到 setInterval return 的定時器 ID,也就沒辦法用程式碼清除這個 timer。
雖然 data.hugeString 完全沒被使用,也會一直保留在記憶體中。
```javascript=
// wrong
function setCallback() {
const data = {
counter: 0,
hugeString: new Array(100000).join('x')
};
return function cb() {
data.counter++; // data 物件現在已經屬於 callback 函式的作用域
console.log(data.counter);
}
}
setInterval(setCallback(), 1000); // 無法停止定時器
```
**如何避免**
對於使用壽命未定義或不確定的 callback 函式,我們應該:
* 留意被 timer 的 callback 所參考的物件
* 必要時使用 timer return 的定時器 ID,丟進 clearTimeout 或 clearInterval 以清除 timer。
```javascript=
// right
function setCallback() {
// 分開定義變數
let counter = 0;
const hugeString = new Array(100000).join('x'); // setCallback return 後即被回收
return function cb() {
counter++; // 只剩 counter 位於 callback 函式作用域
console.log(counter);
}
}
const timerId = setInterval(setCallback(), 1000); // 儲存定時器 ID
// 執行某些操作 ...
clearInterval(timerId); // 清除定時器,ex 按完按鈕後清除
```
### 4.Event listeners 事件監聽器
事件監聽器會阻止其作用域內的變數被 GC 回收,事件監聽器會一直處於 active,直到
1. 使用 removeEventListener() 移除該 event listers
2. 移除與其關聯的 DOM 元素
```javascript=
// wrong
const hugeString = new Array(100000).join('x');
document.addEventListener('keyup', function() { // 匿名監聽器無法移除
doSomething(hugeString); // hugeString 會一直處於 callback 函式的作用域內
});
```
上面例子中
1. 事件監聽器用了匿名函式,沒法用 removeEventListener()移除
2. 也無法刪除 document元素
因此事件 callback 函式內的變數會一直保留,就算我們只想觸發一次事件。
**如何避免**
當事件監聽器不再需要時,使用具名函式方式得到其 reference,並且在 removeEventListener() 中解除事件監聽器跟關聯的 DOM 元素的連結
```javascript=
// right
function listener() {
doSomething(hugeString);
}
document.addEventListener('keyup', listener);
document.removeEventListener('keyup', listener);
```
如果事件監聽器只需要執行一次, addEventListener()可以接受[第三個 optional 參數 {once: true}](https://developer.mozilla.org/zh-CN/docs/Web/API/EventTarget/addEventListener),監聽器函式會在事件觸發一次執行後自動移除(此時匿名函式也可以用此方式)。
```javascript=
// right
document.addEventListener('keyup', function listener(){
doSomething(hugeString);
}, {once: true}); // 執行一次後自動移除事件監聽器
```
React 寫法
```javascript=
useEffect(() => {
window.addEventListener('keyup', listener);
return () => {
window.removeEventListener('keyup', listener);
};
},[]);
```
### 5.Cache 快取
如果持續地往快取裡增加資料,沒有定時清除無用的物件,也沒有限制快取大小,那麼快取就會像滾雪球一樣越來越大。
Wrong Example: 使用 Map 資料結構來儲存快取
```javascript=
// wrong
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); // 添加 key
return [value, 'computed'];
}
return [mapCache.get(obj), 'cached']; // 讀取 value
}
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
```
當設定 key value pair 在 Map 的資料結構中,即使之後 key 被清空,原本的 key value pair 還是依然存在,因為 Map 中所有 key 和 value 會一直被引用,導致無法被 GC
![](https://i.imgur.com/v0sEm8B.png)
解決方案: 上述案例可以改使用 [WeakMap](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/WeakMap)。
WeakMap 是 ES6 新增的一種資料結構,它只用物件作為 key,並保持物件 key 的 weak reference,如果物件 key 被置空了,相關的 key value pair 會被 GC 自動回收。
```javascript=
// right
let user_1 = { name: "Kayson", id: 12345 };
let user_2 = { name: "Jerry", id: 54321 };
const weakMapCache = new WeakMap();
function cache(obj){
// 程式碼跟前一個例子相同,只不過用的是 weakMapCache
return [weakMapCache.get(obj), 'cached'];
}
cache(user_1); // ['Kayson has an id of 12345', 'computed']
cache(user_2); // ['Jerry has an id of 54321', 'computed']
console.log(weakMapCache); // ((…) => "Kayson has an id of 12345", (…) => "Jerry has an id of 54321"}
user_1 = null;
// Garbage Collector
console.log(weakMapCache); // ((…) => "Jerry has an id of 54321") - 第一條記錄已被 GC 刪除
```
### 6.移除 DOM 節點
如果 DOM 節點被 JavaScript 程式碼引用,即使將該節點從 DOM three 移除,也不會被 GC 回收。
```javascript=
// wrong
function createElement() {
const div = document.createElement('div');
div.id = 'detached';
return div;
}
// 即使呼叫了deleteElement() ,全域變數 detachedDiv 依然儲存著 DOM 元素的 reference,所以無法被 GC 回收
const detachedDiv = createElement();
document.body.appendChild(detachedDiv);
function deleteElement() {
document.body.removeChild(document.getElementById('detached'));
}
deleteElement(); // Heap snapshot 顯示為 detached div#detached
```
**如何避免**
限制只能在 local scope 之內引用 DOM
```javascript=
// right
function createElement() {...} // 程式碼跟前一個例子相同
function appendElement() {
// 在 local scope 之內引用 DOM
const detachedDiv = createElement();
document.body.appendChild(detachedDiv);
}
appendElement();
function deleteElement() {
document.body.removeChild(document.getElementById('detached'));
}
deleteElement();
```
**結論**
了解 GC 可以幫助我們寫出比較不容易記憶體洩漏的程式碼。在開發上這並不是一個容易被找出來的問題,透過以上手法我們可以防範一些比較基本的錯誤,必要時也可以透過 Chrome dev tool 來除錯。
### Reference
1. https://www.ditdot.hr/en/causes-of-memory-leaks-in-javascript-and-how-to-avoid-them
2. 通過【垃圾回收機制】的角度認識【Map與WeakMap】的區別
https://www.gushiciku.cn/pl/g4iM/zh-tw?fbclid=IwAR3_BMatJFYGTMtJbc331F4Iur1fyKTCuNQSmWHN5Ja2ftLLeO-qwP_JxT4
3. https://medium.com/starbugs/%E8%BA%AB%E7%82%BA-js-%E9%96%8B%E7%99%BC%E8%80%85-%E4%BD%A0%E4%B8%8D%E8%83%BD%E4%B8%8D%E7%9F%A5%E9%81%93%E7%9A%84%E8%A8%98%E6%86%B6%E9%AB%94%E7%AE%A1%E7%90%86%E6%A9%9F%E5%88%B6-d9db2fd66f8
4. https://ithelp.ithome.com.tw/articles/10214185