推薦一個檢測 JavaScript 內(nèi)存泄漏的神器
大家好,我是 ConardLi?。作為一名 Web? 應(yīng)用程序開發(fā)者,排查和修復(fù) JavaScript 代碼的內(nèi)存泄漏一直是最困擾我的問題之一。
最近,Meta? 開源了一款檢測 JavaScript? 代碼內(nèi)存泄漏的框架:MemLab,我們來一起看看這個框架有啥神奇之處吧~

2020? 年,Meta? 的工程師將 Facebook.com? 重構(gòu)為了單頁應(yīng)用(SPA?),程序的大部分渲染和導(dǎo)航都會在客戶端使用 JavaScript? 完成。后來他們又使用類似的架構(gòu)來重構(gòu)了 Meta? 的大多數(shù)其他流行的網(wǎng)絡(luò)應(yīng)用程序,包括 Instagram? 和 Workplace?。雖然這種架構(gòu)能夠提供更快的用戶交互、更好的開發(fā)者體驗和更像原生應(yīng)用程序的感覺,但是在客戶端維護 Web 應(yīng)用的狀態(tài)會讓內(nèi)存的管理變得更加復(fù)雜。
使用 Meta? 網(wǎng)站的用戶經(jīng)常會快速注意到一些性能和功能正常使用的問題。然而,內(nèi)存泄漏就是另一回事了。它不會立即被察覺出來,因為它一次會占用一大塊內(nèi)存 — 然后逐漸影響整個 Web 會話并讓后續(xù)的交互和響應(yīng)變得更慢。
Meta? 的工程師花費了大量時間來測試、優(yōu)化和控制頁面加載和交互時間,以及 JavaScript? 的代碼大小。相比之下,他們在管理 Web? 瀏覽器內(nèi)存方面做的工作并不多。當分析新 Facebook.com? 的內(nèi)存使用情況時,發(fā)現(xiàn)客戶端的內(nèi)存使用情況和內(nèi)存不足 (OOM) 崩潰的數(shù)量一直在攀升。較高的內(nèi)存使用對頁面加載、交互性能、用戶參與度等核心指標都有負面影響。
為了幫助開發(fā)者解決這個問題,Meta? 的工程師構(gòu)建了 MemLab?,這是一個 JavaScript? 內(nèi)存測試框架,可以自動進行內(nèi)存泄漏檢測,并且更容易找到內(nèi)存泄漏的根本原因。Meta? 使用 MemLab 成功地控制了不可持續(xù)的內(nèi)存增長,并識別出了產(chǎn)品和基礎(chǔ)設(shè)施中的內(nèi)存泄漏和內(nèi)存優(yōu)化的一些手段。
導(dǎo)致 Web 應(yīng)用內(nèi)存過高的原因
因為內(nèi)存泄漏通常不是很明顯,在開發(fā)過程中,以及做 Code Review? 的時候都很難發(fā)現(xiàn),而且在生產(chǎn)環(huán)境中通常也很難找到根本原因。雖然主流的 JavaScript 運行時都有垃圾回收機制,那么為什么還會有內(nèi)存泄漏呢?
JavaScript 代碼中可能會有很多隱藏對象的引用,而隱藏的引用會以許多意想不到的方式導(dǎo)致內(nèi)存泄漏。
例如:
var obj = {};
console.log(obj);
obj = null;
在 Chrome? 中,即使我們將引用設(shè)置為 null? ,這段代碼也會泄漏 obj? 。發(fā)生這種情況是因為 Chrome? 需要保留對打印對象的內(nèi)部引用,以便以后可以在 Web 控制臺中對其進行檢查(即使在 Web 控制臺沒打開的情況下)。
在某些情況下,內(nèi)存在技術(shù)上并沒有發(fā)生泄漏,而是在用戶會話期間線性增長而且沒有限制。最常見的原因是客戶端緩存沒有內(nèi)置任何釋放的邏輯,無限滾動列表沒有任何虛擬化的功能,無法在添加新內(nèi)容時從列表中刪除較早的內(nèi)容。
我們也沒有適當?shù)淖詣踊到y(tǒng)和流程來控制內(nèi)存,因此防止此類問題的唯一防御措施就是專家通過 Chrome DevTools 定期挖掘內(nèi)存泄漏,一些大型的項目幾乎每天都會有發(fā)布和變更,這樣的工作方式是不可持續(xù)的。
MemLab 的工作原理
MemLab? 通過預(yù)定義的測試場景運行無頭瀏覽器并比較和分析 JavaScript 堆快照來發(fā)現(xiàn)內(nèi)存泄漏的問題。

這個過程可以分為下面六個步驟:
1.「瀏覽器交互」:MemLab? 使用 Puppeteer 自動化瀏覽器,在目標頁面上查找泄露的對象;
2.「區(qū)分堆」:導(dǎo)航到一個頁面然后離開它,正常情況下該頁面分配的大部分內(nèi)存也應(yīng)該被釋放,如果沒有,可能暗示著存在內(nèi)存泄漏。MemLab? 通過區(qū)分 JavaScript? 堆并記錄在頁面 B? 上分配的一組對象,這些對象沒有在頁面 ?A 上分配,但在重新加載頁面 A 時仍然存在,從而發(fā)現(xiàn)潛在的內(nèi)存泄漏;
3.「細化內(nèi)存泄漏列表」:內(nèi)存泄漏檢測器進一步結(jié)合了特定框架的知識來細化泄漏對象的列表。例如,React? 分配的 Fiber? 節(jié)點(React? 用于渲染虛擬 DOM 的內(nèi)部數(shù)據(jù)結(jié)構(gòu))應(yīng)該在我們訪問多個選項卡后清理時釋放。
4.「生成 retainer traces」:遍歷堆并為每個泄漏的對象生成 retainer traces? 。trace? 顯示了泄漏對象為何以及如何在內(nèi)存中保持活動狀態(tài)。打破引用鏈意味著泄漏的對象將不再可以從 GC? 的根訪問,因此可以進行垃圾回收。通過一步步地跟蹤,就可以找到應(yīng)該設(shè)置為 null 的引用;
5.「聚合 retainer traces」:將所有 retainer traces? 聚集在一起,并為每個共享相似 retainer traces 的泄漏對象聚合顯示為一個跟蹤,其中還包括調(diào)試信息,例如支配節(jié)點和保留大小。
6.「報告泄漏」:定期運行 MemLab?,以持續(xù)收集 retainer traces?,任何新的 traces? 都會記錄到內(nèi)部儀表板,開發(fā)者可以查看每個內(nèi)存泄漏的 retainer traces 上的對象屬性。
MemLab 有哪些能力
內(nèi)存泄漏檢測
對于瀏覽器內(nèi)存泄漏的檢測,MemLab? 需要開發(fā)者提供的唯一輸入就是一個測試場景文件,這個文件定義了如何通過使用 Puppeteer API? 和 CSS? 選擇器覆蓋三個回調(diào)來與網(wǎng)頁交互。MemLab? 會自動區(qū)分 JavaScript 堆、優(yōu)化內(nèi)存泄漏并聚合結(jié)果。

JavaScript 堆的 Graph-view API
MemLab? 支持一個自定義的泄漏檢測器,作為篩選器回調(diào),應(yīng)用于每個由目標交互分配的泄漏候選對象,但之后從不釋放。泄漏過濾器回調(diào)函數(shù)可以遍歷堆并確定哪些對象是內(nèi)存泄漏。例如,我們的內(nèi)置檢漏器會跟蹤 React Fiber? 節(jié)點的返回鏈路,檢查 Fiber? 節(jié)點是否與 React Fiber 樹分離。

為了分析每個可能內(nèi)存泄漏的上下文,MemLab? 提供了一個 JavaScript? 堆的內(nèi)存效率圖。這可以在不了解 V8? 堆快照文件結(jié)構(gòu)的任何領(lǐng)域知識的情況下查詢和遍歷 JavaScript 堆。
在視圖中,堆中的每個 JavaScript? 對象或原生對象都是一個圖節(jié)點,堆中的每個 JavaScript? 引用都是一個圖的邊。實際應(yīng)用程序的堆大小通常很大,因此圖視圖需要在提供直觀的面向?qū)ο蠖驯闅v API? 的同時提高內(nèi)存效率。因此,圖節(jié)點被設(shè)計成了虛擬的,不通過 JavaScript? 引用進行連接。當分析代碼遍歷堆時,虛擬圖會部分地即時構(gòu)建圖的接觸部分。圖的任何部分都可以很容易地釋放,因為這些虛擬節(jié)點彼此之間沒有 JavaScript 引用。
堆視圖可以從基于 Chromium? 的瀏覽器、Node.js、Electron? 和 Hermes? 獲取的 JavaScript? 堆快照加載。這允許分析復(fù)雜的模式并回答諸如 “有多少 React Fiber? 節(jié)點是備用的 Fiber 節(jié)點,它們用于不完整的并發(fā)渲染?”之類的問題。
import {getHeapFromFile} from '@memlab/heap-analysis';
const heapGraph = await getHeapFromFile(heapFile);
heapGraph.nodes.forEach(node => {
// heap node traversal
node.type
node.references
);
內(nèi)存斷言
Node.js? 程序或 Jest? 測試也可以使用 graph-view API 來獲取其自身狀態(tài)的堆視圖,進行自內(nèi)存檢查,并編寫各種內(nèi)存斷言。
import type {IHeapSnapshot} from '@memlab/core';
import {config, takeNodeMinimalHeap, tagObject} from '@memlab/core';
test('memory test', async () => {
config.muteConsole = true;
const o1 = {};
let o2 = {};
// tag o1 with marker: "memlab-mark-1", does not modify o1 in any way
tagObject(o1, 'memlab-mark-1');
// tag o2 with marker: "memlab-mark-2", does not modify o2 in any way
tagObject(o2, 'memlab-mark-2');
o2 = null;
const heap: IHeapSnapshot = await takeNodeMinimalHeap();
// expect object with marker "memlab-mark-1" exists
expect(heap.hasObjectWithTag('memlab-mark-1')).toBe(true);
// expect object with marker "memlab-mark-2" can be GCed
expect(heap.hasObjectWithTag('memlab-mark-2')).toBe(false);
}, 30000);
內(nèi)存工具箱
除了內(nèi)存泄漏檢測,MemLab? 還包括一組內(nèi)置的 CLI? 命令和 API,用于尋找可能的內(nèi)存優(yōu)化機會:


Meta 使用 MemLab 的實踐
在過去的幾年中,Meta? 一直在使用 MemLab 檢測和診斷內(nèi)存泄漏,并收集了很多有助于優(yōu)化內(nèi)存、減少 OOM 崩潰并改善用戶體驗的手段。

在 2021? 年上半年, Facebook.com? 上的 OOM? 崩潰減少了 50%。
React Fiber 節(jié)點清理
為了渲染組件,React? 構(gòu)建了 Fiber? 樹 — 一個 React? 用于渲染虛擬 DOM? 的內(nèi)部數(shù)據(jù)結(jié)構(gòu)。雖然 Fiber? 樹看起來像一棵樹,但它是一個雙向圖,將所有 Fiber? 節(jié)點、React? 組件實例和關(guān)聯(lián)的 HTML DOM? 元素強連接起來。理想情況下,React? 維護對組件 Fiber? 樹的根的引用,并防止 Fiber? 樹被垃圾回收。當一個組件被卸載時,React 會斷開組件的根與 Fiber 樹的其余部分之間的連接,然后這些部分就可以被垃圾回收了。
擁有這樣的強連接圖的缺點是,如果有任何外部引用指向圖的任何部分,就無法對整個圖進行垃圾回收。例如,下面 export? 語句在模塊范圍級別緩存 React? 組件,因此相關(guān)的 Fiber? 樹和分離的 DOM 元素永遠不會被釋放。
export const Component = ((
<List> </List>
): React.Element<typeof List>);
也不僅僅是 React 數(shù)據(jù)結(jié)構(gòu)要 keep alive? ,Hooks? 和它們的閉包也可以讓各種其他對象?;睢_@意味著單個 React 組件泄漏可能會導(dǎo)致頁面對象的重要部分泄漏,從而導(dǎo)致巨大的內(nèi)存泄漏。

為了防止 Fiber? 樹中內(nèi)存泄漏的級聯(lián)效應(yīng),MemLab? 添加了一個樹的完整遍歷,當組件在 React 18? 中卸載時會進行清理。這可以讓垃圾回收器在清理未掛載的樹方面做得更好一點。這個優(yōu)化將 Facebook? 上的平均內(nèi)存使用量減少了近 25%?,其他使用 React? 的站點在升級時也有了很大的改進。你可能會擔心這種比較激進的清理方式可能會減慢 React 組件的卸載速度,但令人驚訝的是,由于內(nèi)存的減少,性能也有顯著的提升。
string interning
通過利用 MemLab? 中的 heap analysis API,Meta? 團隊發(fā)現(xiàn)字符串占據(jù)了 70%? 的堆內(nèi)存,其中一半的字符串至少有一個重復(fù)的實例。(V8? 對 string interning 支持的不是很好,這是一種對具有相同值的字符串實例進行重復(fù)數(shù)據(jù)刪除的優(yōu)化。)
另外很大一部分字符串內(nèi)存被 Relay? 中緩存的鍵字符串消耗。通過與 Relay? 和 React Apps? 團隊合作,可以在客戶端插入和縮短過長的字符串鍵來優(yōu)化 Relay 緩存鍵字符串。
這種優(yōu)化使 Relay? 能夠緩存更多數(shù)據(jù),允許站點向用戶顯示更多內(nèi)容,尤其是在客戶端 RAM? 有限的情況下。內(nèi)存 p99? 和 OOM? 崩潰減少了 20%,頁面渲染速度更快,用戶體驗得到改善,在收入上也有一定提升。
試用 MemLab:
npm i -g memlab
最后:MemLab Github:https://github.com/facebookincubator/memlab















 
 
 












 
 
 
 