引言
最近有一個工單是說用戶在使用我們的系統的時候,如果使用某個頁面的次數多了以后瀏覽器就開始變慢甚至卡死崩潰掉。這個問題明顯是提示有內存泄露,今天就由這個問題開始分享一些關于內存泄漏的知識。
一、?Web 應用內存泄漏的危害與易忽略性
危害:
- 性能下降:內存泄漏導致瀏覽器內存占用持續增長,頁面卡頓、響應延遲,最終可能崩潰
- 資源耗盡:移動設備電池消耗加劇,低端設備體驗惡化
- 跨頁面影響:SPA(單頁應用)因無頁面刷新,泄漏累積更嚴重
易忽略原因:
- 漸進性:泄漏初期癥狀不明顯,用戶僅感知輕微卡頓,難及時反饋
- 工具缺失:開發者缺乏實時監控手段,需主動使用 DevTools 分析
- 框架依賴:Vue/React 等框架的自動回收機制讓開發者誤以為無需手動管理
二、?什么是內存泄漏
- 核心概念:程序申請內存后未釋放,導致無效內存占用持續增長
- JavaScript 中的表現:對象未被 GC 回收。即使不再使用,仍被全局變量、閉包或事件監聽器引用
常見場景:
1. 意外的全局變量
// javascript function leak() {leakedVar = '全局泄漏'; // 未使用var/let/const,成為window屬性 }
- 原因:未聲明的變量會掛載到window對象,直到頁面關閉才釋放。
- 解決:嚴格模式(‘use strict’)或明確聲明變量
2. 未清除的定時器與回調
// javascript const intervalId = setInterval(() => {// 重復操作 }, 1000); // 未調用 clearInterval(intervalId)
- 影響:定時器持續引用函數,阻止垃圾回收(GC)。
- 解決:在組件卸載或不再需要時清除定時器(clearInterval/clearTimeout)。
3. DOM引用未釋放
// javascript const elements = {button: document.getElementById('myButton') }; document.body.removeChild(elements.button); // 移除了DOM節點 // 但 elements.button 仍保留引用
- 原因:JavaScript對象持有DOM引用,即使節點已從DOM樹移除。
- 解決:移除節點后手動置空引用(elements.button = null)
4. 閉包引用
// javascript function createClosure() {const largeData = new Array(1000000).fill('data');return () => console.log(largeData); // 閉包持有largeData } const closure = createClosure(); // largeData無法被GC回收
- 風險:閉包可能無意中持有大型數據結構。
- 解決:避免在閉包中保留不必要的數據,必要時手動解除引用。
5.未移除的事件監聽器
// javascript document.addEventListener('click', handleClick); // 頁面卸載時未調用 removeEventListener
- 后果:事件監聽器阻止相關對象被回收。
- 解決:使用removeEventListener或在框架中利用生命周期鉤子(如Angular的OnDestroy清理函數)
6. Web Workers未終止
// javascript const worker = new Worker('worker.js'); // 未調用 worker.terminate()
- 影響:Worker線程持續占用內存。
- 解決:在不需要時調用worker.terminate()。
7. 緩存無限增長
// javascript const cache = {}; function cacheData(key, data) {cache[key] = data; // 無緩存淘汰機制 }
- 問題:緩存未設置上限或過期策略。
- 解決:實現LRU(最近最少使用)等緩存淘汰策略。
三、?出發去找內存泄露
我們可以最大限度利用Chrome提供的工具來診斷內存泄露,我們一般有如下幾種方式來診斷:
- 堆快照分析: 使用Chrome DevTools的堆快照功能記錄內存狀態,通過比較操作前后的快照差異來定位泄露對象。 對比視圖(Comparison View)可顯示操作后新增或未釋放的對象,幫助確認泄露。 重點關注DOM節點泄露,例如已分離的DOM子樹(Detached DOM Tree)因未被垃圾回收而持續占用內存。
- 分配分析器工具: 通過分配分析器(Allocation Profiler)實時跟蹤內存分配,識別頻繁創建且未釋放的對象。
- 保留路徑分析: 在堆快照中檢查對象的保留路徑(Retainers),分析為何對象未被釋放。可忽略無關保留器以簡化分析。
- 重復字符串與閉包檢查: 過濾重復字符串(Duplicate Strings)和閉包(Closures),命名函數有助于區分閉包內存占用
四、開始診斷
如果你的應用要運行在移動端的瀏覽器中,那么對于內存的使用會更嚴格一些。需要在不同性能的設備上進行測試。但是我們這次主要是面對的是PC端,所以在測試環節會沒有那么復雜。
1. 使用 Chrome 任務管理器實時監控內存使用情況
使用 Chrome 任務管理器作為調查內存問題的起點。Task Manager 是一個實時監視器,類似windows任務管理器,它能告訴頁面使用了多少內存。
- 按 Shift+Esc 或者從 Chrome 主菜單選擇 更多工具 > 任務管理器 打開任務管理器
- 然后右鍵單擊 Task Manager 的表窗口啟用 JavaScript 內存 。
- 實際的效果
- Memory footprint (內存占用) 列表示 OS 內存。DOM 節點存儲在 OS 內存中。如果此值增加,則表示正在創建 DOM 節點。
- JavaScript Memory 列表示 JS 堆。這個列包含兩個值。值得注意的是實時數字(括號中的數字)。活動數字表示頁面上的可訪問對象使用的內存量。如果此數量增加,則表示正在創建新對象,或者現有對象正在增長。
2. 使用性能記錄可視化內存泄漏
可以使用Performance(性能)面板作為另一種調查方式。Performance(性能)面板可以讓我們可視化的調查內存隨著時間推移的使用情況.
- 在 DevTools 中打開 Performance (性能 ) 面板。
- 啟用 Memory 復選框。
- 進行錄制 ,最好在每次開始錄制和結束前進行強制垃圾回收,點擊小掃帚圖標進行垃圾回收。
- 實際效果
記錄下每次的內存數據,然后強制GC再次記錄。觀察如果內存數據持續增加不會被GC釋放,則說明可能存在內存泄漏。
3. 上述的兩種辦法是初步的判斷辦法,下面我們以診斷分離樹造成的內存泄漏為例,進行進一步分析。
首先什么是分離樹(Detached DOM Tree)? 在v8執行GC的時候只有當頁面的 DOM 樹或 JavaScript 代碼中沒有對 DOM 節點的引用時,才能對 DOM 節點進行垃圾回收。當一個節點從 DOM 樹中刪除時,該節點會成為 “detached”的狀態,但如果某些 JavaScript 仍然引用它就會造成內存泄露。
分離的 DOM 節點是內存泄漏的常見原因。這里使用 DevTools 的堆分析器來識別分離的節點。
好的,我們先開始新建一個Angular的簡單APP
在Page1中,添加了監聽事件統計鼠標的點擊次數,隨著點擊次數的增加,改變背景顏色。 代碼如下:
page1.html
<div><h1>This is page 1</h1><page-click-counter></page-click-counter></div>
pageClickCounter.html
<div id="page-counter-child-view" style="border-radius: 10px; padding: 5px;"><h1>This is page counter, it will show the user click count number:</h1><h2>click: {{clickCount()}}</h2> </div>
page1.ts
? import { Component } from '@angular/core';import { PageClickCounter } from '../pageClickCounter/pageClickCounter';@Component({selector: 'app-page1',imports: [PageClickCounter],templateUrl: './page1.html',styleUrl: './page1.less'})export class Page1 {}
pageClickCounter.ts
import { Component, signal, AfterViewInit, OnDestroy } from '@angular/core';@Component({selector: 'page-click-counter',imports: [],templateUrl: './pageClickCounter.html',styleUrl: './pageClickCounter.less'})export class PageClickCounter implements AfterViewInit {protected clickCount = signal(0);childView: HTMLElement | null = null;ngAfterViewInit(): void {this.childView = document.querySelector('#page-counter-child-view');document.addEventListener('click', this.clickHandler);}clickHandler = () => {this.clickCount.update(count => count + 1);console.log('Page1 click count:', this.clickCount());// Update background color based on click count// Use HSL color with hue changing from green (120) to red (0) as clicks increaseconst hue = Math.min(120 - (this.clickCount() * 5), 120);(this.childView as HTMLElement).style.backgroundColor = `hsl(${hue}, 70%, 60%)`;};}
實際的運行效果如下:
這個Demo里已經存在泄露了,這里我們使用DevTools的堆分析器進行內存泄漏的檢測。
- 點擊錄制,錄制好的快照如下:
- 在搜索框輸入 detached 搜索分離的DOM樹節點:
看這個搜索結果的表,里面有四個列:
- Constructor: 表示分離的DOM節點的構造類型
- Distance: 節點與根節點的距離
在瀏覽器中GC回收的根節點就是window對象,其他的對象或者基本類型都是從這里出發鏈接到一起的。
- Shallow size:這是對象本身持有的內存大小。典型的 JavaScript 對象會保留一些內存用于其描述和存儲即時值。通常,只有數組和字符串可以具有明顯的淺層大小。
- Retained size:這是對象及其所有子對象所占的內存大小。更精確的描述是刪除對象本身及其無法從 GC 根訪問的依賴對象后釋放的內存大小。比如上圖中的節點6和8,節點8依賴節點6兒存在,如果節點6被刪除,那么節點8就無法訪問。
現在我們了解了快照表上的幾個列的含義,點擊一個行在下面的Retainer表和看到詳細的引用情況。然后我們就可以找到泄露產生的位置,來用對應的辦法解決。
但是看我們搜索出來的結果很雜亂,而且在實際的復雜項目中這個結果可能更加的復雜。那我們要從哪里開始下手呢?
其實在我們Angular或者Vue這些一組件為基礎組裝的應用中,如果我們從 <div> 或者 <h1> 這些節點開始向上找的話大概率會找到一個自定義的組件為止。
所以這里開始解決的小技巧是從大的組件開始解決,因為好多搜索出來的基礎元素泄露可能只是被自定義組件持有,當我們解決了組件級別的泄露,這些小的元素泄露會跟著消失。
好,看回我們的Demo在列表里發現了我們的自定義組件 page-click-counter,點擊進去
這里提示我們的 clickHandler 函數的引用關系,我們點擊進去
我們分析代碼,這里的childView持有了頁面上的元素,然后訂閱了document的click事件。問題出現在頁面銷毀的時候這個點擊事件的定義還在,clickHandler函數持有的DOM 對象childView就成為了分離的DOM。
好,我們開始解決這個問題。在頁面銷毀的生命周期函數里把訂閱取消。
// javascript ngOnDestroy(): void {// Clean up the event listener to prevent memory leaksdocument.removeEventListener('click', this.clickHandler); }
重新編譯運行,然后同樣的方法記錄內存快照。
我們可以看到,內存快照中不再有剛才的泄露對象。
五、?其他內存檢測方案
瀏覽器內置工具:
- Chrome DevTools:
- Heap Snapshot:對比多次快照,識別未釋放對象
- Allocation Timeline:跟蹤內存分配時間線,定位泄漏點
六、?預防策略與未來方向
代碼規范:
- 及時釋放資源:
- 事件監聽器、定時器在 ngOnDestroy 中移除
// javascript ngOnDestroy(): void {this.subscription.unsubscribe(); // 清理RxJS訂閱document.removeEventListener('click', this.handler); }
- 使用 WeakMap 替代強引用存儲臨時數據
// javascript // 使用WeakMap存儲臨時數據 const weakMap = new WeakMap(); const element = document.getElementById('target'); weakMap.set(element, { metadata: 'data' }); // 當element被移除時,關聯數據可被GC回收
- 避免全局變量:嚴格模式(use strict)禁用意外全局聲明
架構優化:
- 資源隔離:為組件分配獨立作用域,避免交叉引用
// javascript // Angular服務作用域隔離示例 @Injectable({ providedIn: 'root' }) // 根作用域 class RootService {}@Injectable({ providedIn: 'platform' }) // 平臺級作用域 class PlatformService {}@Injectable({ providedIn: 'any' }) // 每個模塊獨立實例 class ModuleService {}
- 團隊實踐:定期代碼審查,重點關注事件綁定、閉包和第三方庫
審查項
檢查點
事件管理
是否在組件銷毀時移除監聽器
訂閱管理
RxJS訂閱是否正確取消
DOM引用
是否存在未釋放的DOM引用
緩存機制
是否設置過期策略
未來趨勢: 現在我們可以借助AI大模型和MCP來實現文件掃描,讓大模型可以掃描并分析代碼查找可能出現內存泄露的寫法,并給出修改建議。
以下就是AI掃描pageClickCounter.ts文件后給出的修復建議。
The following memory leak issues were identified in the pageClickCounter.ts file:
1. Missing Event Listener Cleanup
The clickHandler event listener attached to document in ngAfterViewInit is not removed in ngOnDestroy.This creates a reference cycle:
- document retains a reference to clickHandler
- clickHandler retains a reference to the component instance via this
- Prevents Angular’s garbage collection of the component
2. Unmanaged DOM Reference
The childView property retains a direct DOM element reference through: javascript??// javascript this.childView = document.querySelector('#page-counter-child-view');
While less critical than the event listener issue, this could prolong DOM element lifecycle beyond component destruction if not explicitly nullified.
Recommended Fix:
Uncomment and implement ngOnDestroy to clean up resources:// javascriptngOnDestroy(): void {// 1. Remove event listenerdocument.removeEventListener('click', this.clickHandler);// 2. Clear DOM referencethis.childView = null;}
Analysis Context:
- This is an Angular component using reactive signals (signal()) - The memory leak occurs through:
document -> clickHandler -> component -> childView -> DOM Element - The event handler pattern follows Angular’s best practices but requires explicit cleanup for document/window listeners
七、結論
與應用程序中的內存泄漏作斗爭可能是一項艱巨的任務。但是通過執行以上這些步驟,我們可以了解內存泄漏模式確定內存泄漏的根本原因,并實施必要的清理以防止進一步危害。而且現在我們現在有了LLM工具,我們可以使用大模型來分析代碼并確定內存泄漏的根本原因,并使這個自動化過程更加高效和有效。