页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生

页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生

线上监控最近开始报警: 某个页面的用户, 只要打开时间一长, 标签页内存就一路飙升, 最后卡到要刷新才能继续用。你打开 Chrome DevTools 的 Memory 面板, 录一份堆快照(heap snapshot), 搜一圈, 发现一堆本该早就消失的 DOM 节点还'活'在内存里, 状态是刺眼的 Detached HTMLButtonElement ------ 意思是这些按钮早就从页面上被删掉了, 用户根本看不见, 但 JS 引擎死活不肯回收它们。

这篇文章就是从这样一次真实排查出发, 一步步讲清楚: 这种"删了却回收不掉"的泄漏到底是怎么发生的, 两种常见的补救办法为什么都不够好, 以及 JavaScript 专门为这个问题设计的解决方案 ------ WeakMap ------ 到底是怎么工作的。不要求你已经懂垃圾回收或者"引用"这些概念, 会从最基础的地方讲起; 最后落到怎么用、有哪些最佳实践、有哪些坑。

一、案发现场: 明明删了, 内存却没降

排查这类问题, 第一步通常是去找"谁还拿着这个本该消失的对象的引用"。顺着这条线往回查, 很容易在代码里发现类似这样的写法: 团队为了做埋点统计, 想记录"每个按钮上一次被点击的时间", 用一张表把按钮对象和时间戳关联起来:

ts 复制代码
const lastClickedAt = new Map()

button.addEventListener('click', () => {
  lastClickedAt.set(button, Date.now())
})

单看这几行, 完全挑不出毛病, 埋点数据也确实是对的。问题出在这个页面是单页应用(SPA), 按钮会随着用户切 tab、开关弹窗被频繁创建和销毁 ------ 而 lastClickedAt 是一个模块级变量, 从页面加载起就一直活着, 从来没人清空过它。于是它就这样不声不响地, 把每一个曾经被点击过的按钮的引用都攥在手里, 一个都不放。

要理解这为什么会导致内存降不下来, 得先搞清楚两个基础概念。

二、垃圾回收和"引用"是怎么回事

JavaScript 不需要你手动释放内存(不像 C 语言要 malloc/free)。引擎会自动判断: 一个对象如果没有任何地方还在引用它, 就会被垃圾回收器(GC)清理掉, 内存还给系统。

关键就在"没有任何地方还在引用它"这句话。举个例子:

ts 复制代码
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: 手动清理

发现泄漏之后, 很自然的补救办法是: 按钮销毁的时候, 顺手把它在表里的记录也删掉。

ts 复制代码
function destroyButton(button) {
  lastClickedAt.delete(button)
  button.remove()
}

这确实能修好问题, 但引入了一个新的负担: 你必须精确覆盖所有会让这个按钮"消失"的路径。正常点击关闭是一条路径, 用户直接刷新页面是一条, 父组件被卸载导致子按钮跟着消失又是一条, 某个异常分支里提前 return 导致清理代码没跑到又是一条......

只要漏掉一条路径, 泄漏就会回来。而且这种清理逻辑跟业务逻辑纠缠在一起, 每加一个新的"数据关联到按钮"的需求, 就要在心里再多记一条"别忘了对应的清理"。项目越大, 心智负担越重, 越容易漏。

四、土办法 2: 直接把数据塞进对象本身

另一种常见思路是不单独维护一张表, 而是直接在对象上加一个属性:

ts 复制代码
button.__lastClickedAt = Date.now()

这样数据跟对象绑得更紧, 对象销毁数据自然跟着消失, 看起来解决了泄漏问题。但这样做污染了对象本身的结构:

  • 如果代码里有遍历这个对象所有属性的逻辑(for...inObject.keys()、序列化成 JSON 发给后端), 都会意外扫到这个多出来的字段。
  • 如果 button 是别的库返回给你的对象(不是你自己定义的类), 你在上面加字段有可能跟对方内部实现用的字段名撞车, 覆盖掉对方本来的数据, 引发很诡异的 bug。
  • 多个不相关的模块都想在同一个对象上挂点自己的数据, 字段名很容易互相冲突。

所以这条路在"不是你自己拥有的对象"这个场景下, 通常是不安全的。

到这里, 我们遇到的核心矛盾就很清楚了: 既想在外部维护数据、不去污染对象本身的结构, 又想让这份外部数据的生命周期完全跟随对象走, 对象没了数据也该自动没。土办法 1(外部表 + 手动清理)满足了"不污染对象", 但清理靠人肉记忆, 容易漏; 土办法 2(挂在对象上)生命周期是对了, 但污染了对象结构。

WeakMap 就是 ES6(2015 年)专门为了同时满足这两个诉求而设计的数据结构。

五、WeakMap 是什么

WeakMap 长得跟 Map很像, 也是 key-value 结构, 但有一条本质区别: 它对 key 持有的是弱引用(weak reference)

弱引用的意思是: 这个引用不算数。判断一个对象能不能被回收时, 引擎会忽略 WeakMap 里指向它的这一条。只要对象在其他地方已经没有别的(强)引用了, 哪怕它还作为某个 WeakMap 的 key 存在, GC 照样把它连同它在 WeakMap 里的那条记录一起回收掉。

把第一节的例子换成 WeakMap:

ts 复制代码
const lastClickedAt = new WeakMap()

button.addEventListener('click', () => {
  lastClickedAt.set(button, Date.now())
})

代码几乎没变, 但语义完全变了: 按钮从页面移除、别的地方也不再引用它之后, 它会被正常回收, lastClickedAt 里那条记录也随之自动消失。不需要写任何 delete, 不需要担心漏掉某条销毁路径。

六、WeakMap 的 API 和限制

API 很小, 只有四个方法:

ts 复制代码
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 节点是最经典的例子, 前面按钮的例子就是这一类。再泛化一点说: 任何"别人创建、别人负责销毁, 你只是想顺路挂点数据"的对象, 都适合这个模式。

ts 复制代码
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 是实例本身:

ts 复制代码
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 可以做到完全不改动这个类:

ts 复制代码
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)
  })
}

如果 oldInstancenewInstance 其实是同一个内存对象(也就是没有发生偷偷重建), 两次调用 debugId() 会返回同一个数字; 如果确实被重建了, 就会是两个不同的数字。这个映射表只在真正怀疑发生碰撞时才第一次分配 id, 平时完全不占用这个类的任何结构空间, 排查完之后也不需要手动清理 ------ 实例正常销毁后, 这条调试记录自动跟着消失。

八、最佳实践

  • 先问自己一句话: "这份数据的生命周期, 是不是应该完全跟着某个我不拥有的对象走?" 如果答案是"是", 优先考虑 WeakMap, 而不是 Map + 手动清理。
  • 只用它存"锦上添花"的数据, 不要存核心业务状态。 因为条目何时被回收不可控(见下一节), 任何时候都可能读不到, 核心逻辑不该依赖它一定还在。
  • 开发环境可以额外镜像一份便于调试。 因为 WeakMap 不能遍历、看不到内容, 排查问题很不方便。可以在开发模式下, 用同样的 key 再塞一份到一个普通的 Map/Set 里专门用来打日志、调试, 生产环境不启用这份镜像, 避免引入真正的泄漏。
  • 不要用它替代所有的 Map。 如果你的 key 本来就是字符串/数字, 或者你需要遍历、需要知道条目数量, 该用 Map 就用 Map, 硬套 WeakMap 只会让代码更别扭。
  • 模块级的 WeakMap 实例本身要长期存在, 只有 key 是弱引用。 不要误以为整个 WeakMap 会自己消失, 它自己作为变量的生命周期还是跟普通对象一样, 由持有它的作用域决定。

九、限制和坑

key 必须是对象, 原始值会直接报错。

ts 复制代码
const wm = new WeakMap()
wm.set('some-id', 1) // TypeError: Invalid value used as weak map key

如果发现自己想拿一个字符串 id 当 key, 说明这个场景一开始就不该用 WeakMap, 该用普通 Map 或对象。

不能遍历, 没有 .size, 调试体验差。

ts 复制代码
wm.forEach // undefined
wm.size    // undefined
[...wm]    // TypeError: wm is not iterable

出问题时没法直接把 WeakMap 内容打印出来看, 参考上面"最佳实践"里的镜像调试建议。

GC 时机不可控, 不能当成"确定性"的清理机制。

WeakMap 保证的是"不会阻止对象被回收", 但不保证"什么时候回收"、"回收动作跑没跑"。如果业务逻辑要求某份数据必须在某个确切时间点失效(比如安全相关的临时凭证、限时锁), 不能依赖 WeakMap 的自动清理, 该用显式的过期时间戳 + 定时检查, 或者显式 delete()

比较的是引用相等, 不是内容相等。

ts 复制代码
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 节点辅助数据、私有字段模拟、排查对象是否被偷偷重建)本质上都是同一件事的不同外壳。

相关推荐
竹林8181 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
Z小明1 小时前
第 6 章 组件进阶
前端·vue.js
江华森1 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端
南青1 小时前
Vue 3 中后台实战:我踩过的 10 个坑和最佳实践
前端
江华森1 小时前
HTTPS协议详解——SSL/TLS握手、证书与加密通信
前端
江华森1 小时前
从一个HTTP请求看网络分层原理:基于华为云ECS的真实抓包实战
前端
江华森1 小时前
TCP协议详解——三次握手、四次挥手与连接状态
前端
甲维斯1 小时前
《钢铁洪流》官网搞定,纯AI制作,Opus5操刀!
前端·人工智能·游戏开发