大型 SPA 内存泄漏:从 Detached DOM 到闭包引用链的系统性猎杀
performance.memory.usedJSHeapSize 这个值一直在涨,你慌不慌?页面切了十几次路由之后开始掉帧,动画卡得像PPT——这种问题你大概率碰到过,只是当时可能没把它跟内存泄漏联系起来。我在一个 Vue 后台项目里头一回认真对付这事的时候,页面开了 40 分钟,堆内存从 80MB 一路飙到 400MB,Chrome 的 Task Manager 里那个数字就没停过,GC 压根回收不掉,眼睁睁看着它往上蹿。不夸张。
问题在哪呢?
Detached DOM 和 Heap Snapshot:泄漏猎杀的主战场
先说结论:SPA 内存泄漏里,Detached DOM 是最常见、也最容易排查的类型。为什么常见?前端框架的路由切换,本质上就是 document.createElement 和 removeChild 的循环往复,任何一个环节没断干净,整棵子树就留在内存里了。为什么容易排查?因为 Chrome DevTools 的 Heap Snapshot 对这个场景有专门支持——你在 Summary 视图里搜 Detached,直接就能看到所有脱离 DOM 树但没被回收的节点。
但搜到和定位到原因,完全是两码事。
先把概念理清。
DOM 树(document) JS 堆
┌──────────┐ ┌──────────────┐
│ <body> │ │ window.app │
│ │ │ │ │ │
│ <div#a> │──── 路由切换 ──→│ (已移除) │
│ │ │ removeChild │ │
│ <div#b> │ │ 但 cache 里 │
│ │ │ 还存着 #a │──→ Detached HTMLDivElement
└──────────┘ │ 的引用 │ (以及它的整棵子树)
└──────────────┘
这里有个坑,很多人踩过但没意识到:Detached 的不是那一个节点,是整棵子树。你缓存了一个父节点的引用,它下面挂的几百个 <span>、<div>、文本节点,全都跟着留在内存里。我见过一个案例,一个富文本编辑器组件路由切换时没正确调 destroy(),单次泄漏 2000 多个 DOM 节点。跑不动。
怎么用 Heap Snapshot 抓 Detached DOM
操作流程不复杂。但有几个关键步骤很多教程糊弄过去了,我把自己实际跑的流程写一下(不是唯一的方法,但确实最稳)。
打开页面,先手动触发一次 GC(说起来都是泪)。DevTools → Memory 面板 → 左上角那个垃圾桶图标,点一下。这步很重要。不手动 GC 的话快照里会混进一堆还没来得及回收的临时对象,全是噪音,你根本分不清哪些是真泄漏哪些只是还没来得及清理。
然后跑你怀疑会泄漏的操作——比如打开某个页面再关掉,或者反复切 router.push 切 router.back。
// Heap Snapshot 里你会看到类似这样的条目:
//
// Constructor | Shallow Size | Retained Size
// Detached HTMLDivElement | 120 | 1,204,800 ← Retained Size 才是真正吃掉的内存
// Detached HTMLSpanElement | 96 | 12,480
// Detached Text | 72 | 72
//
// Retained Size >> Shallow Size → 这个节点下面挂了一大坨子节点
看到 Retained Size 特别大的条目,点进去。下面的 Retainers 面板会告诉你,是谁在引用这个节点导致它没法回收。这是整个排查里最关键的一步。Retainers 展示的是一棵引用树,从 GC roots 到目标对象的完整路径,你要找的是第一个不该存在的引用。
(讲道理,Retainers 面板的显示顺序第一次看会有点晕——目标对象在顶上,root 在底下,跟你直觉是反的。习惯就好。)
实际的 Retainer 链条长这样:
Detached HTMLDivElement @1234567
native in __editorInstance__ @2345678
__cache__ in Object @3456789
_componentCache in VueComponent @4567890
context in closure @5678901 ← 闭包持有了组件引用
handler in EventListener @6789012 ← 事件监听器没解绑
看到了吗?一个 EventListener 的回调闭包里捂着组件引用,组件缓存了编辑器实例,编辑器又抓着 DOM 引用不放,链条拉得这么长,你光盯着源码看八辈子也看不出来,必须靠 Heap Snapshot 的 Retainers 一层一层刨。
分代追踪:Comparison 视图才是杀手锏
很多人只会在 Summary 里搜 Detached。够呛。真正排查复杂泄漏的时候,Comparison 视图更狠。
做法是拍两个快照:第一个作为基线(页面刚加载完、手动 GC 之后),然后执行一轮"打开→关闭"操作,再 GC,拍第二个快照,切到 Comparison 视图,选 Snapshot 1 作为对比基准。这时候你看到的就是两个快照之间新增了哪些对象——如果某个操作没泄漏,"打开→关闭→GC"之后新增对象应该趋近于零。要是 # New 列里冒出来一大堆 HTMLDivElement、Object、Array,那就是抓到了。
// Comparison 视图关键指标:
//
// Constructor | # New | # Deleted | # Delta | Size Delta
// HTMLDivElement | 847 | 3 | +844 | +101,280 ← 建了847个只删3个
// Object | 2341 | 1892 | +449 | +35,920
// (closure) | 156 | 12 | +144 | +11,520 ← 闭包也在涨
//
// Delta 是正数 → 漏了
// Delta 是 0 或负数 → 正常
怎么说呢,Comparison 的价值就在于它帮你做了差分——不用在几万个对象里大海捞针,只盯增量。你反复做同一个操作,比如连切 5 次路由,每次 Delta 都在涨,那基本实锤了。涨幅乘以用户实际操作次数,就是你内存炸弹的当量。
反正大概是这么个意思。
还有个好东西:Allocation instrumentation on timeline。就这。按时间轴记录内存分配,蓝色竖条表示还活着的对象,灰色的表示已回收。操作完看到一大片蓝色集中在某个时间点的话?个时间点的操作就是泄漏源。比 Comparison 更直观,但性能开销大——我一般控制在 30 秒以内,录久了 DevTools 自己先卡死。
闭包引用链:看不见的内存钉子
Detached DOM 好歹还能搜到。闭包泄漏才是真正的阴间。
闭包为什么会漏?JS 引擎创建闭包时会捕获外层作用域中被内部代码引用的变量(V8 做了优化,只捕获实际用到的,所以你在 DevTools 里看 Scope 时没用到的变量显示 undefined)。但如果闭包本身的生命周期超过了它捕获的变量的预期寿命——是的你没看错,那就完了,引用断不掉,对象死不了。
最经典的场景:
function setupPage() {
const hugeData = fetchDashboardData() // 假设返回 50MB 报表数据
const container = document.getElementById('chart-container')
// resize 监听器的闭包捕获了 hugeData 和 container
window.addEventListener('resize', () => {
renderChart(container, hugeData)
})
// 页面卸载时你把 container remove 了,UI 清得干干净净
// 但 resize listener 还挂在 window 上
// → 闭包活着 → hugeData (50MB) 活着 → container (Detached DOM) 活着
//
// 切 3 次路由 = 150MB 泄漏
}
问题出在 addEventListener 注册在 window 上,组件销毁时没有调 removeEventListener。
(虽然 V8 的 GC 是分代的,新生代用 Scavenge、老生代用 Mark-Sweep-Compact,但甭管哪种算法,只要从 root 可达就不会回收。GC 不关心你"想不想要"这个对象,它只看"能不能到达",这个设计我觉得有点残酷。)
闭包链条叠加
更恶心的来了。闭包套闭包。
function createModule() {
const moduleState = { data: new Array(100000).fill('x') }
return {
getData() { return moduleState.data },
startPolling() {
setInterval(() => {
console.log(moduleState.data.length) // 闭包 → moduleState → data[]
}, 5000)
// setInterval 返回的 ID 没存,clearInterval 都调不了
// 这个定时器会永远跑下去,永远
}
}
}
const mod = createModule()
mod.startPolling()
// 后来你觉得这模块没用了
// mod = null ← 你以为这就完事了?
// setInterval 的回调还在跑,闭包还引用着 moduleState
// moduleState 永远不会被回收
在 Heap Snapshot 的 Retainers 里你会看到:moduleState ← context in (closure) ← callback in Timeout ← GC root。setInterval 嘛,说白了就是引擎内部给你的回调续了命——它持有的引用算 GC root 级别,整条链全是不可回收的。
坦白说,我碰到过一个比这狠得多的真实案例:一个 WebSocket 连接模块在 onmessage 回调里引用了 Vue 组件实例,组件又通过 $refs.chart 引用了一个 HTMLCanvasElement,Canvas 下面还有 2D context 和内部 buffer。组件销毁了,ws.close() 忘调了。一条链拉通:WebSocket.onmessage → 闭包 → 组件 → $refs.chart → Canvas + buffer。单个组件泄漏 15MB 左右,因为 Canvas 内部 buffer 按像素算——一个 1920 * 1080 * 4 ≈ 8MB,再加上 context 对象和其他杂七杂八的,就是这个数。
怎么在 Heap Snapshot 里追闭包
在 Summary 视图搜 (closure) 能看到所有闭包对象,但数量通常是几千上万。没法看。更高效的路子还是 Comparison 视图看 Delta,或者直接在 Retainers 里从泄漏目标反向追。
追的时候有个窍门:Retainers 里显示的 context 就是闭包捕获的作用域对象,点开能看到里面存了哪些变量。如果你看到某个 context 里面躺着你组件的 this 或者一个巨大的 Array,基本就定位到了——再往上看这个闭包被谁持有,通常是某个 EventListener、Timeout、或者 Promise.then 的回调。
分代追踪方法论:别瞎猜,用数据说话
工具会用了,原理也懂了,但实际干活时最大的问题往往不是技术层面的——是你不知道该怀疑谁。一个大型 SPA 几百个组件、几十个路由、无数个 addEventListener 和异步操作散落各处,泄漏可能藏在任何犄角旮旯。
我自己跑下来最靠谱的方法论就俩字:二分。先粗后细,先定位路由再定位组件。
别一上来就拍 Heap Snapshot。先粗筛。打开 Chrome Task Manager(快捷键 Shift+Esc),盯着 JavaScript Memory 那列,然后一个路由一个路由地切——进去操作一下,退出来,看内存有没有回落。没回落?嫌疑人。
排查流程:
1. Task Manager 粗筛
切路由 A → 内存 +20MB → 切回 → -18MB → 差值 2MB → 可疑
切路由 B → 内存 +5MB → 切回 → -5MB → 正常
切路由 C → 内存 +35MB → 切回 → -8MB → 严重泄漏 ← 先搞这个
2. 锁定嫌疑路由后做 Heap Snapshot 分代比对
基线快照 → 进入路由 C → GC → 快照 2 → 退出路由 C → GC → 快照 3
Comparison: 快照 1 vs 快照 3
Delta 全是 0 → 没泄漏,Task Manager 的波动可能只是 GC 延迟
Delta 有大量正值 → 实锤,看 Retainers
3. 缩小范围
路由 C 页面有 5 个大组件 → 逐个注释掉 → 重复步骤 2
→ 锁定到具体组件
4. Retainers 追引用链 → 找到根因 → 修
这个流程看着笨。但稳。我试过直接上来就拍快照硬刚,对着几万条记录发了一下午呆,啥也没查出来。哦不,准确说是接上来就拍快照硬刚,对着几万条记录发了一下午呆,啥也没查出来。二分法每一步都在缩范围,不绕弯。
这部分我自己也不太确定。
有个坑得提:performance.memory 这个 API 只有 Chrome 有,返回的 usedJSHeapSize 不包含 DOM 节点占用的内存——DOM 节点在 C++ 堆上,不在 JS 堆上。所以你拿 performance.memory 做监控,Detached DOM 的泄漏有可能完全看不到。Task Manager 里 JavaScript Memory 列显示的是 JS 堆大小,括号里是 live 对象大小,但旁边那个 Memory Footprint 列包含了 DOM 和其他 C++ 对象的内存。看后面那列更准。
再说一嘴 WeakRef 和 FinalizationRegistry,ES2021 引入的两个 API。理论上你可以用 FinalizationRegistry 注册回调,对象被 GC 回收时通知你,反过来如果注册了半天也没通知,说明对象泄漏了。但实际跑起来不太靠谱——GC 时机不确定,回调不保证及时触发,甚至不保证一定会触发。等等,其实起来不太靠谱——GC 时机不确定,回调不保证及时触发,甚至不保证一定会触发。拿来辅助调试还行,线上监控就算了。
防御性编码:别等漏了再查
查泄漏是后手。写代码的时候就防住才是正道。几个我跑了两年多、踩过坑验证过的模式,直接上代码。
组件级清理清单——在组件里维护一个 cleanups 数组,所有副作用注册完就立刻把清理函数推进去,onUnmounted 时统一执行。
// Vue 3 Composition API
function useCleanup() {
const cleanups: (() => void)[] = []
const addCleanup = (fn: () => void) => cleanups.push(fn)
onUnmounted(() => {
cleanups.forEach(fn => fn())
cleanups.length = 0
})
return { addCleanup }
}
// 用法
function useChartModule(containerRef: Ref<HTMLElement>) {
const { addCleanup } = useCleanup()
const chart = new HeavyChartLibrary(containerRef.value)
addCleanup(() => chart.destroy())
const handler = () => chart.resize()
window.addEventListener('resize', handler)
addCleanup(() => window.removeEventListener('resize', handler))
const timer = setInterval(() => chart.refresh(), 30000)
addCleanup(() => clearInterval(timer))
// 三个副作用三个清理,写在一起,配对出现
// 比在 onUnmounted 里攒一坨清理逻辑靠谱得多——因为不容易漏
}
第二个模式快速过一下——用 WeakMap 替代 Map 做缓存,前提是你的 key 得是对象类型,WeakMap 的 key 是弱引用,key 被 GC 了对应 entry 自动消失。但 WeakMap 不可枚举、没有 size 属性、没法遍历,这是弱引用的代价。如果你的缓存必须支持遍历或者需要知道 .size,那就老实用 Map 加个 LRU 淘汰策略,别硬上 WeakMap。
第三个,全局事件监听一律用 AbortController 管理。这个 API 我觉得严重被低估了。
const controller = new AbortController()
window.addEventListener('resize', handleResize, { signal: controller.signal })
window.addEventListener('scroll', handleScroll, { signal: controller.signal })
document.addEventListener('keydown', handleKeydown, { signal: controller.signal })
// 组件销毁时一行搞定
controller.abort()
// 比手动 removeEventListener 好在哪:
// 1. 不用存每个 handler 的引用,匿名函数也能移除
// 2. 一个 abort() 批量干掉所有监听
// 3. fetch 请求也能共用同一个 signal 取消
这套方案跑了大半年,没翻过车。内存泄漏这事说到底就是引用的生命周期管理——你创建的每一个"连接",不管是 addEventListener、setInterval、WebSocket.onmessage 还是往某个 Map 里塞的缓存条目,都是一根线。组件活着的时候这些线是有用的,组件死了它们就变成锁链,把本该回收的对象拽在内存里不放。养成"创建即注册清理"的习惯,比出了问题再翻 Heap Snapshot 高效得多。当然话说回来,再好的习惯也防不住第三方库内部的泄漏,所以 Heap Snapshot 这个技能还是得练,留着兜底用(虽然官方文档不是这么说的)。