許多開發者可能平時並不關心自己維護的頁面是否存在內存洩漏,原因可能是剛開始簡單的頁面內存洩漏的速度很緩慢,在造成嚴重卡頓之前可能就被用戶刷新了,問題也就被隱藏了,但是隨著頁面越來越複雜,尤其當你的頁面是SAP 方式交互時,內存洩漏的隱患便越來越嚴重,直到突然有一天用戶反饋說:“操作一會兒頁面就卡住不動了,也不知道為什麼,以前不這樣的呀」。
這篇文章透過一些簡單的例子介紹記憶體洩漏的調查方法、總結記憶體洩漏出現的原因和常見情況,並針對每種情況總結如何避免記憶體洩漏。希望能對大家有幫助。
推薦影片:《javascript基礎教學》
一、一個簡單的例子
先看一個簡單的例子,以下是這個範例對應的程式碼:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <title>memory-leak</title> </head> <body> <p>push date for <button>0</button> times</p> <p>add Date: <button>add date</button></p> <p>clear: <button>clear</button></p> <script> const pushDate = document.querySelector(".push-date"); const dateCount = document.querySelector(".count-date"); let dateAry = []; let dateNum = 0; // 【写入 date】 pushDate.addEventListener("click", () => { dateCount.innerHTML = `${++dateNum}`; for (let j = 0; j < 3000; ++j) { dateAry.push(new Date()); } }); const clear = document.querySelector(".clear"); // 【回收内存】 clear.addEventListener("click", () => { dateAry = []; dateCount.innerHTML = "0"; }); </script> </body> </html>
程式碼1
程式碼1 的邏輯很簡單:點擊「add date」按鈕時會向dateAry 陣列中push 3000 個new Date 對象,點選「clear」按鈕時將dateAry 清空。很明顯,「add date」操作會造成記憶體佔用不斷增長,如果將這個邏輯用在實際應用中便會造成內存洩漏(不考慮故意將代碼邏輯設計成這樣的情況),下面我們看一下如何調查這種內存增長出現的原因以及如何找出內存洩漏點。
1 heap snapshot
為了避免瀏覽器外掛程式的干擾,我們在 chrome 中新建一個無痕視窗開啟上述程式碼。然後在chrome 的devtools 中的Memory 工具中找到「Heap Snapshot」工具,點擊左上角的錄製按鈕錄製一個Snapshot,然後點擊「add date」按鈕,在手動觸發GC(Garbage Collect)之後,再次錄製一個Snapshot,重複執行上述操作若干次,像圖1 中操作的那樣,得到一系列的Snapshot。
圖1 錄製Snapshot
#圖2 是我們剛剛得到的Snapshot 群組,其中的第一個是頁面初始載入的時候錄製的,不很難發現,從第二個開始,每個Snapshot 相比於上一個其大小都增加了約200KB,我們點擊選擇Snapshot 2,在class filter 輸入框中處輸入date,可以得到Snapshot 2 中所有被Date 構造器建構出來的JS 對象,也就是Date 對象,這裡看到的建構器跟瀏覽器內部的實作有關,不必跟JS 的對象對應。
選取一個Date 對象,在下面的面板中可以看到所選對象的持有鏈以及相關持有對象的記憶體的保留大小(Retained Size),從圖中可以看出選取的Date物件是Array 的第1 個元素(index 從0 開始),而這個Array 的持有者是system/Context 上下文中的dateAry,system/Context 上下文就是程式碼中script 標籤的上下文,我們可以看到在這個dataAry的保留大小是197KB,我們再切到Snapshot 3,用相同的方式查看內存持有和大小,可以發現Snapshot 3 中的dataAry 的保留大小變成了386KB,相比於Snapshot 2 增漲了約200KB!逐一比較後面的 Snapshot 4、5 後也能得到相同的對比結果,即下一個 Snapshot 中的 dateAry 比上一個的保留大小大約 200KB。
圖2 錄製的Snapshot 群組
#參考【程式碼1】我們可以知道,「add date」按鈕在點擊時,會向dateAry 陣列中push 3000 個新的Date 對象,而在圖2 中的Date 建構器的右側可以看到這3000 個Date 對象(Date x 3000),它對應的正式我們的循環創建的那3000 個Date 對象。綜合上面的操作我們可以知道,chorome devtools 中的Memroy 的Heap Snapshot 工具可以錄製某一個時刻的所有內存對象,也就是一個“快照”,快照中按“構造器”分組,展示了所有被記錄下來的JS 對象。
如果這個頁面是一個實際服務於使用者的網站的某個頁面話(使用者可能非常頻繁的點擊「add date」按鈕,作者可能想記錄使用者點擊的次數?也許吧,雖然我也不知道他什麼要這麼做)隨著使用者使用時間的成長,「add date」按鈕的反應就會越來越慢,整體頁面也隨之越來越卡,原因除了系統的記憶體資源被佔用之外,還有GC 的頻率和時長增長,如圖3 所示,因為GC 執行的過程中JS 的執行是被暫停的,所以頁面就會呈現出越來越卡的樣子。
图 3 Performance 录制的 GC 占比
图 4 chrome 的任务管理器
最终:
图 5 内存占用过高导致浏览器崩溃
那么,在这个“实际”的场景下,如何找出那“作祟”的 3000 个 Date 对象呢?我们首先想到的应该是就是:之前不是录制了好多个 Snapshot 吗?可不可以把它们做对比找到“差异”呢,从差异中找到增长的地方不就行了?思路非常正确,在此之前我们再分析一下这几个 Snapshot:每次点击“add date”按钮、手动触发 GC、得到的 Snapshot 的大小相比上一次都有所增加,如果这种内存的增长现象不符合“预期”的话(显然在这个“实际”的例子中是不符合预期的),那么这里就有很大的嫌疑存在内存泄漏。
这个时候我们选中 Snapshot 2,在图 2 所示的 " Summary" 处选择“Comparison”,在右侧的 "All objects" 处选择 Snapshot 1,这样一来,Constructor 里展示便是 Snapshot 1 和 Snapshot 2 的对比,通过观察不难发现,图中的 +144KB 最值得怀疑,于是我们选中它的构造器 Date,展开选中任意子项看详情,发现其是被 Array 构造器构造出来的 dateAry 持有的(即 dateAry 中的一员),并且 dateAry 被三个地方持有,其中系统内部的 array 我们不用理会,图 6 中写有 "context in ()" 地方给了我们持有 dateAry 的 context 所在的位置,点击便可以跳到代码所在的位置了,整个操作如图 6 所示:
图 6 定位代码位置
这里有一个值得注意的地方,图 6 中的 “context in () @449305” 中的 "()",这里之所以展示为了 "()" 是因为代码中用了“匿名函数”(代码 2 中第 2 行的箭头函数):
// 【写入 date】 pushDate.addEventListener("click", () => { dateCount.innerHTML = `${++dateNum}`; for (let j = 0; j < 3000; ++j) { dateAry.push(new Date()); } });
代码 2 匿名函数
但是如果我们给函数起一个名字,如下面的代码所示,也就是如果我们使用具名函数(代码3 第 2 行函数 add)或者将函数赋值给一个变量并使用这个变量(第 10 和 18 行的行为)的时候,devtools 中都可以看到相应的函数的名字,这也就可以帮助我们更好的定位代码,如图 7 所示。
// 【写入 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"; }; // 【回收内存】 clear.addEventListener("click", doClear);
代码 3 具名函数
图 7 具名函数方便定位
这样我们便找到了代码可疑的地方,只需要将代码的作者抓过来对着他一顿“分析”这个内存泄漏的问题基本就水落石出了。
其实,Snapshot 除了“Comparison”之外还有一个更便捷的用于对比的入口,在这里直接可以看到在录制 Snapshot 1 和 Snapshot 2 两个时间点之间被分配出来的内存,用这种方式也可以定位到那个可疑的 Date x 3000:
图 8 Snapshot 比较器
上文件介绍的是用 Heap Snapshot 寻找内存泄漏点的方法,这个方法的优点:可以录制多个 Snapshot,然后方便的两两比较,并且能看到 Snapshot 中的全量内存,这一点是下文要讲的“Allocation instrumentation on timeline”方法不具备的,并且这种方法可以更加方便地查找后面会讲的因 Detached Dom 导致的内存泄漏。
2 Allocation instrumentation on timeline
但是,不知道你有没有觉得,这种高频率地录制 Snapshot、对比、再对比的方式有点儿麻烦?我需要不断的去点击“add date”,然后鼠标又要跑过去点击手动 GC、录制 Snapshot、等待录制完毕,再去操作,再去录制。有没有简单一些的方式来查找内存泄漏?这个时候我们回到 Memory 最初始的界面,你突然发现 “Heap snapshot”下面还有一个 radio:“Allocation instrumentation on timeline”,并且这个 radio 下面的介绍文案的最后写着:“Use this profile type to isolate memory leaks”,原来这是一个专门用于调查内存泄漏的工具!于是,我们选中这个 radio,点击开始录制按钮,然后将注意力放在页面上,然后你发现当点击“add date”按钮时,右面录制的 timeline 便会多出一个心跳:
图 9 Allocation instrumentation on timeline
如图 9 所示,每当我们点击“add date”按钮时,右面都有一个对应的心跳,当我们点击“clear”按钮时,刚才出现的所有心跳便全都“缩回”去了,于是我们得出结论:每一个“心跳”都是一次内存分配,其高度代表内存分配的量,在之后的时间推移过程中,如果刚才心跳对应的被分配的内存被 GC 回收了,“心跳”便会跟着变化为回收之后的高度。于是,我们便摆脱了在 Snapshot 中来回操作、录制的窘境,只需要将注意力集中在页面的操作上,并观察哪个操作在右边的时间线变化中是可疑的。
经过一系列操作,我们发现“add date”这个按钮的点击行为很可疑,因为它分配的内存不会自动被回收,也就是只要点击一次,内存就会增长一点,我们停止录制,得到了一个 timeline 的 Snapshot,这个时候如果我们点击某个心跳的话:
图 10 点击某个心跳
熟悉的 Date x 3000 又出现了(图 11),点击一个 Date 对象看持有链,接下来便跟上文 Snapshot 的持有链分析一样了:
图 11 通过 timeline 找到泄漏点
这个方法的优点上文已经说明,可以非常直观、方便的观察内存随可疑操作的分配与回收过程,可以方便的观察每次分配的内存。它的缺点:录制时间较长时 devtools 收集录制结果的时间会很长,甚至有时候会卡死浏览器;下文会讲到 detached DOM,这个工具不能比较出 detached DOM,而 heap snapshot 可以。
3 performance
devtools 中的 Performance 面版中也有一个 Memory 功能,下面看一下它如何使用。我们把 Memory 勾选上,并录制一个 performance 结果:
图 12 Performance 的录制过程
在图 12 中可以看到,在录制的过程中我们连续点击“add date”按钮 10 次,然后点击一次“clear”按钮,然后再次点击“add date” 10 次,得到的最终结果如图 13 所示:
图 13 Performance 的录制结果
在图 13 中我们可以得到下面的信息:
整个操作过程中内存的走势:参见图 13 下方的位置,第一轮点击 10 次的过程中内存不断增长,点 clear 之后内存断崖式下跌,第二轮点击 10 次内存又不断增长。这也是这个工具的主要作用:得到可疑操作的内存走势图,如果内存持续走高则有理由怀疑此操作由内存泄漏的可能。
内存的增长量:参见 JS Heap 位置,鼠标放上去可以看见每个阶梯上下位置的内存增长/下跌的量
通过在 timeline 中定位某个“阶梯”,我们也能找到可疑的代码,如图 14 所示:
图 14 通过 Performance 定位问题代码
这种方法的优点:可以直观得看到内存的总体走势,并且同时得到所有操作过程中的函数调用栈和时间等信息。缺点:没有具体的内存分配的细节,录制的过程不能实时看到内存分配的过程。
二 内存泄漏出现的场景
1 全局
JS 采用标记清扫法去回收无法访问的内存对象,被挂载在全局对象(在浏览器中即指的是 window 对象,在垃圾回收的角度上称其为根节点,也叫 GC root)上的属性所占用内存是不会被回收的,因为其是始终可以访问的,这也符合“全局”的命名含义。
解决方案就是避免用全局对象存储大量的数据。
2 闭包(closure)
我们把【代码 1】稍加改动便可以得到一个闭包导致内存泄漏的版本:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <title>memory-leak</title> </head> <body> <p>push date for <button>0</button> times</p> <p>add Date: <button>add date</button></p> <p>clear: <button>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; }; // 【写入 date】 pushDate.addEventListener("click", function add() { ary.push(wrap()); dateCount.innerHTML = `${ary.length}`; }); const clear = document.querySelector(".clear"); // 【回收内存】 clear.addEventListener("click", function clear() { ary = []; dateCount.innerHTML = `${ary.length}`; }); </script> </body> </html>
代码 3 闭包导致内存泄漏
将上述代码加载到 chrome 中,并用 timeline 的方式录制一个 Snapshot,得到的结果如图 15 所示:
图 15 闭包的录制结果
我们选中 index = 2 的心跳,可以看到 Constructor 里面出现了一个 "(closure)",我们展开这个 closure,可以看到里面的 "inner()",inner() 后面的 "()" 表示 inner 是一个函数,这时候你可能会问:“图中的 Constructor 的 Retained Size 大小都差不多,为什么你要选 (closure)?”,正是因为没有明显占比较高的 Retained Size 我们才随便选一个调查,后面你会发现不管你选了哪一个最后的调查链路都是殊途同归的。
我们在下面的 Retainers 中看下 inner() 的持有细节:从下面的 Retainers 中可以看出 inner() 这个 closure 是某个 Array 的第 2 项(index 从 0 开始),而这个数组的持有者是 system/Context(即全局) 中的 ary,通过观察可以看到 ary 的持有大小(Retained Size)是 961KB 大约等于 192KB 的 5 倍,5 即是我们点击“add date”按钮的次数,而下面的 5 个 "previous in system/Context" 每个大小都是 192KB,而它们最终都是被某个 inner() 闭包持有,至此我们便可以得出结论:全局中有一个 ary 数组,它的主要内存是被 inner() 填充的,通过蓝色的 index.html:xx 处的代码入口定位到代码所在地看一下一切就都了然了,原来是 inner() 闭包内部持有了一个大对象,并且所有的 inner() 闭包及其持有的大对象都被 ary 对象持有,而 ary 对象是全局的不会被回收,导致了内存泄漏(如果这种行为不符合预期的话)。返回去,如果这个时候你选择上面提到的 system/Context 构造器,你会看到(见图 16,熟悉吧):
图 16 system/Context
也就是你选择的 system/Context 其实是 inner() 闭包的上下文对象(context),而此上下文持有了 192KB 内存,通过蓝色的 index.html:xx 又可以定位到问题代码了。如果你像图 17 一样选择了 Date 构造器进行查看的话也可以最终定位到问题,此处将分析过程留给读者自己进行:
图 17 选中 Date 构造器
3 Detached DOM
我们先看一下下面的代码,并用 chrome 载入它:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <title>memory-leak</title> </head> <body> <p>add Date: <button>add date</button></p> <p>delete button: <button>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 的方式将点击“del”按钮前后的两个 snapshot 录制下来,得到的结果如图 6 所示。我们选用和 snapshot 1 对比的方式并在 snapshot 2 的过滤器中输入 "detached"。我们观察得到的筛选结果的 "Delta" 列,其中不为 0 的列如下:
要解释上述表格需要先介绍一个知识点:DOM 对象被回收需要同时满足两个条件,1、DOM 在 DOM 树中被删掉;2、DOM 没有被 JS 对象引用。其中第二点还是比较容易被忽视的。正如上面的例子所示,Detached HTMLButtonElement +1 代表有一个 button DOM 被从组件树中删掉了,但是仍有 JS 引用之(我们不考虑有意为之的情况)。
相似的,Detached EventListener 也是因为 DOM 被删掉了,但是事件没有解绑,于是 Detached 了,解决方案也很简单:及时解绑事件即可。
于是解决的方法就很简单了:参见代码 5,回掉函数 del 在执行完毕时临时变量会被回收,于是两个条件就都同时满足了,DOM 对象就会被回收掉,事件解绑了,Detached EventListener 也就没有了。值得注意的是 table 元素,如果一个 td 元素发生了 detached,则由于其自身引用了自己所在的 table,于是整个 table 就也不会被回收了。
<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 泄漏在编写轮播图、弹窗、toast 提示这种工具的时候还是很容易出现的,chrome 的 devtools 中有一个 Performance monitor 工具可以用来帮助我们调查内存中是否有 DOM/event listener 泄漏。首先看一下代码 6:
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <title>memory-leak</title> </head> <body> <p>add Date: <button>add date</button></p> <div></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 的方式打开 Performance monitor 面版:
图 19 打开 Performance monitor 工具
DOM Nodes 右侧的数量是当前内存中的所有 DOM 节点的数量,包括当前 document 中存在的和 detached 的以及计算过程中临时创建的,每当我们点击一次“add date”按钮,并手动触发 GC 之后 DOM Nodes 的数量就 + 2,这是因为我们向 document 中增加了一个 button 节点和一个 button 的文字节点,就像图 20 中所示。如果你写的 toast 组件在临时插入到 document 并过一会儿执行了 remove 之后处于了 detached 状态的话,Performance monitor 面版中的 DOM Nodes 数量就会不断增加,结合 snapshot 工具你便可以定位到问题所在了。值得一提的是,有的第三方的库的 toast 便存在这个问题,不知道你被坑过没有。
图 20 不断增加的 DOM Nodes
4 console
这一点可能有人不会留意到,控制台打印的内容是需要始终保持引用的存在的,这一点也是值得注意的,因为打印过多过大对象的话也是会造成内存泄漏的,如图 21 所示(配合代码 7)。解决方法便是不要肆意打印对象到控制台中,只打印必要的信息出来。
<!DOCTYPE html> <html> <head> <meta charset="UTF-8" /> <title>memory-leak</title> </head> <body> <p>add Date: <button>add date</button></p> <div></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 导致内存泄漏
图 21 console 导致的内存泄漏
三 总结
本文用了几个简单的小例子介绍了内存泄漏出现的时机、寻找泄漏点的方法并将各种方法的优缺点进行了对比,总结了避免出现内存泄漏的注意点。希望能对读者有所帮助。文中如果有本人理解错误或书写错误的地方欢迎留言指正。
参考
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
以上是出現JS記憶體洩漏怎麼辦?如何避免JS內存洩漏?的詳細內容。更多資訊請關注PHP中文網其他相關文章!