页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
线上监控最近开始报警: 某个页面的用户, 只要打开时间一长, 标签页内存就一路飙升, 最后卡到要刷新才能继续用。你打开 Chrome DevTools 的 Memory 面板, 录一份堆快照(heap snapshot), 搜一圈, 发现一堆本该早就消失的 DOM 节点还'活'在内存里, 状态是刺眼的 Detached HTMLButtonElement —— 意思是这些按钮早就从页面上被删掉了, 用户根本看不见, 但 JS 引擎死活不肯回收它们。
这篇文章就是从这样一次真实排查出发, 一步步讲清楚: 这种"删了却回收不掉"的泄漏到底是怎么发生的, 两种常见的补救办法为什么都不够好, 以及 JavaScript 专门为这个问题设计的解决方案 —— WeakMap —— 到底是怎么工作的。不要求你已经懂垃圾回收或者"引用"这些概念, 会从最基础的地方讲起; 最后落到怎么用、有哪些最佳实践、有哪些坑。
一、案发现场: 明明删了, 内存却没降
排查这类问题, 第一步通常是去找"谁还拿着这个本该消失的对象的引用"。顺着这条线往回查, 很容易在代码里发现类似这样的写法: 团队为了做埋点统计, 想记录"每个按钮上一次被点击的时间", 用一张表把按钮对象和时间戳关联起来:
const lastClickedAt = new Map()
button.addEventListener('click', () => {
lastClickedAt.set(button, Date.now())
})
单看这几行, 完全挑不出毛病, 埋点数据也确实是对的。问题出在这个页面是单页应用(SPA), 按钮会随着用户切 tab、开关弹窗被频繁创建和销毁 —— 而 lastClickedAt 是一个模块级变量, 从页面加载起就一直活着, 从来没人清空过它。于是它就这样不声不响地, 把每一个曾经被点击过的按钮的引用都攥在手里, 一个都不放。
要理解这为什么会导致内存降不下来, 得先搞清楚两个基础概念。
二、垃圾回收和"引用"是怎么回事
JavaScript 不需要你手动释放内存(不像 C 语言要 malloc/free)。引擎会自动判断: 一个对象如果没有任何地方还在引用它, 就会被垃圾回收器(GC)清理掉, 内存还给系统。
关键就在"没有任何地方还在引用它"这句话。举个例子:
let btn = document.createElement('button')
document.body.appendChild(btn)
// 后来页面逻辑把它从 DOM 里移除
document.body.removeChild(btn)
btn = null // 变量也不再指向它了
这时候, 只要没有别的变量、别的数据结构还存着这个按钮对象的引用, GC 迟早会把它回收掉。
但注意第一节里那段代码: lastClickedAt 这个 Map 也存了一份 button 的引用。哪怕你把 btn = null、把按钮从页面上删掉了, lastClickedAt 这张表里那条 button → 时间戳 的记录依然拿着 button 对象的引用没放。
Map 对它的 key 持有的是强引用(strong reference) —— 只要 Map 本身还活着(而这种全局/模块级的 Map 通常是活到页面关闭为止), 它 key 住的每一个对象就永远不会被回收, 不管这个对象在其他地方是不是早就没人要了。
这就是内存泄漏的成因: 逻辑上按钮已经"死"了, 但因为这张调试/统计用的表还攥着引用, 它在内存里"诈尸"般地一直活着。页面用得越久, 创建过的按钮越多, 泄漏的内存就越多。
三、土办法 1: 手动清理
发现泄漏之后, 很自然的补救办法是: 按钮销毁的时候, 顺手把它在表里的记录也删掉。
function destroyButton(button) {
lastClickedAt.delete(button)
button.remove()
}
这确实能修好问题, 但引入了一个新的负担: 你必须精确覆盖所有会让这个按钮"消失"的路径。正常点击关闭是一条路径, 用户直接刷新页面是一条, 父组件被卸载导致子按钮跟着消失又是一条, 某个异常分支里提前 return 导致清理代码没跑到又是一条……
只要漏掉一条路径, 泄漏就会回来。而且这种清理逻辑跟业务逻辑纠缠在一起, 每加一个新的"数据关联到按钮"的需求, 就要在心里再多记一条"别忘了对应的清理"。项目越大, 心智负担越重, 越容易漏。
四、土办法 2: 直接把数据塞进对象本身
另一种常见思路是不单独维护一张表, 而是直接在对象上加一个属性:
button.__lastClickedAt = Date.now()
这样数据跟对象绑得更紧, 对象销毁数据自然跟着消失, 看起来解决了泄漏问题。但这样做污染了对象本身的结构:
- 如果代码里有遍历这个对象所有属性的逻辑(
for...in、Object.keys()、序列化成 JSON 发给后端), 都会意外扫到这个多出来的字段。 - 如果
button是别的库返回给你的对象(不是你自己定义的类), 你在上面加字段有可能跟对方内部实现用的字段名撞车, 覆盖掉对方本来的数据, 引发很诡异的 bug。 - 多个不相关的模块都想在同一个对象上挂点自己的数据, 字段名很容易互相冲突。
所以这条路在"不是你自己拥有的对象"这个场景下, 通常是不安全的。
到这里, 我们遇到的核心矛盾就很清楚了: 既想在外部维护数据、不去污染对象本身的结构, 又想让这份外部数据的生命周期完全跟随对象走, 对象没了数据也该自动没。土办法 1(外部表 + 手动清理)满足了"不污染对象", 但清理靠人肉记忆, 容易漏; 土办法 2(挂在对象上)生命周期是对了, 但污染了对象结构。
WeakMap 就是 ES6(2015 年)专门为了同时满足这两个诉求而设计的数据结构。
五、WeakMap 是什么
WeakMap 长得跟 Map很像, 也是 key-value 结构, 但有一条本质区别: 它对 key 持有的是弱引用(weak reference)。
弱引用的意思是: 这个引用不算数。判断一个对象能不能被回收时, 引擎会忽略 WeakMap 里指向它的这一条。只要对象在其他地方已经没有别的(强)引用了, 哪怕它还作为某个 WeakMap 的 key 存在, GC 照样把它连同它在 WeakMap 里的那条记录一起回收掉。
把第一节的例子换成 WeakMap:
const lastClickedAt = new WeakMap()
button.addEventListener('click', () => {
lastClickedAt.set(button, Date.now())
})
代码几乎没变, 但语义完全变了: 按钮从页面移除、别的地方也不再引用它之后, 它会被正常回收, lastClickedAt 里那条记录也随之自动消失。不需要写任何 delete, 不需要担心漏掉某条销毁路径。
六、WeakMap 的 API 和限制
API 很小, 只有四个方法:
const wm = new WeakMap()
wm.set(obj, value) // 关联数据
wm.get(obj) // 取数据, 没有则返回 undefined
wm.has(obj) // 判断是否关联过
wm.delete(obj) // 手动移除(可选, 通常不需要)
和 Map 相比, 故意少了这些能力, 而且是设计上刻意去掉的, 不是疏漏:
- 没有
.size, 也没有.forEach()/.keys()/.values()/.entries(), 不支持for...of遍历。 - key 必须是对象(或 Symbol), 不能是字符串、数字这类原始值。
为什么故意阉割掉遍历能力? 因为 WeakMap 里的条目会随时因为 GC 而"凭空消失", 如果支持遍历, 遍历结果什么时候变、变成什么样完全不可预测, 语义会非常混乱。禁止遍历, 也顺带带来一个好处: 外部代码就算拿到了某个 WeakMap 实例, 也没有任何 API 能反查出里面到底存了哪些对象 —— 这让它天然适合存一些"外部不该窥探"的数据(下面私有字段的例子会展开)。
七、三个典型应用场景
场景 A: 给不属于自己生命周期的对象挂辅助数据
DOM 节点是最经典的例子, 前面按钮的例子就是这一类。再泛化一点说: 任何"别人创建、别人负责销毁, 你只是想顺路挂点数据"的对象, 都适合这个模式。
const metricsByElement = new WeakMap()
function trackHover(el) {
metricsByElement.set(el, { hoverCount: 0 })
}
function onHover(el) {
const m = metricsByElement.get(el)
if (m) m.hoverCount += 1
}
// el 从页面移除、其他地方也不再引用后
// metricsByElement 里对应的记录自动被回收, 不用手动清理
场景 B: 模拟私有字段
在 JS/TS 原生支持 #field 私有字段语法之前(这是比较新的语法), 想让类的某些内部状态外部代码物理上拿不到(不是"约定加下划线假装私有"), 常见做法是把数据存进一个被闭包关起来的 WeakMap, key 是实例本身:
const privateState = new WeakMap()
class Counter {
constructor() {
privateState.set(this, { count: 0 })
}
increment() {
const state = privateState.get(this)
state.count += 1
return state.count
}
}
const c = new Counter()
c.increment() // 1
console.log(c.count) // undefined, 外部访问不到
console.log(Object.keys(c)) // [], 实例上什么都没有
privateState 这个变量被闭包锁在模块内部, 外部代码即使拿到了 c 这个实例, 也没有任何手段反查出 privateState 里对应存了什么。现在有了原生的 #field 语法, 这个技巧在新代码里已经不是首选了, 但很多现存的老代码库、以及一些编译到旧版 JS 的场景里还能看到。
场景 C: 排查"这两个对象是不是同一个"这类问题
这是一个更贴近后端/状态管理场景的例子。假设你在维护一个会创建大量"实例"的系统, 某个业务 id(比如订单号、会话号)理论上应该始终对应同一个实例对象, 但你怀疑系统里存在 bug: 某个地方在业务 id 不变的情况下, 悄悄把旧实例销毁、又新建了一个实例来顶替, 而这个过程在业务层面完全看不出来(id 一样, 对外状态字段也一样)。
想在日志里证明"这确实是同一个实例, 没有被偷偷重建", 光靠业务 id 是不够的 —— 业务 id 在销毁重建前后都不变, 没法区分。你需要一个跟"对象这次被创建"这个事件绑定的标识。
一种做法是给这个类加一个字段, 构造时赋值一个随机数或自增 id, 但这样要改这个类的公共接口, 而且这个字段只是为了排查这一个问题才存在, 却要跟着对象结构永久留下来。
用 WeakMap 可以做到完全不改动这个类:
const debugIds = new WeakMap()
let seq = 0
function debugId(instance) {
if (!debugIds.has(instance)) {
debugIds.set(instance, ++seq)
}
return debugIds.get(instance)
}
function onSuspectedCollision(oldInstance, newInstance) {
log.info('possible_silent_recreate', {
oldDebugId: debugId(oldInstance),
newDebugId: debugId(newInstance)
})
}
如果 oldInstance 和 newInstance 其实是同一个内存对象(也就是没有发生偷偷重建), 两次调用 debugId() 会返回同一个数字; 如果确实被重建了, 就会是两个不同的数字。这个映射表只在真正怀疑发生碰撞时才第一次分配 id, 平时完全不占用这个类的任何结构空间, 排查完之后也不需要手动清理 —— 实例正常销毁后, 这条调试记录自动跟着消失。
八、最佳实践
- 先问自己一句话: "这份数据的生命周期, 是不是应该完全跟着某个我不拥有的对象走?" 如果答案是"是", 优先考虑 WeakMap, 而不是 Map + 手动清理。
- 只用它存"锦上添花"的数据, 不要存核心业务状态。 因为条目何时被回收不可控(见下一节), 任何时候都可能读不到, 核心逻辑不该依赖它一定还在。
- 开发环境可以额外镜像一份便于调试。 因为 WeakMap 不能遍历、看不到内容, 排查问题很不方便。可以在开发模式下, 用同样的 key 再塞一份到一个普通的
Map/Set里专门用来打日志、调试, 生产环境不启用这份镜像, 避免引入真正的泄漏。 - 不要用它替代所有的 Map。 如果你的 key 本来就是字符串/数字, 或者你需要遍历、需要知道条目数量, 该用 Map 就用 Map, 硬套 WeakMap 只会让代码更别扭。
- 模块级的 WeakMap 实例本身要长期存在, 只有 key 是弱引用。 不要误以为整个 WeakMap 会自己消失, 它自己作为变量的生命周期还是跟普通对象一样, 由持有它的作用域决定。
九、限制和坑
key 必须是对象, 原始值会直接报错。
const wm = new WeakMap()
wm.set('some-id', 1) // TypeError: Invalid value used as weak map key
如果发现自己想拿一个字符串 id 当 key, 说明这个场景一开始就不该用 WeakMap, 该用普通 Map 或对象。
不能遍历, 没有 .size, 调试体验差。
wm.forEach // undefined
wm.size // undefined
[...wm] // TypeError: wm is not iterable
出问题时没法直接把 WeakMap 内容打印出来看, 参考上面"最佳实践"里的镜像调试建议。
GC 时机不可控, 不能当成"确定性"的清理机制。
WeakMap 保证的是"不会阻止对象被回收", 但不保证"什么时候回收"、"回收动作跑没跑"。如果业务逻辑要求某份数据必须在某个确切时间点失效(比如安全相关的临时凭证、限时锁), 不能依赖 WeakMap 的自动清理, 该用显式的过期时间戳 + 定时检查, 或者显式 delete()。
比较的是引用相等, 不是内容相等。
const wm = new WeakMap()
wm.set({ id: 1 }, 'a')
wm.get({ id: 1 }) // undefined, 这是另一个对象, 不是同一个引用
这在"想借助对象引用是否变化来判断有没有被重建"这类场景下正是我们想要的行为(见场景 C), 但如果本意是"按内容查找", 用错了数据结构, 结果会很反直觉, 排查半天才发现是这个原因。
不能被序列化或跨上下文传输。
WeakMap 实例不能 JSON.stringify, 也不支持结构化克隆(没法直接塞进 postMessage 发给另一个 iframe/worker)。它天生只适合活在单个 JS 运行时内部, 不适合需要持久化或跨进程传递的数据。
老环境兼容性。
WeakMap 从 ES6 就在规范里了, 现代浏览器和 Node.js 都支持多年, 日常开发基本不用担心。只有极少数需要兼容非常古老浏览器(如 IE11 以下)的场景才需要留意, 而且这类环境的 polyfill 通常做不到真正的弱引用语义, 只能退化成手动清理的 Map, 等于失去了 WeakMap 最核心的能力。
十、一句话总结
WeakMap 解决的是一个具体的工程问题: 如何在不拥有一个对象生命周期控制权的前提下, 安全地给它挂外部数据, 而不需要手写清理逻辑、也不需要污染这个对象本身。核心机制就是弱引用 —— "我在外部记录过这个对象"这件事本身, 不应该成为"这个对象不能被回收"的理由。
前面三个场景(DOM 节点辅助数据、私有字段模拟、排查对象是否被偷偷重建)本质上都是同一件事的不同外壳。