在 Web 應用程式中除錯記憶體洩漏是很難的。工具是存在的,但是它很複雜,很麻煩,而且往往不能回答簡單的問題。為什麼我的應用程式會洩漏記憶體?
正因為如此,我敢打賭,大多數網路開發人員都沒有積極監測記憶體洩漏。當然,如果你不對某樣東西進行測試,就很容易出現漏洞。
當我第一次開始研究記憶體洩漏時,我認為這是一件罕見的事情。JavaScript–一種具有自動垃圾收集器的語言–怎麼可能是記憶體洩露的一大來源?但我瞭解得越多,我就越懷疑記憶體洩露在單頁應用程式(SPA)中其實是很常見的–只是沒有人對它進行測試而已!”。
由於大多數網頁開發人員並不是為了好玩而擺弄 Chrome 瀏覽器的記憶體工具,他們可能不會注意到洩漏,直到瀏覽器標籤出現Out Of Memory錯誤而崩潰,或者頁面變慢,或者有人碰巧開啟任務管理器,發現一個網站正在使用許多兆位元組(甚至是千兆位元組!)的記憶體。但在這一點上,情況已經變得足夠糟糕,在同一個頁面上可能有多個洩漏。
我過去曾寫過關於記憶體洩露的文章,但我的建議基本上歸結為 “使用Chrome開發工具,遵循這十幾個繁瑣的步驟,然後也許你能找出你的頁面洩漏的原因。” 這不是一個很好的開發者體驗,我相信很多讀者只是絕望地搖搖頭,然後繼續前進。如果一個工具能自動找到記憶體洩漏,那就更好了。
這就是為什麼我寫了fuite(法語中的 “洩漏”)。fuite是一個CLI工具,你可以指向任何URL,它將分析頁面的記憶體洩漏。
npx fuite https://example.com
這就是了! 預設情況下,它假定該網站是一個客戶端渲染的SPA,它將抓取頁面的內部連結(如/關於或/聯絡)。然後,對於每個連結,它都會執行以下步驟。
- 點選該連結
- 按下瀏覽器的返回按鈕
- 重複進行,看看記憶體是否增長了
如果fuite發現任何洩漏,它將顯示哪些物件被懷疑導致洩漏。
Test : Go to /foo and back
Memory change: +10 MB
Leak detected: Yes
Leaking objects:
| Object | # added | Retained size increase |
| ----------------- | ------- | ---------------------- |
| HTMLIFrameElement | 1 | +10 MB |
Leaking event listeners:
| Event | # added | Nodes |
| ------------ | ------- | ------ |
| beforeunload | 2 | Window |
Leaking DOM nodes:
DOM size grew by 6 node(s)
為了做到這一點,fuite使用了我的部落格中概述的基本策略。它將啟動 Chrome 瀏覽器,執行一些場景 n 次(預設為7次),看看是否有任何物件洩露了 n 次的倍數(7,14,21,等等)。
fuite 還將分析任何陣列、物件、Maps、集合、事件監聽器和整個DOM,看其中是否有洩漏的情況。例如,如果一個陣列在7次迭代後正好增長了7,那麼它就可能是洩露了。
測試真實世界的網站
有點令人驚訝的是,點選內部連結和按下返回按鈕的 “基本 “場景足以在許多SPA中發現記憶體洩漏。我在10個流行的前端框架的主頁上測試了fuite,發現所有這些網站都有洩漏。
| Site | Leak detected | 內部連結 | 平均成長 | 最大成長 |
|---|---|---|---|---|
| Site 1 | yes | 8 | 27.2 kB | 43 kB |
| Site 2 | yes | 10 | 50.4 kB | 78.9 kB |
| Site 3 | yes | 27 | 98.8 kB | 135 kB |
| Site 4 | yes | 8 | 180 kB | 212 kB |
| Site 5 | yes | 13 | 266 kB | 1.07 MB |
| Site 6 | yes | 8 | 638 kB | 1.15 MB |
| Site 7 | yes | 7 | 1.37 MB | 2.25 MB |
| Site 8 | yes | 15 | 3.49 MB | 4.28 MB |
| Site 9 | yes | 43 | 5.57 MB | 7.37 MB |
| Site 10 | yes | 16 | 14.9 MB | 186 MB |
在這種情況下,”內部連結 “指的是測試的內部連結數量,”平均成長 “指的是每個連結的平均記憶體成長(即點選它然後按下返回按鈕),而 “最大成長 “指的是哪一個內部連結的洩漏量最大。請注意,這些數字不包括一次性的設定成本,因為fuite在正常的7次迭代之前會進行一次預檢迭代。
要想自己確認這些結果,你可以使用Chrome DevTools的記憶體標籤。下面是我這一組中表現最差的網站的截圖,我點選一個連結,按下返回按鈕,拍下一個堆快照,然後重複。

為了避免點名和羞辱,我沒有列出實際的網站。重點是展示一些流行的SPA的代表性樣本–這些網站的作者可以自由地自己執行 fuite 並追蹤這些洩漏。(請這樣做!)。
注意事項
但請注意,並不是 SPA 中的每一個洩漏都是需要解決的惡劣問題。例如,SPA 需要維護焦點和滾動狀態,以正確支援可訪問性,這意味著可能有一些小的元資料被儲存在每個頁面導航中。Fuite會盡職地報告這種洩漏(因為它們是洩漏),但這取決於開發者決定一個微小的洩漏是否值得追逐。
一些記憶體的成長也可能是由於瀏覽器內部的變化(比如JITing),而網頁並不能真正控制這些變化。因此,記憶體成長的數字並不能完美地衡量你透過修復洩漏所獲得的收益–很有可能幾千位元組的成長是不可避免的。(儘管 fuite 試圖忽略瀏覽器內部的成長,而且只有在對網頁開發者有可操作的建議時才會說 “檢測到洩漏”)。
在極少數情況下,一些記憶體成長也可能是由於直接的瀏覽器bug造成的。在分析上面的網站時,我發現有一個網站(4號網站)似乎由於 <img loading=”lazy”> 沒有被解除安裝而受到 Chrome 瀏覽器 bug 的影響。不幸的是,Fuite很難檢測到瀏覽器的bug,所以如果你對一個漏洞感到神秘,最好是與其他瀏覽器進行交叉檢查
還要注意的是,多頁面應用程式(MPA)幾乎不可能發生洩漏,因為瀏覽器會在每個頁面導航時清除記憶體。(在我的測試中,我發現有兩個前端框架的主頁是MPA,毫不奇怪,fuite在它們身上找不到任何洩漏。這些被排除在上面的結果之外。
記憶體洩漏對SPA來說是一個更大的問題,因為在每次導航時記憶體不會被自動清除。
fuite 目前只測量頁面主框架中的 JavaScript Heap 記憶體,所以跨源iframe、Web Worker和Service Workers不被測量。像 performance.measureUserAgentSpecificMemory() 這樣的東西會更準確,但它只在 cross-origin isolated 情況下可用,所以現在對於一個通用工具來說並不實用。
其他記憶體洩漏的情況
fuite 是建立在 Puppeteer 之上的,所以對於你想測試的任何情境,你基本上只需要寫一個 Puppeteer 指令碼來告訴瀏覽器該怎麼做。你可能會測試的一些常見場景是。
- 開啟一個 modal 的對話方塊,然後關閉它
- 將滑鼠懸停在一個元素上,顯示一個工具提示,然後將滑鼠移開,使其失效
- 滾動瀏覽一個無限載入的列表,然後離開並返回
- 等等。
在每一種情況下,你都會期望記憶體在之前和之後都是一樣的。但當然,對於 Web 應用程式來說,這並不總是那麼簡單 你可能會驚訝於你的對話方塊和工具提示中有多少記憶體洩漏。
為了分析洩漏,fuite 捕捉了 heap snapshot files,你可以在 Chrome DevTools 中載入這些檔案來檢查。它還有一個–除錯模式,你可以用它來進行更精細的分析:在測試過程中逐步進行測試,即時除錯瀏覽器,分析洩漏的物件等等。
在引擎蓋下,fuite 是一個相當基本的工具,我不會宣稱它可以完成修復記憶體洩露的100%的工作。仍然存在著人為的成分,即弄清楚為什麼你的物件被分配和保留,然後找到一個合理的修復方法。但我的目標是將 95% 的工作自動化,這樣一來,修復 Web 應用程式中的記憶體洩露實際上就可以實現。
你可以在 GitHub 上找到fuite。尋漏快樂
更新:我做了一個影片教程,展示如何用fuite除錯記憶體洩露。