如何避免JavaScript內(nèi)存泄漏?
很多開(kāi)發(fā)者可能平時(shí)并不關(guān)心自己維護(hù)的頁(yè)面是否存在內(nèi)存泄漏,原因可能是剛開(kāi)始簡(jiǎn)單的頁(yè)面內(nèi)存泄漏的速度很緩慢,在造成嚴(yán)重卡頓之前可能就被用戶(hù)刷新了,問(wèn)題也就被隱藏了,但是隨著頁(yè)面越來(lái)越復(fù)雜,尤其當(dāng)你的頁(yè)面是 SAP 方式交互時(shí),內(nèi)存泄漏的隱患便越來(lái)越嚴(yán)重,直到突然有一天用戶(hù)反饋說(shuō):“操作一會(huì)兒頁(yè)面就卡住不動(dòng)了,也不知道為什么,以前不這樣的呀”。
這篇文章通過(guò)一些簡(jiǎn)單的例子介紹內(nèi)存泄漏的調(diào)查方法、總結(jié)內(nèi)存泄漏出現(xiàn)的原因和常見(jiàn)情況,并針對(duì)每種情況總結(jié)如何避免內(nèi)存泄漏。希望能對(duì)大家有所幫助。
一、一個(gè)簡(jiǎn)單的例子
先看一個(gè)簡(jiǎn)單的例子,下面是這個(gè)例子對(duì)應(yīng)的代碼:
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8" />
- <title>memory-leak</title>
- </head>
- <body>
- <p>push date for <button class="count-date">0</button> times</p>
- <p>add Date: <button class="push-date">add date</button></p>
- <p>clear: <button class="clear">clear</button></p>
- <script>
- const pushDate = document.querySelector(".push-date");
- const dateCount = document.querySelector(".count-date");
- let dateAry = [];
- let dateNum = 0;
- // 【寫(xiě)入 date】
- pushDate.addEventListener("click", () => {
- dateCount.innerHTML = `${++dateNum}`;
- for (let j = 0; j < 3000; ++j) {
- dateAry.push(new Date());
- }
- });
- const clear = document.querySelector(".clear");
- // 【回收內(nèi)存】
- clear.addEventListener("click", () => {
- dateAry = [];
- dateCount.innerHTML = "0";
- });
- </script>
- </body>
- </html>
代碼 1
代碼 1 的邏輯很簡(jiǎn)單:點(diǎn)擊“add date”按鈕時(shí)會(huì)向 dateAry 數(shù)組中 push 3000 個(gè) new Date 對(duì)象,點(diǎn)擊“clear”按鈕時(shí)將 dateAry 清空。很明顯,“add date”操作會(huì)造成內(nèi)存占用不斷增長(zhǎng),如果將這個(gè)邏輯用在實(shí)際應(yīng)用中便會(huì)造成內(nèi)存泄漏(不考慮故意將代碼邏輯設(shè)計(jì)成這樣的情況),下面我們看一下如何調(diào)查這種內(nèi)存增長(zhǎng)出現(xiàn)的原因以及如何找出內(nèi)存泄漏點(diǎn)。
1.heap snapshot
為了避免瀏覽器插件的干擾,我們?cè)?chrome 中新建一個(gè)無(wú)痕窗口打開(kāi)上述代碼。然后在 chrome 的 devtools 中的 Memory 工具中找到 “Heap Snapshot”工具,點(diǎn)擊左上角的錄制按鈕錄制一個(gè) Snapshot,然后點(diǎn)擊“add date”按鈕,在手動(dòng)觸發(fā) GC(Garbage Collect)之后,再次錄制一個(gè) Snapshot,反復(fù)執(zhí)行上述操作若干次,像圖 1 中操作的那樣,得到一系列的 Snapshot。
圖 1 錄制 Snapshot
圖 2 是我們剛剛得到的 Snapshot 組,其中的第一個(gè)是頁(yè)面初始加載的時(shí)候錄制的,不難發(fā)現(xiàn),從第二個(gè)開(kāi)始,每個(gè) Snapshot 相比于上一個(gè)其大小都增加了約 200KB,我們點(diǎn)擊選擇 Snapshot 2,在 class filter 輸入框中處輸入 date,可以得到 Snapshot 2 中所有被 Date 構(gòu)造器構(gòu)造出來(lái)的 JS 對(duì)象,也就是 Date 對(duì)象,這里看到的構(gòu)造器跟瀏覽器內(nèi)部的實(shí)現(xiàn)有關(guān),不必跟 JS 的對(duì)象對(duì)應(yīng)。
選中一個(gè) Date 對(duì)象,在下面的面板中可以看到所選對(duì)象的持有鏈以及相關(guān)持有對(duì)象的內(nèi)存的保留大小(Retained Size),從圖中可以看出選中的 Date 對(duì)象是 Array 的第 1 個(gè)元素(index 從 0 開(kāi)始),而這個(gè) Array 的持有者是 system/Context 上下文中的 dateAry,system/Context 上下文就是代碼中 script 標(biāo)簽的上下文,我們可以看到在這個(gè) dataAry 的保留大小是 197KB,我們?cè)偾械?Snapshot 3,用相同的方式查看內(nèi)存持有和大小,可以發(fā)現(xiàn) Snapshot 3 中的 dataAry 的保留大小變成了 386KB,相比于 Snapshot 2 增漲了約 200KB!逐一比較后面的 Snapshot 4、5 后也能得到相同的對(duì)比結(jié)果,即下一個(gè) Snapshot 中的 dateAry 比上一個(gè)的保留大小大約 200KB。
圖 2 錄制的 Snapshot 組
參考【代碼 1】我們可以知道,“add date”按鈕在被點(diǎn)擊時(shí),會(huì)向 dateAry 數(shù)組中 push 3000 個(gè)新的 Date 對(duì)象,而在圖 2 中的 Date 構(gòu)造器的右側(cè)可以看到這 3000 個(gè) Date 對(duì)象(Date x 3000),它對(duì)應(yīng)的正式我們的循環(huán)創(chuàng)建的那 3000 個(gè) Date 對(duì)象。綜合上面的操作我們可以知道,chorome devtools 中的 Memroy 的 Heap Snapshot 工具可以錄制某一個(gè)時(shí)刻的所有內(nèi)存對(duì)象,也就是一個(gè)“快照”,快照中按“構(gòu)造器”分組,展示了所有被記錄下來(lái)的 JS 對(duì)象。
如果這個(gè)頁(yè)面是一個(gè)實(shí)際服務(wù)于用戶(hù)的網(wǎng)站的某個(gè)頁(yè)面話(huà)(用戶(hù)可能非常頻繁的點(diǎn)擊“add date”按鈕,作者可能想記錄用戶(hù)點(diǎn)擊的次數(shù)?也許吧,雖然我也不知道他什么要這么做)隨著用戶(hù)使用時(shí)間的增長(zhǎng),“add date”按鈕的反應(yīng)就會(huì)越來(lái)越慢,整體頁(yè)面也隨之越來(lái)越卡,原因除了系統(tǒng)的內(nèi)存資源被占用之外,還有 GC 的頻率和時(shí)長(zhǎng)增長(zhǎng),如圖 3 所示,因?yàn)?GC 執(zhí)行的過(guò)程中 JS 的執(zhí)行是被暫停的,所以頁(yè)面就會(huì)呈現(xiàn)出越來(lái)越卡的樣子。
圖 3 Performance 錄制的 GC 占比
圖 4 chrome 的任務(wù)管理器
最終:
圖 5 內(nèi)存占用過(guò)高導(dǎo)致瀏覽器崩潰
那么,在這個(gè)“實(shí)際”的場(chǎng)景下,如何找出那“作祟”的 3000 個(gè) Date 對(duì)象呢?我們首先想到的應(yīng)該是就是:之前不是錄制了好多個(gè) Snapshot 嗎?可不可以把它們做對(duì)比找到“差異”呢,從差異中找到增長(zhǎng)的地方不就行了?思路非常正確,在此之前我們?cè)俜治鲆幌逻@幾個(gè) Snapshot:每次點(diǎn)擊“add date”按鈕、手動(dòng)觸發(fā) GC、得到的 Snapshot 的大小相比上一次都有所增加,如果這種內(nèi)存的增長(zhǎng)現(xiàn)象不符合“預(yù)期”的話(huà)(顯然在這個(gè)“實(shí)際”的例子中是不符合預(yù)期的),那么這里就有很大的嫌疑存在內(nèi)存泄漏。
這個(gè)時(shí)候我們選中 Snapshot 2,在圖 2 所示的 " Summary" 處選擇“Comparison”,在右側(cè)的 "All objects" 處選擇 Snapshot 1,這樣一來(lái),Constructor 里展示便是 Snapshot 1 和 Snapshot 2 的對(duì)比,通過(guò)觀察不難發(fā)現(xiàn),圖中的 +144KB 最值得懷疑,于是我們選中它的構(gòu)造器 Date,展開(kāi)選中任意子項(xiàng)看詳情,發(fā)現(xiàn)其是被 Array 構(gòu)造器構(gòu)造出來(lái)的 dateAry 持有的(即 dateAry 中的一員),并且 dateAry 被三個(gè)地方持有,其中系統(tǒng)內(nèi)部的 array 我們不用理會(huì),圖 6 中寫(xiě)有 "context in ()" 地方給了我們持有 dateAry 的 context 所在的位置,點(diǎn)擊便可以跳到代碼所在的位置了,整個(gè)操作如圖 6 所示:
圖 6 定位代碼位置
這里有一個(gè)值得注意的地方,圖 6 中的 “context in () @449305” 中的 "()",這里之所以展示為了 "()" 是因?yàn)榇a中用了“匿名函數(shù)”(代碼 2 中第 2 行的箭頭函數(shù)):
- // 【寫(xiě)入 date】
- pushDate.addEventListener("click", () => {
- dateCount.innerHTML = `${++dateNum}`;
- for (let j = 0; j < 3000; ++j) {
- dateAry.push(new Date());
- }
- });
代碼 2 匿名函數(shù)
但是如果我們給函數(shù)起一個(gè)名字,如下面的代碼所示,也就是如果我們使用具名函數(shù)(代碼3 第 2 行函數(shù) add)或者將函數(shù)賦值給一個(gè)變量并使用這個(gè)變量(第 10 和 18 行的行為)的時(shí)候,devtools 中都可以看到相應(yīng)的函數(shù)的名字,這也就可以幫助我們更好的定位代碼,如圖 7 所示。
- // 【寫(xiě)入 date】
- pushDate.addEventListener("click", function add() {
- dateCount.innerHTML = `${++dateNum}`;
- for (let j = 0; j < 3000; ++j) {
- dateAry.push(new Date());
- }
- });
- const clear = document.querySelector(".clear");
- const doClear = function () {
- dateAry = [];
- dateCount.innerHTML = "0";
- };
- // 【回收內(nèi)存】
- clear.addEventListener("click", doClear);
代碼 3 具名函數(shù)
圖 7 具名函數(shù)方便定位
這樣我們便找到了代碼可疑的地方,只需要將代碼的作者抓過(guò)來(lái)對(duì)著他一頓“分析”這個(gè)內(nèi)存泄漏的問(wèn)題基本就水落石出了。
其實(shí),Snapshot 除了“Comparison”之外還有一個(gè)更便捷的用于對(duì)比的入口,在這里直接可以看到在錄制 Snapshot 1 和 Snapshot 2 兩個(gè)時(shí)間點(diǎn)之間被分配出來(lái)的內(nèi)存,用這種方式也可以定位到那個(gè)可疑的 Date x 3000:
圖 8 Snapshot 比較器
上文件介紹的是用 Heap Snapshot 尋找內(nèi)存泄漏點(diǎn)的方法,這個(gè)方法的優(yōu)點(diǎn):可以錄制多個(gè) Snapshot,然后方便的兩兩比較,并且能看到 Snapshot 中的全量?jī)?nèi)存,這一點(diǎn)是下文要講的“Allocation instrumentation on timeline”方法不具備的,并且這種方法可以更加方便地查找后面會(huì)講的因 Detached Dom 導(dǎo)致的內(nèi)存泄漏。
2.Allocation instrumentation on timeline
但是,不知道你有沒(méi)有覺(jué)得,這種高頻率地錄制 Snapshot、對(duì)比、再對(duì)比的方式有點(diǎn)兒麻煩?我需要不斷的去點(diǎn)擊“add date”,然后鼠標(biāo)又要跑過(guò)去點(diǎn)擊手動(dòng) GC、錄制 Snapshot、等待錄制完畢,再去操作,再去錄制。有沒(méi)有簡(jiǎn)單一些的方式來(lái)查找內(nèi)存泄漏?這個(gè)時(shí)候我們回到 Memory 最初始的界面,你突然發(fā)現(xiàn) “Heap snapshot”下面還有一個(gè) radio:“Allocation instrumentation on timeline”,并且這個(gè) radio 下面的介紹文案的最后寫(xiě)著:“Use this profile type to isolate memory leaks”,原來(lái)這是一個(gè)專(zhuān)門(mén)用于調(diào)查內(nèi)存泄漏的工具!于是,我們選中這個(gè) radio,點(diǎn)擊開(kāi)始錄制按鈕,然后將注意力放在頁(yè)面上,然后你發(fā)現(xiàn)當(dāng)點(diǎn)擊“add date”按鈕時(shí),右面錄制的 timeline 便會(huì)多出一個(gè)心跳:
圖 9 Allocation instrumentation on timeline
如圖 9 所示,每當(dāng)我們點(diǎn)擊“add date”按鈕時(shí),右面都有一個(gè)對(duì)應(yīng)的心跳,當(dāng)我們點(diǎn)擊“clear”按鈕時(shí),剛才出現(xiàn)的所有心跳便全都“縮回”去了,于是我們得出結(jié)論:每一個(gè)“心跳”都是一次內(nèi)存分配,其高度代表內(nèi)存分配的量,在之后的時(shí)間推移過(guò)程中,如果剛才心跳對(duì)應(yīng)的被分配的內(nèi)存被 GC 回收了,“心跳”便會(huì)跟著變化為回收之后的高度。于是,我們便擺脫了在 Snapshot 中來(lái)回操作、錄制的窘境,只需要將注意力集中在頁(yè)面的操作上,并觀察哪個(gè)操作在右邊的時(shí)間線(xiàn)變化中是可疑的。
經(jīng)過(guò)一系列操作,我們發(fā)現(xiàn)“add date”這個(gè)按鈕的點(diǎn)擊行為很可疑,因?yàn)樗峙涞膬?nèi)存不會(huì)自動(dòng)被回收,也就是只要點(diǎn)擊一次,內(nèi)存就會(huì)增長(zhǎng)一點(diǎn),我們停止錄制,得到了一個(gè) timeline 的 Snapshot,這個(gè)時(shí)候如果我們點(diǎn)擊某個(gè)心跳的話(huà):
圖 10 點(diǎn)擊某個(gè)心跳
熟悉的 Date x 3000 又出現(xiàn)了(圖 11),點(diǎn)擊一個(gè) Date 對(duì)象看持有鏈,接下來(lái)便跟上文 Snapshot 的持有鏈分析一樣了:
圖 11 通過(guò) timeline 找到泄漏點(diǎn)
這個(gè)方法的優(yōu)點(diǎn)上文已經(jīng)說(shuō)明,可以非常直觀、方便的觀察內(nèi)存隨可疑操作的分配與回收過(guò)程,可以方便的觀察每次分配的內(nèi)存。它的缺點(diǎn):錄制時(shí)間較長(zhǎng)時(shí) devtools 收集錄制結(jié)果的時(shí)間會(huì)很長(zhǎng),甚至有時(shí)候會(huì)卡死瀏覽器;下文會(huì)講到 detached DOM,這個(gè)工具不能比較出 detached DOM,而 heap snapshot 可以。
3.performance
devtools 中的 Performance 面版中也有一個(gè) Memory 功能,下面看一下它如何使用。我們把 Memory 勾選上,并錄制一個(gè) performance 結(jié)果:
圖 12 Performance 的錄制過(guò)程
在圖 12 中可以看到,在錄制的過(guò)程中我們連續(xù)點(diǎn)擊“add date”按鈕 10 次,然后點(diǎn)擊一次“clear”按鈕,然后再次點(diǎn)擊“add date” 10 次,得到的最終結(jié)果如圖 13 所示:
圖 13 Performance 的錄制結(jié)果
在圖 13 中我們可以得到下面的信息:
- 整個(gè)操作過(guò)程中內(nèi)存的走勢(shì):參見(jiàn)圖 13 下方的位置,第一輪點(diǎn)擊 10 次的過(guò)程中內(nèi)存不斷增長(zhǎng),點(diǎn) clear 之后內(nèi)存斷崖式下跌,第二輪點(diǎn)擊 10 次內(nèi)存又不斷增長(zhǎng)。這也是這個(gè)工具的主要作用:得到可疑操作的內(nèi)存走勢(shì)圖,如果內(nèi)存持續(xù)走高則有理由懷疑此操作由內(nèi)存泄漏的可能。
- 內(nèi)存的增長(zhǎng)量:參見(jiàn) JS Heap 位置,鼠標(biāo)放上去可以看見(jiàn)每個(gè)階梯上下位置的內(nèi)存增長(zhǎng)/下跌的量
- 通過(guò)在 timeline 中定位某個(gè)“階梯”,我們也能找到可疑的代碼,如圖 14 所示:
圖 14 通過(guò) Performance 定位問(wèn)題代碼
這種方法的優(yōu)點(diǎn):可以直觀得看到內(nèi)存的總體走勢(shì),并且同時(shí)得到所有操作過(guò)程中的函數(shù)調(diào)用棧和時(shí)間等信息。缺點(diǎn):沒(méi)有具體的內(nèi)存分配的細(xì)節(jié),錄制的過(guò)程不能實(shí)時(shí)看到內(nèi)存分配的過(guò)程。
二、內(nèi)存泄漏出現(xiàn)的場(chǎng)景
1.全局
JS 采用標(biāo)記清掃法去回收無(wú)法訪問(wèn)的內(nèi)存對(duì)象,被掛載在全局對(duì)象(在瀏覽器中即指的是 window 對(duì)象,在垃圾回收的角度上稱(chēng)其為根節(jié)點(diǎn),也叫 GC root)上的屬性所占用內(nèi)存是不會(huì)被回收的,因?yàn)槠涫鞘冀K可以訪問(wèn)的,這也符合“全局”的命名含義。
解決方案就是避免用全局對(duì)象存儲(chǔ)大量的數(shù)據(jù)。
2.閉包(closure)
我們把【代碼 1】稍加改動(dòng)便可以得到一個(gè)閉包導(dǎo)致內(nèi)存泄漏的版本:
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8" />
- <title>memory-leak</title>
- </head>
- <body>
- <p>push date for <button class="count-date">0</button> times</p>
- <p>add Date: <button class="push-date">add date</button></p>
- <p>clear: <button class="clear">clear</button></p>
- <script>
- const pushDate = document.querySelector(".push-date");
- const dateCount = document.querySelector(".count-date");
- let ary = [];
- const wrap = () => {
- const dateAry = Array(3_000).map(() => new Date());
- const inner = () => {
- return dateAry;
- };
- return inner;
- };
- // 【寫(xiě)入 date】
- pushDate.addEventListener("click", function add() {
- ary.push(wrap());
- dateCount.innerHTML = `${ary.length}`;
- });
- const clear = document.querySelector(".clear");
- // 【回收內(nèi)存】
- clear.addEventListener("click", function clear() {
- ary = [];
- dateCount.innerHTML = `${ary.length}`;
- });
- </script>
- </body>
- </html>
代碼 3 閉包導(dǎo)致內(nèi)存泄漏
將上述代碼加載到 chrome 中,并用 timeline 的方式錄制一個(gè) Snapshot,得到的結(jié)果如圖 15 所示:
圖 15 閉包的錄制結(jié)果
我們選中 index = 2 的心跳,可以看到 Constructor 里面出現(xiàn)了一個(gè) "(closure)",我們展開(kāi)這個(gè) closure,可以看到里面的 "inner()",inner() 后面的 "()" 表示 inner 是一個(gè)函數(shù),這時(shí)候你可能會(huì)問(wèn):“圖中的 Constructor 的 Retained Size 大小都差不多,為什么你要選 (closure)?”,正是因?yàn)闆](méi)有明顯占比較高的 Retained Size 我們才隨便選一個(gè)調(diào)查,后面你會(huì)發(fā)現(xiàn)不管你選了哪一個(gè)最后的調(diào)查鏈路都是殊途同歸的。
我們?cè)谙旅娴?Retainers 中看下 inner() 的持有細(xì)節(jié):從下面的 Retainers 中可以看出 inner() 這個(gè) closure 是某個(gè) Array 的第 2 項(xiàng)(index 從 0 開(kāi)始),而這個(gè)數(shù)組的持有者是 system/Context(即全局) 中的 ary,通過(guò)觀察可以看到 ary 的持有大小(Retained Size)是 961KB 大約等于 192KB 的 5 倍,5 即是我們點(diǎn)擊“add date”按鈕的次數(shù),而下面的 5 個(gè) "previous in system/Context" 每個(gè)大小都是 192KB,而它們最終都是被某個(gè) inner() 閉包持有,至此我們便可以得出結(jié)論:全局中有一個(gè) ary 數(shù)組,它的主要內(nèi)存是被 inner() 填充的,通過(guò)藍(lán)色的 index.html:xx 處的代碼入口定位到代碼所在地看一下一切就都了然了,原來(lái)是 inner() 閉包內(nèi)部持有了一個(gè)大對(duì)象,并且所有的 inner() 閉包及其持有的大對(duì)象都被 ary 對(duì)象持有,而 ary 對(duì)象是全局的不會(huì)被回收,導(dǎo)致了內(nèi)存泄漏(如果這種行為不符合預(yù)期的話(huà))。返回去,如果這個(gè)時(shí)候你選擇上面提到的 system/Context 構(gòu)造器,你會(huì)看到(見(jiàn)圖 16,熟悉吧):
圖 16 system/Context
也就是你選擇的 system/Context 其實(shí)是 inner() 閉包的上下文對(duì)象(context),而此上下文持有了 192KB 內(nèi)存,通過(guò)藍(lán)色的 index.html:xx 又可以定位到問(wèn)題代碼了。如果你像圖 17 一樣選擇了 Date 構(gòu)造器進(jìn)行查看的話(huà)也可以最終定位到問(wèn)題,此處將分析過(guò)程留給讀者自己進(jìn)行:
圖 17 選中 Date 構(gòu)造器
3.Detached DOM
我們先看一下下面的代碼,并用 chrome 載入它:
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8" />
- <title>memory-leak</title>
- </head>
- <body>
- <p>add Date: <button class="push-date">add date</button></p>
- <p>delete button: <button class="del">del</button></p>
- <script>
- const addDate = document.querySelector(".push-date");
- const del = document.querySelector(".del");
- function add() {}
- addDate.addEventListener("click", add);
- del.addEventListener("click", function del() {
- addDate.remove();
- });
- </script>
- </body>
- </html>
代碼 4 Detached Dom
然后我們采用 Heap Snapshot 的方式將點(diǎn)擊“del”按鈕前后的兩個(gè) snapshot 錄制下來(lái),得到的結(jié)果如圖 6 所示。我們選用和 snapshot 1 對(duì)比的方式并在 snapshot 2 的過(guò)濾器中輸入 "detached"。我們觀察得到的篩選結(jié)果的 "Delta" 列,其中不為 0 的列如下:
Constructor | # Delta |
Detached HTMLButtonElement | +1 |
Detached EventListener | +1 |
Detached InternalNode | +2 |
Detached Text | +1 |
Detached V8EventListener | +1 |
要解釋上述表格需要先介紹一個(gè)知識(shí)點(diǎn):DOM 對(duì)象被回收需要同時(shí)滿(mǎn)足兩個(gè)條件,1、DOM 在 DOM 樹(shù)中被刪掉;2、DOM 沒(méi)有被 JS 對(duì)象引用。其中第二點(diǎn)還是比較容易被忽視的。正如上面的例子所示,Detached HTMLButtonElement +1 代表有一個(gè) button DOM 被從組件樹(shù)中刪掉了,但是仍有 JS 引用之(我們不考慮有意為之的情況)。
相似的,Detached EventListener 也是因?yàn)?DOM 被刪掉了,但是事件沒(méi)有解綁,于是 Detached 了,解決方案也很簡(jiǎn)單:及時(shí)解綁事件即可。
于是解決的方法就很簡(jiǎn)單了:參見(jiàn)代碼 5,回掉函數(shù) del 在執(zhí)行完畢時(shí)臨時(shí)變量會(huì)被回收,于是兩個(gè)條件就都同時(shí)滿(mǎn)足了,DOM 對(duì)象就會(huì)被回收掉,事件解綁了,Detached EventListener 也就沒(méi)有了。值得注意的是 table 元素,如果一個(gè) td 元素發(fā)生了 detached,則由于其自身引用了自己所在的 table,于是整個(gè) table 就也不會(huì)被回收了。
- <script>
- const del = document.querySelector(".del");
- function add() {}
- document.querySelector(".push-date").addEventListener("click", add);
- del.addEventListener("click", function del() {
- document.querySelector(".push-date").removeEventListener("click", add);
- document.querySelector(".push-date").remove();
- });
- </script>
代碼 5 Detached DOM 的解決方法
圖 18 Detached DOM 的 Snapshot
Performance monitor 工具
DOM/event listener 泄漏在編寫(xiě)輪播圖、彈窗、toast 提示這種工具的時(shí)候還是很容易出現(xiàn)的,chrome 的 devtools 中有一個(gè) Performance monitor 工具可以用來(lái)幫助我們調(diào)查內(nèi)存中是否有 DOM/event listener 泄漏。首先看一下代碼 6:
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8" />
- <title>memory-leak</title>
- </head>
- <body>
- <p>add Date: <button class="push-date">add date</button></p>
- <div class="btn-list"></div>
- <script>
- const btnList = document.querySelector(".btn-list");
- const addDate = document.querySelector(".push-date");
- addDate.addEventListener("click", function del() {
- const btn = document.createElement("button");
- btn.innerHTML = "a btn";
- btnList.appendChild(btn);
- });
- </script>
- </body>
- </html>
代碼 6 不斷增加 DOM NODE
按照我們圖 19 的方式打開(kāi) Performance monitor 面版:
圖 19 打開(kāi) Performance monitor 工具
DOM Nodes 右側(cè)的數(shù)量是當(dāng)前內(nèi)存中的所有 DOM 節(jié)點(diǎn)的數(shù)量,包括當(dāng)前 document 中存在的和 detached 的以及計(jì)算過(guò)程中臨時(shí)創(chuàng)建的,每當(dāng)我們點(diǎn)擊一次“add date”按鈕,并手動(dòng)觸發(fā) GC 之后 DOM Nodes 的數(shù)量就 + 2,這是因?yàn)槲覀兿? document 中增加了一個(gè) button 節(jié)點(diǎn)和一個(gè) button 的文字節(jié)點(diǎn),就像圖 20 中所示。如果你寫(xiě)的 toast 組件在臨時(shí)插入到 document 并過(guò)一會(huì)兒執(zhí)行了 remove 之后處于了 detached 狀態(tài)的話(huà),Performance monitor 面版中的 DOM Nodes 數(shù)量就會(huì)不斷增加,結(jié)合 snapshot 工具你便可以定位到問(wèn)題所在了。值得一提的是,有的第三方的庫(kù)的 toast 便存在這個(gè)問(wèn)題,不知道你被坑過(guò)沒(méi)有。
圖 20 不斷增加的 DOM Nodes
4.console
這一點(diǎn)可能有人不會(huì)留意到,控制臺(tái)打印的內(nèi)容是需要始終保持引用的存在的,這一點(diǎn)也是值得注意的,因?yàn)榇蛴∵^(guò)多過(guò)大對(duì)象的話(huà)也是會(huì)造成內(nèi)存泄漏的,如圖 21 所示(配合代碼 7)。解決方法便是不要肆意打印對(duì)象到控制臺(tái)中,只打印必要的信息出來(lái)。
- <!DOCTYPE html>
- <html lang="en">
- <head>
- <meta charset="UTF-8" />
- <title>memory-leak</title>
- </head>
- <body>
- <p>add Date: <button class="push-date">add date</button></p>
- <div class="btn-list"></div>
- <script>
- const addDate = document.querySelector(".push-date");
- addDate.addEventListener("click", function del() {
- const tmp = Array(3_000)
- .fill()
- .map(() => new Date());
- console.info(tmp);
- });
- </script>
- </body>
- </html>
代碼 7 console 導(dǎo)致內(nèi)存泄漏
圖 21 console 導(dǎo)致的內(nèi)存泄漏
三、總結(jié)
本文用了幾個(gè)簡(jiǎn)單的小例子介紹了內(nèi)存泄漏出現(xiàn)的時(shí)機(jī)、尋找泄漏點(diǎn)的方法并將各種方法的優(yōu)缺點(diǎn)進(jìn)行了對(duì)比,總結(jié)了避免出現(xiàn)內(nèi)存泄漏的注意點(diǎn)。希望能對(duì)讀者有所幫助。文中如果有本人理解錯(cuò)誤或書(shū)寫(xiě)錯(cuò)誤的地方歡迎留言指正。
參考
https://commandlinefanatic.com/cgi-bin/showarticle.cgi?article=art038
https://developer.chrome.com/docs/devtools/memory-problems/
https://www.bitdegree.org/learn/chrome-memory-tab
【本文為51CTO專(zhuān)欄作者“阿里巴巴官方技術(shù)”原創(chuàng)稿件,轉(zhuǎn)載請(qǐng)聯(lián)系原作者】