不能直接用 map + lru 模拟 dom 节点缓存,因为 map 对 value 是强引用,导致已移除的 dom 节点无法被 gc 回收,引发内存泄漏;weakmap 不支持字符串 key 且无法实现 lru 排序;正确方案是 weakref + 元数据 map + finalizationregistry 三层防御。

为什么不能直接用 Map + LRU 模拟 DOM 节点缓存
因为 Map 对 value 是强引用,只要缓存里存着某个 HTMLElement,它就永远不会被 GC 回收——哪怕页面上早已 removeChild() 或导航跳走了。这在单页应用中极易导致 DOM 节点堆积、内存持续上涨。而 WeakMap 又只支持对象作 key,无法用字符串 ID 查节点,更没法做 LRU 排序。
WeakRef + 元数据 Map 的最小可行结构
核心是拆开“可查”和“可回收”:用普通 Map 存键元信息(key → { lastAccess, hitCount, valueRef }),其中 valueRef 是 WeakRef 实例;所有读写都围绕这个 Map,但每次 get() 前必须调用 valueRef.deref() 检查有效性。
-
set(key, node)时:先新建new WeakRef(node),再注册registry.register(node, key, weakRef)(传weakRef作 token,后续才能精准注销) -
get(key)时:先取元数据项,再if (ref?.deref()),无效则立刻metadataMap.delete(key) - LRU 排序靠维护一个
Set或数组记录访问顺序,每次get/set后把key移到末尾;淘汰时从开头遍历,对每个key执行deref(),跳过已失效项,直到腾出空间
FinalizationRegistry 必须配对 weakRef 注销
只靠轮询 deref() 不够:如果某个节点被外部 JS 持有强引用(比如还挂在某个未清理的事件监听器里),但长期没被缓存访问,它的元数据会一直卡在 Map 里,导致键膨胀。这时 FinalizationRegistry 是唯一能真正“感知死亡”的机制。
- 注册必须在
new WeakRef(node)后**立即执行**,且第三个参数必须传入该WeakRef实例本身(不是node) - 回调函数里只做一件事:
metadataMap.delete(key),不要尝试deref()、不要异步、不要抛错 - 不注销会导致重复触发——比如同一节点被多次
set(),旧注册未清除,GC 回收时可能删错 key
DOM 节点缓存特有的边界陷阱
和普通对象不同,DOM 节点有明确的生命周期信号(如 disconnect 自定义事件、removedCallback),但这些信号不可靠或需额外侵入式改造。所以必须坚持“弱引用 + 主动探测 + 终极兜底”三层防御。
- 禁止用
WeakRef包装整个{ key, node }对象——一旦外层包装对象被 GC,key 和 node 同时消失,你连清理依据都没了 - 避免在
get()返回前对deref()结果做任何 DOM 操作(如cloneNode()),因为此时节点可能刚被移除,但deref()还没返回undefined(存在微小时间窗) - 初始化时设好容量上限,且
maxsize === 0应直接禁用缓存逻辑,而不是让 LRU 链表空转
最易被忽略的是:WeakRef 不等于自动清理,FinalizationRegistry 回调也不保证执行时机——你得接受部分元数据残留,靠定期 cleanStale() 轮询兜底,这才是真实环境下的内存友好型设计。










