本节摘要:回收器只认可达性,泄漏全是「可达但不再需要」。四种高发形态:忘清的定时器与事件监听、从文档摘下但仍被引用的 detached 节点、无界增长的缓存与数组、闭包无意扣住的大环境。本节对每种给出最小复现、修复代码与排查路径,并演示堆快照对比法——两次快照之间的增量就是泄漏清单。
function startTicker() { const payload = new Array(500000).fill('数据'); // 约 20MB 的伴生数据 return setInterval(() => { console.log(payload.length); // 回调闭包扣住 payload }, 1000); } const timer = startTicker(); // ...页面切走、组件销毁,但 clearInterval 从未被调用 // 回调每秒照跑,payload 永远可达 → 20MB 常驻直到页面关闭
定时器回调是宏任务的生产者(第 4 章),它引用的一切都活到回调被取消。修复是生命周期配对:
function createTicker() { const timer = startTicker(); return { stop() { clearInterval(timer); } // 暴露停止口,组件销毁钩子里必须调用 }; }
组件框架让这件事可以约定化:挂载时登记、卸载时统一清理。原生代码则记一条铁律——每次 setInterval 与 addEventListener 的调用点,当场写好对应的清理行,别等「以后再补」。同类现场:IntersectionObserver 与 MutationObserver 的 disconnect、requestAnimationFrame 的 cancel、订阅式总线的 unsubscribe。
DOM 节点是 C++ 实体重对象(第 5 章),从文档移除不等于可回收——只要有 JS 引用指着,整棵子树连同监听器全被扣住:
let detachedRoot = null; function rebuild() { const tree = document.createElement('div'); for (let i = 0; i < 2000; i++) { const child = document.createElement('span'); child.textContent = '条目' + i; tree.appendChild(child); } if (detachedRoot) { /* 旧树已从文档移除 */ } detachedRoot = tree; // 保存引用 const mount = document.getElementById('mount'); mount.replaceChildren(tree); // 挂新树,旧树(若还被 detachedRoot 指着)不回收 } rebuild(); rebuild(); rebuild(); // 若期间 detachedRoot 一直指着中间某棵旧树,它们全部滞留堆中
replaceChildren 语义是「清空容器再挂新内容」,被清掉的旧节点若无其他引用即可回收——问题出在外部变量还指着它。修复原则:临时节点用局部变量,别存到长寿命容器;确实要缓存 DOM,问自己「缓存的是数据还是节点」——九成场景该缓存渲染所需的数据(字符串与普通对象),节点按需重建。detached 节点在堆快照里有专门分类,一眼可辨(下文排查法演示)。
const searchCache = new Map(); function search(keyword) { if (searchCache.has(keyword)) return searchCache.get(keyword); const result = heavyLookup(keyword); searchCache.set(keyword, result); // 只进不出 return result; }
缓存不是泄漏,「无界」才是。用户输入的组合是无限的,Map 只增不减,长会话页面内存单调上爬。三种收敛手段按场景选:
// 手段一:容量上限 + 淘汰(简化 LRU:超限即删最旧) const LIMIT = 100; if (searchCache.size >= LIMIT) { searchCache.delete(searchCache.keys().next().value); } // 手段二:过期时间(读时惰性清理) const TTL = 60000; function cachedGet(k) { const hit = searchCache.get(k); if (!hit) return undefined; if (Date.now() - hit.at > TTL) { searchCache.delete(k); return undefined; } return hit.v; } // 手段三:弱引用(键不可达时条目自动消失,适合「以 DOM 节点或对象为键」的附属数据) const meta = new WeakMap(); function attach(node, info) { meta.set(node, info); } // 节点回收,info 随之而去
WeakMap 的语义与回收器天然配合:键的可达性决定条目生死。给 DOM 节点挂私有数据、给对象挂临时标记,用 WeakMap 而不是 Map,泄漏通道直接焊死。无界数组同理:日志缓冲区、消息列表、图表数据窗口,都要有「最多保留 N 条」的滑动上限。
第 6.1 节的 demo 是最小版,工程里的版本更隐蔽——「中间层闭包」:
function createDashboard(loadData) { const hugeContext = { raw: new Array(2e6).fill(1) }; // 初始化用的大数据 function init() { /* 用 hugeContext.raw 做初始化,之后不再需要 */ } function refresh() { return loadData().then(r => r.summary); } // 只用远程摘要 init(); return { refresh }; } const dash = createDashboard(fetchSummary); // refresh 闭包与 init 同环境 → hugeContext 随 refresh 活到页面关闭
修复:把「一次性使用的重型数据」限制在初始化函数的局部,或初始化后显式置 null(长驻对象上才有意义),或把 refresh 独立成不共享环境的函数。审查口径:返回的每个函数,逐个检查它实际引用了环境里的哪些名字——引擎扣的是整个环境,你检查的却常常只是「我用了哪个」。6.1 图里那条「环境引用」箭头,在这一节是所有案情的现场中心。
开发者工具 Memory 面板,三步锁定:
第一步:页面跑稳定后拍快照 A( Heap snapshot)。
第二步:重复疑似泄漏的操作(比如开关同一个弹层二十次),再拍快照 B。
第三步:视图切到「距快照A的差异」(Objects allocated between A and B),按 Retained Size(保留大小)倒序。
正常情况,重复操作制造的临时对象在两次快照间应基本归零;如果某个构造器(比如 HTMLSpanElement、Array、Object)增量持续上涨且 Retained Size 可观,展开它的 Retainers(保留链)面板——那条链就是「谁在扣着谁」,沿链找到你代码里的变量名,修复点即在那行。

⚠️ 常见坑:看到内存上涨就先怀疑引擎或浏览器——先跑对比法,九成泄漏能在保留链上看到自己代码的变量名。另一个坑:弹层泄漏测试「开关一次」看不出问题,必须重复十到二十次放大增量,偶发的引用才会在差异视图里显形。
按快照对比法走一遍完整流程(浏览器面板操作,代码给最小复现形态)。
背景:地图页有「标记模式」,每开一次画五十个标记节点,关一次全部移除。用户反馈「反复开关几十次,页面越来越卡」。
第一步:稳定态快照 A。 打开页面,等加载平稳,Memory 面板拍 Heap snapshot,记为 A。
第二步:放大操作。 开关标记模式二十次(每次全开全关,理论上结束时内存应回到初始态)。
第三步:快照 B 与差异。 再拍 B,视图切「距 A 的差异」,按 Retained Size 倒序。实测形态:HTMLDivElement 增量约 1000(五十乘二十次,一个没回收!),其下挂着更大的 Array 与对象增量。
第四步:读保留链。 展开任意一个未回收的 div,Retainers 面板显示链条:div ← markers 数组 ← currentMode 对象 ← 全局模块。案情水落石出:开关函数里「关闭时移除了 DOM 节点」,但模块级 markers 数组还存着这些节点的引用——第二型泄漏(detached 节点)的教科书现场:
// 泄漏版:DOM 移除了,引用还在 const markers = []; // 模块级,长寿 function openMode() { for (let i = 0; i < 50; i++) { const dot = document.createElement('div'); dot.className = 'marker'; map.appendChild(dot); markers.push(dot); // 登记引用 } } function closeMode() { markers.forEach(d => d.remove()); // 只摘 DOM,数组没清 } // 修复版:两种改法任选 function closeModeFixed() { markers.forEach(d => d.remove()); markers.length = 0; // 改法一:同步清空引用 } // 改法二(更优):根本不存节点,存数据,节点按需重建 const markerData = []; // 只存坐标等纯数据
第五步:验证。 再跑「开关二十次加两次快照」的循环,div 增量归零,锯齿基线平稳,结案。整个流程十分钟,其中八分钟在第二、三步的操作与等待,真正的「分析」只是读一条保留链——工具把大海捞针变成按图索骥,前提是你知道图长什么样(本节的四型速查卡)。
内存上涨但不确定是不是泄漏,先做什么?
先做「放大实验」:怀疑哪个操作就重复它二十次,看内存是否线性累加且不回落。确定性泄漏(像上面的 detached 节点)会清晰显形;不累加的上涨多半是正常缓存或浏览器行为。这一步能避免对着健康页面白费功夫。
WeakMap 里的数据什么时候消失?
键对象不可达后的「某次 GC 时刻」——注意是异步且不确定的:不是键一置 null 条目立刻蒸发,而是等回收器跑那一轮。所以 WeakMap 不适合做「精确计数」或「及时通知」,只适合「键死了数据陪葬」的附属缓存语义。需要确定性生命周期,还是显式 delete。
iframe 里加载的第三方页面会泄漏主页面内存吗?
会且常见:iframe 移除后,它的窗口对象、脚本上下文若被主页面的任何引用扣住(变量、监听器、定时器跨框架注册),整棵框架的内存都滞留。排查思路同四型速查卡,保留链会直接显示引用来自主页面哪一行;设计上的预防是「iframe 通信走消息通道、不共享对象引用」,移除前先让双方解绑。
性能面板的内存锯齿,怎么区分「正常回收」与「泄漏前兆」?
看三点:锯齿的底部基线是否上移(正常:每次回落到同一水平;泄漏:底部逐级抬高);锯齿频率是否异常密集(分配过快,通常是热点代码造太多短命对象);回收后的净增量(操作一轮前后对比,应基本归零)。基线平稳的密集锯齿是「忙但健康」,基线爬升才是病——先修爬升,再考虑优化分配速率。
缓存占了很大内存,算泄漏吗?
判定标准是「有意且有界」而非大小:有淘汰策略、有命中统计、可以解释「为什么留这么多」的,是设计;只进不出、无法说明上界的,是泄漏的马甲。审查时的两个追问:「这个缓存的最坏占用是多少」(答不出就是隐患)、「满了以后会发生什么」(没有答案就是泄漏预备役)。把这两个答案写成注释放在缓存声明旁边,评审会感谢你。
发布前有没有自动化的泄漏检查?
有两层可行方案。轻量层:端到端测试里对核心流程做「重复操作加内存断言」——操作三十次后读取性能内存接口,断言增量低于阈值(阈值宽松,只拦恶性泄漏);重量层:持续集成里跑带无头浏览器的快照对比脚本,对关键页面做两快照差异报告,增量异常即告警。两层都只抓「确定性泄漏」,缓慢的形状问题仍要靠人肉快照复查——自动化是安全网,不是替代品。
移动端泄漏排查有什么不同?
设备与工具都更受限:内存预算小(低端机给标签页的配额更紧,同样增速更快触顶)、工具链弱(远程调试与真机性能面板不如桌面顺手)。实操对策三条:先用桌面浏览器复现同类操作路径(泄漏逻辑与设备无关,桌面查到就能修);真机上观察「页面被系统回收重载」的频率(回退前台时白屏重载,就是被杀过的痕迹);上报里带上内存水位采样,用线上数据补实验室看不到的真机分布。
四型泄漏与排查法讲完,下一节上升到方法论:怎样一开始就写出「不制造问题」的代码——性能清单是全书引擎知识的总集成。