怎樣修復Web應用程序中的內存泄漏

做者:Nolan Lawson

翻譯:瘋狂的技術宅javascript

原文:https://nolanlawson.com/2020/...html

未經容許嚴禁轉載前端

從服務器端渲染的網站切換到客戶端渲染的 SPA 時,咱們忽然不得不更加註意用戶設備上的資源,必須作不少工做:不要阻塞 UI 線程,不要使筆記本電腦的風扇瘋狂旋轉,不要耗盡手機的電池等。咱們將交互性和「類應用程序」行爲轉換成了更好的新型問題,這些問題實際上並不存在在服務端渲染的世界中。java

這些問題中最主要的一個是內存泄漏。編碼不正確的 SPA 可能很容易耗盡 MB 甚至 GB 的內存,從而繼續吞噬愈來愈多的資源,即便它無辜地存在於後臺標籤中也是如此。這時頁面可能開始變成龜速,或者瀏覽器終止了標籤頁,你將會看到熟悉的 「Aw, snap!」 頁面。node

image.png

(固然,服務端渲染的網站也可能會泄漏服務器端的內存。可是客戶端泄漏內存的可能性很小,由於每次你在頁面之間導航時瀏覽器都會清除內存。)git

Web 開發文獻中沒有很好地解決內存泄漏問題的方法。可是,我很是肯定大多數不凡的 SPA 都會泄漏內存,除非它們背後的團隊擁有強大的基礎結構來捕獲和修復內存泄漏。用 JavaScript 太容易了,以致於不當心分配了一些內存而忘了清理它。程序員

那麼,爲何關於內存泄漏的文章這麼少呢?個人猜想是:github

  • 缺少抱怨:大多數用戶在上網時並未認真觀察 Task Manager。一般,除非泄漏嚴重到致使選項卡崩潰或程序運行緩慢,不然你不會從用戶那裏聽到有關它的消息。
  • 缺少數據:Chrome 小組不提供有關網站在使用大量內存的數據。網站也不是常常本身測量的。
  • 缺乏工具:用現有工具識別或修復內存泄漏仍然不容易。
  • 缺少關懷:瀏覽器很是擅長於殺死佔用過多內存的標籤頁。另外人們彷佛喜歡指責瀏覽器而不是網站。

在本文中,我想分享一些我在解決 Web 程序中的內存泄漏方面的經驗,並提供一些示例來講明如何有效地跟蹤它們。web

內存泄漏的剖析

像 React、Vue 和 Svelte 這樣的現代 Web 框架都使用基於組件的模型。在此模型中,產生內存泄漏的最多見方法是這樣的:面試

window.addEventListener('message', this.onMessage.bind(this));

就這樣,引入了一個內存泄漏。若是你在某些全局對象(window<body> 等)上調用 addEventListener 而後在卸載組件時忘記用 removeEventListener 進行清理,就會產生一個內存泄漏。

更糟糕的是,你剛剛泄漏了整個組件。因爲 this.onMessage 綁定到 this,因此組件已泄漏,包括其全部子組件。並且極可能全部與組件相關聯的 DOM 節點也是如此。這會很快會變得很是糟糕。

解決方法是:

// Mount phase
this.onMessage = this.onMessage.bind(this);
window.addEventListener('message', this.onMessage);

// Unmount phase
window.removeEventListener('message', this.onMessage);

注意,咱們保存了對綁定的 onMessage 函數的引用。你必須把前面傳給 addEventListener 的函數再原封不動的傳給 removeEventListener,不然它將沒法正常工做。

致使內存泄漏的狀況

以個人經驗,最多見的內存泄漏源與如下 API 相關:

  1. addEventListener。這是最多見的一種,調用 removeEventListener 進行清理。
  2. setTimeout / setInterval。若是你建立一個循環計時器(例如每 30 秒運行一次),則須要使用 clearTimeoutclearInterval 進行清理。(若是像 setInterval 那樣使用 setTimeout 可能會泄漏,即在 setTimeout 回調內部安排新的 setTimeout。)
  3. IntersectionObserverResizeObserverMutationObserver 等。這些新穎的 API 很是方便,但它們也可能泄漏。若是你在組件內部建立一個組件並將其附加到全局可用元素,則須要調用 disconnect() 進行清理。 (請注意,垃圾收集的 DOM 節點也將會對它的垃圾監聽器和觀察者進行垃圾收集。所以,一般你只須要擔憂全局元素,例如文檔、無所不在的頁眉和頁腳元素等)
  4. Promise, Observable, EventEmitter,等。若是你設置了偵聽器,但忘記了中止偵聽,則任何用於設置偵聽器的編程模型均可能會形成內存泄漏。 (若是 Promise 從未獲得解決或拒絕,則可能會泄漏,在這種狀況下,附加到它的任何 .then() 回調都會泄漏。)
  5. 全局對象存儲。Redux 之類的狀態是全局的,若是你不當心,能夠持續爲其添加內存,而且永遠都不會被清除。
  6. 無限的 DOM 增加。若是在沒有虛擬化的狀況下實現無限滾動列表,則 DOM 節點的數量將會無限增加。

固然,還有許多其餘致使泄漏內存的狀況,但這些是最多見的。

識別內存泄漏

這是困難的部分。首先我要說的是,我認爲那裏的任何工具都不是很好。我嘗試使用 Firefox 的內存工具,Edge 和 IE 內存工具,甚至 Windows Performance Analyzer。同類最佳的仍然是 Chrome Dev Tools,可是它有不少雜亂的細節值得咱們瞭解。

在 Chrome Dev Tools中,咱們選擇的主要工具是「內存(Memory)」標籤中的「堆快照(heap snapshot)」。 Chrome 中還有其餘存儲工具,但我發現它們對識別泄漏不是頗有幫助。

image.png

帶有堆快照工具的Chrome DevTools內存選項卡

堆快照工具使你能夠捕獲主線程、Web Worker 或 iframe 的內存。

當你點擊「獲取快照(take snapshot)」按鈕時,你已經捕獲了該網頁上特定 JavaScript VM 中的全部活動對象。這包括 window 所引用的對象,setInterval 回調所引用的對象等。可將其視爲時間暫停後,表明該網頁使用的全部內存。

下一步是重現你認爲可能正在泄漏的某些場景,例如,打開和關閉模態對話框。對話框關閉後,你但願內存恢復到上一級。所以,你獲取了另外一個快照,而後將其與上一個快照進行比較。這種差別確實是該工具的殺手級特性。

image.png
顯示第一個堆快照的示意圖,而後是一個泄漏的場景,而後是第二個堆快照,該快照應該等於第一個

可是,你應該注意該工具的一些限制:

  1. 即便單擊「收集垃圾(collect garbage)」小按鈕,你可能也須要爲 Chrome 連續產生多個快照才能真正清除未引用的內存。以個人經驗,三個就足夠了。 (檢查每一個快照的總內存大小——它最終應穩定下來。)
  2. 若是你有 Web worker、service worker、iframe、shared worker 等,則該內存將不會在堆快照中表示,由於它位於另外一個 JavaScript VM 中。你能夠根據須要捕獲此內存,但只需確保知道要測量的內存便可。
  3. 有時快照程序會卡住或崩潰。在這種狀況下,只需關閉瀏覽器選項卡,而後從新開始便可。

此時,若是你的程序很複雜,那麼可能會在兩個快照之間看到大量的泄漏對象。這是棘手的地方,由於並不是全部這些都是真正的泄漏。其中許多隻是正經常使用法——某些對象被取消分配,而另外一個對象被優先分配,某些對象以某種方式被緩存,以便稍後進行清理,等等。

消除噪音

我發現消除噪音的最好方法是屢次重複泄漏狀況。例如,你不只能夠執行一次打開和關閉模式對話框這種操做,還能夠將其打開和關閉 7 次。 (7 是一個質數。)而後你能夠檢查堆快照 diff,以查看是否有什麼對象泄漏7次。 (或14次或21次。)

image.png

Chrome開發者工具堆快照差別的截圖顯示了六個堆快照捕獲,其中有多個對象泄漏了7次

堆快照差別。請注意,咱們正在將 6 號快照與 3 號快照進行比較,由於我連續拍攝了三個快照,以便進行更多的垃圾收集。注意,有幾個對象泄漏了 7 次。

(另外一種有用的技術是在記錄第一個快照以前對方案進行一次遍歷。特別是若是你進行大量的代碼拆分,則方案可能會花費一次內存來加載必要的 JavaScript 模塊。)

你可能想知道爲何應該按對象數而不是總內存進行排序。直觀地講,咱們正在努力減小內存泄漏的數量,因此咱們不該該專一於總的內存使用狀況嗎?嗯,這不是很好,有一個很重要的緣由。

當什麼東西泄漏時,是由於你想要獲得香蕉,可是最終獲得的是香蕉、拿着香蕉的大猩猩以及整個叢林。若是你基於總字節數進行衡量,那麼你所衡量的是叢林,而不是香蕉。

讓咱們回到上面的 addEventListener 的例子。泄漏的來源是事件偵聽器,該事件偵聽器引用一個函數,該函數引用一個組件,該組件可能引用大量的東西,例如數組、字符串和對象。

若是你按總內存對堆快照差別進行排序,那麼它將向你顯示一堆數組、字符串和對象——其中大多數可能與泄漏無關。你真正想要找到的是事件偵聽器,可是與它所引用的內容相比,佔用的內存很小。要修復泄漏,你要找到香蕉,而不是叢林。

因此,若是按泄漏對象的數量進行排序,則會看到 7 個事件監聽器。多是 7 個組件和 14 個子組件等等。 「7」 應該像腰間盤同樣突出,由於它是一個不尋常的數字。並且,不管你重複該場景多少次,都應該確切的看到泄漏的對象數量。這樣能夠快速找到泄漏源。

retainer 樹

堆快照差別還將向你顯示一個 「retainer」 鏈,該鏈顯示哪些對象指向哪些其餘對象,從而使內存保持活動狀態。這樣能夠弄清楚泄漏對象的分配位置。

image.png
事件監聽器引用的閉包所引用的 someObject 的固定鏈

retainer 鏈將向你顯示哪一個對象正在引用泄漏的對象。讀取它的方式是每一個對象都由其下面的對象引用。

在上面的示例中,有一個名爲 someObject 的變量,該變量由閉包(也稱爲「上下文」)引用,並由事件偵聽器引用。若是單擊源連接,它將帶你到 JavaScript 聲明,這很簡單:

class SomeObject () { /* ... */ }

const someObject = new SomeObject();
const onMessage = () => { /* ... */ };
window.addEventListener('message', onMessage);

在上面的示例中,「上下文」是 onMessage 閉包,它引用了 someObject 變量。 (這是一我的爲的例子;實際的內存泄漏可能不那麼明顯!)

可是堆快照工具備幾個限制:

  1. 若是保存並從新加載快照文件,則全部文件引用都將會丟失到分配對象的位置。例如你不會看到在 foo.js 第 22 行的事件監聽器的關閉。因爲這是很是關鍵的信息,所以保存和發送堆快照文件幾乎沒有用。
  2. 若是涉及 WeakMap,那麼 Chrome 會向你顯示這些引用,即便它們不要緊——清除其餘引用後,將當即取消分配這些對象。因此它們只是噪音。
  3. Chrome 根據對象的原型來對對象進行分類。因此使用實際類或函數的次數越多,使用匿名對象的次數越少,則更容易看到泄漏的確切內容。例如排查泄漏是否因爲 object 而不是 EventListener 引發的。由於 object 很是通用,因此咱們不太可能看到其中有 7 個存在泄漏。

這是識別內存泄漏的基本策略。我過去已經成功地用這種技術發現了許多內存泄漏。

可是,本指南只是一個開始——除此以外,你還必須隨手設置斷點、記錄日誌並測試你的修復程序,以查看它是否能夠解決泄漏。不幸的是,這是一個很是耗時的過程。

內存泄漏自動分析

在此以前,我要說的是,我尚未找到一種自動檢測內存泄漏的好方法。 Chrome 有非標準的performance.memory API,但出於隱私方面的考慮它沒有很是精確的粒度,所以你不能真正在生產中用它來識別泄漏。 W3C 網絡性能工做組過去討論了內存 工具,但還沒有就取代該 API 的新標準達成共識。

在實驗室或綜合測試環境中,你能夠用 Chrome 標誌 --enable-precise-memory-info。還能夠經過調用專有的 Chromedriver 命令 :takeHeapSnapshot 建立堆快照文件。可是這也具備上述相同的限制——你可能想要連續獲取三個並丟棄前兩個。

因爲事件監聽器是最多見的內存泄漏源,所以我使用的另外一種技術是對 monkey-patch 的 addEventListenerremoveEventListener API進行計數,從而進行計數引用並確保它們返回零。這裏是如何執行此操做的示例

在 Chrome Dev Tools 中,你還可使用專有的 getEventListeners() API 來查看事件監聽器附加到特定元素。注意,這隻能在 Dev Tools 中使用。

總結

在 Web 應用中查找和修復內存泄漏的狀態仍然很初級。在本文中,我介紹了一些對我有用的技術,可是請記住,這仍然是一個困難且耗時的過程。

與大多數性能問題同樣,少許預防賽過大量的治療。你可能會發現進行綜合測試是值得的,而不是在事實發生後嘗試調試內存泄漏。尤爲是若是頁面上存在多個泄漏,則可能會變成洋蔥剝皮練習——你先修復一個泄漏,而後查找另外一個泄漏,而後重複(整個過程都在哭泣!)。若是你知道要查找的內容,代碼審查還能夠幫助捕獲常見的內存泄漏模式。

JavaScript 是一種內存安全的語言,具備諷刺意味的是,在 Web 應用中泄漏內存有多麼容易。不過部分緣由只是 UI 設計所固有的——咱們須要偵聽鼠標事件、滾動事件、鍵盤事件等,而這些都是容易致使內存泄漏的模式。可是,經過嘗試下降 Web 應用的內存使用量,能夠提升運行時性能,避免崩潰,並尊重用戶設備上的資源限制。

感謝 Jake Archibald 和 Yang Guo 對本文的草稿提供反饋。感謝 Dinko Bajric 發明了「choose a prime number」技術,我發現它在內存泄漏分析中很是有用。


本文首發微信公衆號:前端先鋒

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章

歡迎掃描二維碼關注公衆號,天天都給你推送新鮮的前端技術文章

歡迎繼續閱讀本專欄其它高贊文章:


相關文章
相關標籤/搜索