題幹與適用情境
一個 React 單頁訂單看板包含即時圖表、訂單事件訂閱和響應式版面。使用者從訂單列表進入詳情頁, 再返回列表,重複 20 次後頁面開始卡頓。Chrome 的 Performance 記錄顯示:執行相同操作並在 每輪末尾觸發垃圾回收後,JS heap 的低點持續上移,DOM 節點與監聽器計數也沒有回到初始區間。
相關元件的簡化程式碼如下:
useEffect(() => {
const chart = createOrderChart(containerRef.current)
window.addEventListener("resize", () => chart.resize())
orderBus.on("order", (order) => chart.append(order))
const timerId = window.setInterval(() => chart.refresh(), 5_000)
return () => {
window.clearInterval(timerId)
}
}, [])面試要求候選人給出一條可被複核的證據鏈:先區分正常分配、記憶體膨脹、頻繁 GC 與真正洩漏, 再定位是誰從 GC root 保留了本應死亡的物件,最後修復監聽器、訂閱、計時器和第三方實例的 生命週期,並用同一個操作循環證明修復有效。
公開的 2025 年前端題庫直接列出「如何識別並修復 JavaScript 記憶體洩漏」;2025 年 JavaScript 面試材料也給出「SPA 反覆導覽後變慢,如何用 DevTools 找洩漏」的情境。近期公開面試復盤 繼續記錄了垃圾回收、閉包、監聽器與元件卸載的追問。題目的搜尋意圖很明確:候選人需要一套 可執行的瀏覽器診斷方法,不能只羅列「清理計時器」。
面試官考察點
第一項能力是建立正確判斷標準。物件暫時增加、峰值變高或工作管理員中的總記憶體上升,都不能 單獨證明洩漏。強回答會固定瀏覽器、頁面版本、資料和操作步驟,在每輪之間給垃圾回收機會, 比較多輪的存活基準。正常分配常呈現會上升也會回落的鋸齒;同一操作後存活物件持續累積,才是 需要沿引用鏈調查的訊號。
第二項能力是理解可達性。現代 JavaScript 垃圾回收器從全域物件等 roots 出發標記可達物件。 業務已經不用某物件,不代表垃圾回收器知道這一點;只要 window 監聽器、事件匯流排、計時器、 快取或第三方函式庫仍有強引用,它就仍然可達。循環引用本身也不必然洩漏,只要整組物件都不可達, 標記清除仍可回收。
第三項能力是會讀工具證據,而非只會打開 Memory 面板:
| 證據 | 回答的問題 | 不能單獨證明什麼 | |---|---|---| | Task Manager / Performance 記憶體曲線 | 是否隨重複動作持續增長 | 哪個物件負責保留 | | Heap Snapshot 的 Comparison | 哪類存活物件和引用數增加 | 增加是否符合業務快取契約 | | Retainers / retaining path | 哪條引用鏈把物件連到 root | 對應程式碼的正確釋放時機 | | Allocation instrumentation on timeline | 哪次操作分配了後來仍存活的物件 | 所有分配都是洩漏 | | DOM 節點、documents、listeners 計數 | 增長偏向 DOM 還是監聽生命週期 | JS heap 以外的完整程序記憶體 |
最後一項能力是資源所有權。建立資源的生命週期邊界應負責釋放它。AbortController 可以批次 移除透過 signal 註冊的 DOM 監聽器,但不能自動斷開 ResizeObserver、WebSocket、事件匯流排 訂閱或第三方圖表;這些仍需呼叫各自的 disconnect、close、unsubscribe 或 destroy。
回答前需要釐清的問題
- 如何穩定重現? 明確入口、返回動作、每輪等待條件、資料量和重複次數。若每輪收到的訂單
不同,快照差異就混入業務資料差異。
- 哪個指標真的在增長? OS memory footprint、JS heap、DOM 節點、documents 和監聽器表示
不同區域。先縮小問題,再選擇快照或分配分析。
- 業務是否允許快取? 有上限的路由快取或圖表資料可能有意存活;需要快取容量、淘汰規則和
穩態預期,才能區分洩漏與受控占用。
- 元件卸載是否真的發生? 路由可能隱藏、保活或重用元件。先用掛載與清理日誌確認真實
生命週期,避免修復一個沒有發生的 unmount。
- 哪些物件由第三方函式庫建立? 圖表、編輯器和地圖通常擁有 DOM、worker、observer 與內部
listener;只清空容器 HTML 並不等於銷毀實例。
- 正式環境問題能否在測試環境重現? 堆積快照可能包含使用者資料,線上採集需遵守隱私與
存取控制;優先用脫敏資料在受控環境重現。
- 成功標準是什麼? 不設脫離裝置和頁面負載的通用 MB 閾值。應約定重複動作後的基準斜率、
存活實例數、節點/監聽器數量以及使用者可感知卡頓指標。
30 秒回答框架
「我會固定一次列表—詳情—列表操作,預熱後重複多輪,並比較相同 GC 檢查點的存活基準。若 heap 低點、DOM 節點或監聽器仍累積,就比較前後 snapshots,找到持續增加的類型,再沿 Retainers 追到 window、事件匯流排或 timer。來源不清時補錄 Allocation timeline。修復由建立方對稱釋放 監聽、訂閱、timer、observer 和圖表;最後重跑同一循環,要求實例數回落、基準穩定且功能正常。」
分步驟深入解答
第一步:用重複實驗證明洩漏
先建立可比較的基準。使用無關擴充功能較少的同一 Chrome 版本,固定視窗、測試帳號、訂單資料 與路由路徑。重新整理後完成一次預熱,讓延遲載入模組、字型、連線池和一次性快取完成初始化。 隨後在 Performance 面板勾選 Memory,按以下步驟執行:
- 觸發一次垃圾回收並記錄起點。
- 完成五次「列表 → 詳情 → 列表」,每次等待相同的「圖表已渲染」條件。
- 再觸發一次垃圾回收,記錄 JS heap、DOM nodes、documents 與 listeners 的低點。
- 重複三組,比較每組低點,而非比較任意瞬間的峰值。
垃圾回收時機由執行環境決定,短實驗也會受 JIT、圖片解碼、網路回應和 DevTools 本身影響。因此 單輪差值只能產生假設。若第一組低點較高、之後穩定,可能只是預熱;若每組都保留一個新的 OrderChart、一批 detached nodes 和兩個監聽器,增長與操作次數同步,證據就更強。
還要區分三種現象:
- 洩漏: 重複相同動作後,GC 後存活基準持續上升。
- 記憶體膨脹: 穩態占用過高,但不再隨操作次數無界增長。
- 分配抖動: heap 頻繁升降並伴隨 GC 暫停,低點可以回來,問題在過量暫時分配。
Task Manager 的 Memory footprint 包含 DOM 等處理程序占用,JavaScript Memory 的 live number 更 接近目前可達 JS 物件。兩者可以協助分流調查,卻不能取代 heap snapshot 的引用證據。強制 GC 只是診斷控制手段,不能成為產品修復,也不能承諾正式環境會在某個時間點回收。
第二步:用堆積快照和保留路徑定位責任物件
確認存在增長後,在 Memory 面板拍攝 Snapshot A。完成固定導覽循環,回到列表並等待非同步清理, 再拍 Snapshot B。拍快照會先進行垃圾回收,因此 Comparison 檢視更適合查看真正仍可達的新增 物件。先按實例數與 retained size 排序,尋找隨循環次數增長的業務建構類型、閉包、陣列和 detached DOM tree。
shallow size 只表示物件本身占用;retained size 估計該物件若變為不可達時可連帶釋放的 記憶體。一個很小的監聽回呼可能透過閉包保留圖表、資料陣列和整棵 DOM 子樹,因此 retained size 比回呼自身大小更能提示影響範圍。它是排查優先順序,不是直接等同於獨占實體記憶體。
選中一個本應死亡的 OrderChart 或 detached 節點,查看 Retainers。可能得到如下路徑:
Window
└─ resize event listener
└─ callback closure
└─ chart
└─ container
└─ detached HTMLDivElement這條路徑回答了「為何 GC 無法回收」:全域 window 仍持有匿名 resize 回呼,回呼閉包又持有 圖表。DOM 已從文件樹移除,只改變了它與 document 的關係,沒有切斷從 root 出發的 JavaScript 引用。修復點應落在監聽器與圖表所有權,而非對 detached 節點指定 null 後碰運氣。
若快照只能看見大量通用 Object 或 Array,使用 Allocation instrumentation on timeline。 開始錄製後只執行一次洩漏動作,再停止,關注分配後跨過垃圾回收仍存活的藍色條帶,從建構類型 回到分配堆疊。Allocation sampling 開銷更低,適合尋找分配熱點;它提供抽樣結果,不能取代需要 逐實例比較的 snapshot。
常見保留來源包括:
- 全域物件或模組層級陣列不斷追加頁面實例;
- DOM/EventTarget 監聽器沒有移除,或移除時函式身分、capture 選項不符;
setInterval回呼與遞迴 timeout 持有元件狀態;- 事件匯流排、store、WebSocket 或 observable 沒有退訂;
ResizeObserver、IntersectionObserver、worker 與第三方實例沒有銷毀;- 無容量上限的 Map、歷史記錄或請求快取;
- 永不 settle 的 Promise 或佇列工作長期保留閉包。
第三步:按資源所有權修復並用同一實驗驗收
修復後的 Effect 應保存每一種資源的釋放控制代碼。可以用 AbortController 管理 DOM 監聽器, 同時明確釋放其餘資源:
useEffect(() => {
const container = containerRef.current
if (!container) return
const controller = new AbortController()
const chart = createOrderChart(container)
const observer = new ResizeObserver(() => chart.resize())
const unsubscribe = orderBus.on("order", (order) => chart.append(order))
const timerId = window.setInterval(() => chart.refresh(), 5_000)
observer.observe(container)
window.addEventListener("visibilitychange", () => chart.syncVisibility(), {
signal: controller.signal,
})
return () => {
controller.abort()
observer.disconnect()
unsubscribe()
window.clearInterval(timerId)
chart.destroy()
}
}, [])真實 API 的退訂契約需要核對:有的 on 回傳 unsubscribe,有的要求把同一個 handler 傳給 off。如果元件可能因相依值變化重新建立資源,清理函式必須只釋放本次 Effect 建立的實例, 不能誤關新一輪實例。非同步請求還要取消或用代次標記作廢遲到結果;卸載後不更新 UI 只阻止了 一次寫入,若外部訂閱仍持有閉包,洩漏依然存在。
WeakMap 適合物件鍵中繼資料,不能取代明確的生命週期清理。WeakRef 的回收時機與可見行為 刻意缺少保證,通常也不應成為監聽器、連線和圖表實例的修復方案。最直接的改動仍是切斷不再 需要的強引用,並給有意快取設定容量、TTL 或淘汰條件。
驗收必須重跑修復前的同一實驗:相同建置、資料、導覽次數、等待點和 GC 控制。通過條件包括:
- 返回列表後,存活的詳情元件、圖表實例和 detached 子樹回到約定數量;
- 連續多組操作後,GC 後基準圍繞穩定區間波動,不再與導覽次數近似線性增長;
- listeners、documents 與 DOM nodes 回到預期範圍;
- 每次掛載只有一個訊息訂閱、一個圖表實例,卸載恰好釋放一次;
- 圖表仍更新,容器尺寸與頁面可見性變化仍響應,重新進入頁面可以正常重建資源;
- 長時間執行下的卡頓、GC 暫停和崩潰訊號達到產品門檻。
最後保留修復前後快照、重現步驟、建置版本和關鍵 retained path,方便評審者複核。快照可能 包含字串和業務物件,儲存與分享時要按敏感偵錯產物處理。
高品質示範回答
「目前的曲線是強訊號,但我還不會只憑一次上升就下結論。我會先固定訂單資料,把一次 列表—詳情—列表作為一個操作單元,預熱後分三組各做五次。在每組前後都從相同頁面狀態觸發 GC,並記錄 heap 低點、DOM nodes、documents 和 listeners。如果低點只在第一組上升,之後穩定, 我會檢查一次性初始化或有界快取;如果每組都多出相同數量的圖表和監聽器,再進入快照定位。」
「我會在預熱後拍 Snapshot A,執行循環並返回列表,等待清理後拍 Snapshot B。Comparison 先看 實例數和 retained size 的增量。假設我找到 15 個本應銷毀的 OrderChart,Retainers 顯示 Window → resize listener → closure → chart → detached container,那就已經解釋了回收失敗: 匿名監聽器仍由 window 持有,閉包把圖表和 DOM 一起留下。若建構類型太通用,我再錄一次 Allocation timeline,用分配堆疊把那次導覽產生且持續存活的物件對應回程式碼。」
「修復時,我會讓 Effect 保存每個釋放控制代碼:DOM 監聽器交給 AbortController,訊息匯流排 退訂,timer 清除,observer 斷開,最後銷毀圖表。第三方函式庫若要求 off(handler),就保留同一 handler 身分。快取另設容量或淘汰規則。我會重跑完全相同的實驗;只有圖表與 detached subtree 回到約定數量、GC 後基準不再隨導覽次數近似線性增長,而且重新進入詳情仍能正常訂閱和渲染, 才判定修復通過。」
常見錯誤
- 只看一次峰值就宣布洩漏 → 正常分配、JIT 和快取也會抬高峰值 → **比較相同操作後多輪
GC 基準,並尋找實例數與操作次數的對應關係。**
- 看到 detached DOM 就直接清空容器 → 節點仍可能被全域監聽器或閉包保留 → **沿 Retainers
找到 root 路徑並釋放真正所有者。**
- 把所有結構換成 WeakMap 或 WeakRef → 其他強引用仍會保持物件可達 → **先刪除不需要的
監聽、訂閱、timer 和快取引用。**
- 只在卸載時阻止 setState → 外部資源仍可能保留回呼與整棵物件圖 → 取消工作並解除註冊。
- 修完只驗證頁面還能點 → 功能正確不能證明引用已釋放 → **用完全相同的快照實驗比較存活
實例、基準和保留路徑。**
追問及應對
追問一:為何循環引用不一定造成洩漏?
標記清除關心從 roots 是否可達。兩個物件互相引用,但外部沒有任何可達路徑指向它們時,整組 物件仍可被回收。若其中一個被 window 監聽器、模組快取或活動 timer 引用,整組才會保持可達。
追問二:為何移除 DOM 節點後它還出現在快照裡?
移除節點只讓它脫離 document tree。JavaScript 變數、監聽回呼、第三方元件或 observer 仍可 引用它。應選中 detached 節點查看 Retainers,沿路徑找到仍存活的所有者並執行對應釋放動作。
追問三:Heap Snapshot、Allocation timeline 和 sampling 如何選?
Snapshot 適合比較兩個穩定時點的存活物件並檢查逐實例保留路徑;Allocation timeline 適合把 一次使用者動作與隨後仍存活的分配關聯;sampling 用較低開銷定位大量分配發生在哪些函式。通常 先用曲線證明現象,再用 snapshots 找物件與引用,來源模糊時才補 allocation 記錄。
追問四:能否給「記憶體不得增長超過 10 MB」作為自動化門禁?
固定絕對值容易受裝置、瀏覽器、建置、資料和 GC 時機影響。更穩妥的門禁會固定環境和操作, 預熱後重複多組,關注 GC 後基準斜率、受控建構類型的存活實例數,以及 DOM/listener 計數。 閾值應來自該頁面的歷史分布和產品預算,並為雜訊保留明確容差。
追問五:頁面隱藏但沒有卸載時怎麼辦?
先確認產品契約。若保活是有意行為,元件可以暫停 timer、降低取樣、停止不可見訂閱,並在恢復 時繼續;仍需給快取和實例數設上限。此時「返回列表後必須為零」不再適用,驗收目標應改為穩定、 有界且能在真正銷毀時完整釋放。