垃圾回收機制(Garbage Collection),又稱作 GC。
那麼,是要回收怎樣的垃圾呢?在程式語言中,如果沒有指標指向記憶體空間時,代表目前沒有在使用了,留著也只是浪費記憶體,這個就是程式要回收掉的垃圾。
JavaScript 是個現代的語言,當然要有記憶體自動回收機制。
不過要程式幫你回收記憶體,這件事很難嗎?如果有上過 C 語言的課程,教授或教科書裡頭或多或少都會再三提醒你,如果 malloc 一塊記憶體,一定要記得把他還回去,不然有可能會造成記憶體洩漏。
說起來很簡單,但要怎麼還、什麼時候還又是一門學問,如果在不對的時間還回去,導致在某段時間後取用時發現是空指針,就會噴出一個很討厭的訊息,叫做 invalid memory reference
。大部分最容易出錯的時機就是在將記憶體還回去的時候。
大部分比較高階的程式語言都具有垃圾回收機制,畢竟要正確管理記憶體實在太困難。垃圾回收機制的工作是追蹤記憶體分配,當發現有不再被使用到的空間時就會自動幫你釋放掉。
還好 JavaScript 並沒有提供這樣的彈性給我們,直接實作一套完整的記憶體回收機制,不然肯定會遇上不少災難。關於 JavaScript 中的記憶體管理機制,可以參考 MDN 上的文件
既然我們不是實作垃圾回收機制的人,也不是實作瀏覽器的開發者,知道這件事有什麼用?
雖然話這樣說沒錯,但畢竟我們是寫程式的人,知道垃圾回收機制是怎麼運作的,可以幫助你寫出不容易讓記憶體洩漏(memory leak)的程式碼。
另外,每次執行垃圾回收的時候,整個程式就會暫停執行,一旦垃圾回收次數頻繁,有可能影響到效能。持續的記憶體洩漏可能會讓頁面效能變低,進而影響到使用者體驗。
不過,倒也不需要過往矯正到用個閉包就在思考記憶體洩漏,現在的電腦跟手機動輒 2G ~ 4G 記憶體起跳,沒有那麼容易就把記憶體用光的。(chrome 表示)
我們在這邊只關注比較明顯的案例。
最常見的有可能會造成 JavaScript 的記憶體洩漏的問題就是不正確地使用閉包(closure),因為閉包內的變數值外部函數引用,所以會繼續存留在範疇當中。
function myLeakyFn() {
leak = "hello world";
}
這個函數在非 strict 模式下,會導致瀏覽器自動宣告一個全域變數 leak,所以就算函數執行完畢了,leak 還是會繼續存留在 window 物件當中。
哇,既然這個變數變成全域變數了,那麼程式就不可能將它回收掉,因為任何變數、程式都有可能存取這個變數。
對於 DOM 的設計來說,就算節點被移除了,還是會保留在記憶體當中,因為之後可能還會被用在其他地方(例如刪除完後想復原)。
let a = document.createElement('div');
a.textContent = 'hello world';
document.body.appendChild(a);
document.body.removeChild(a);
console.log(a); // <div>hello world</div>
不知道你有沒有想過這個問題,當 node 在 DOM tree 中移除後,事件監聽器也會一起被移除嗎?
要解答這個問題,可以用下列的程式碼測試:
let a = document.createElement('div');
// let b = a;
a.textContent = 'hello world';
a.classList.add('a');
a.addEventListener('click', () => {
console.log('clicked');
});
document.body.appendChild(a);
document.body.removeChild(a);
// a = null;
答案是 yes,我們可以在 chrome 的 devtool 中略知一二。這倒省下不少麻煩,不然每次刪除 node 就要找到對應的 event 監聽器也是挺麻煩的。
如果將 a 設為 null,可以發現連 detach 都不見了,這是因為程式碼發現沒有指標指向它就將它回收了。
在比較舊的瀏覽器中(IE),刪除 node 本身好像不一定會正確移除對應的事件監聽器。使用 jQuery.remove()
內部會幫你移除對應的事件監聽器,讓 GC 可以順利回收。
在使用 React 或 vue 的時候,如果有用 document.addEventListener
的話,記得在 componentWillUnmount
或是 beforeDestory
的時候呼叫 removeListener
,來避免潛在的記憶體洩漏。
對比較有經驗的工程師或許是習慣了,但剛入門的話一不小心就忘記要用 clearInterval 或 clearTimeout。
如下列的程式碼:
setInterval(() => {
const node = document.querySelector('.node');
console.log(node);
}, 1000);
假設 node 被刪除了,而 setInternal 卻一直在執行,那麼記憶體就無法將這個 node 回收掉。
了解 GC 最主要的目的是知道它大致上是怎麼運作的可以幫助我們寫出比較不容易記憶體洩漏的程式碼。在開發上這並不是一個容易被找出來的問題,不過透過以上手法我們可以防範一些比較基本的錯誤,必要時也可以透過開發者工具來除錯。