WeakMap 与 WeakSet:保留什么,又丢弃什么
一次固定形态的 Node 探针:WeakMap 接受哪些键、为何无法计数、强引用 Map 如何留住条目,以及弱集合在序列化时为何失败。
BotBrowser Team
BotBrowser 支持在声明的 Profile 下重放经授权的同源 fixture,并保留一份范围受限的记录:页面存了什么、什么时候存的;但它不能枚举弱集合的内容,不能读取页面已不再持有的键对应的条目,也不能把已被丢弃的条目还原成值。本文用一次探针覆盖十一次实测,记录 WeakMap 接受哪些键类型、它自己的方法遇到原始值键时如何表现、强 Map 是否让同一个键继续存活、WeakRef 与 FinalizationRegistry 补充了什么,以及弱集合在被序列化时会发生什么。
摘要
WeakMap接受对象、函数、数组与未注册的符号,对null、字符串与数字抛出TypeError,因此探针输出accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError。WeakMap与WeakSet都没有size、keys、values、entries或forEach,所以探针在两者上都得到none。- 强
Map与WeakMap可以同时持有同一个键;丢掉外部引用后,强条目仍在,size=1。 - 先
delete再get返回undefined,随后对同一个键执行set仍然有效,所以弱条目是可替换的,而不是一次性的。 set对原始值键抛错,而get、has与delete返回未命中,这就是读路径可能对坏键保持沉默的原因。WeakRef.deref()在目标仍可达时返回目标,第二次请求回收之后返回undefined,这是一个不需要注册表就能读到的回收信号。FinalizationRegistry增加了一个带保留值的回调,而entryTypes: ['gc']的PerformanceObserver也会报告每次回收,所以两者都不是唯一的观察者。- 用
Symbol.for()注册过的键会被拒绝,所以符号规则覆盖Symbol()和知名符号,但不覆盖注册表。 WeakMap经JSON.stringify序列化为{},经structuredClone则抛出DOMException。
目录
- 弱集合究竟持有的是什么
- WeakMap 接受哪些键
- 为什么无法计数或列出
- 强引用对同一个键做了什么
- WeakRef 与 FinalizationRegistry 补充了什么
- 弱集合无法承受什么
- 记录一次保留读数
弱集合究竟持有的是什么
WeakMap 持有一个键与值之间的关联,却不会让这个键保持存活。只有当键还能从别处到达时,值才可达;一旦没有其他东西指向这个键,整个条目就变成可回收的。探针分别检查这句话的两半,因为它们的失败方式不同。
node=v26.9.0
weakmap-keys -> accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError
no-enumeration -> weakmap=none weakset=none
same-key-both -> strong=1 weak=value
strong-still-has-entry -> size=1
delete-then-read -> undefined
reset-after-delete -> second
weakref-deref-before -> true
primitive-key-methods -> get=undefined has=false delete=false set=throws:TypeError
missing-object-key -> undefined
weakset-identity -> frozen=true copy=false
serialization -> json=ok:{} structuredClone=throws:DOMException
第一行关于键,第二行关于集合,其余各行关于它们周围发生的事。按这个顺序读输出,是把三者区分开的最快方式,因为人们期待 Map 具备的属性分散在这三个答案里,而没有一个能像 Map 用户期待的那样成立。
实际后果是,WeakMap 不是带容量上限的缓存,而相邻的问题是哪些值能挺过复制,structuredClone 与 JSON 给出了答案,这件事弱集合根本做不到。它是给一个你已经持有的对象附加数据的地方,并保证这份附加数据随对象一起消失。把每个元素的状态存进 WeakMap 而不是元素属性上的页面,不需要清理代码;以请求对象为键、把每个请求的状态存进 WeakMap 的页面也一样。它得不到的是任何询问“存了多少”的方式,这就是接下来两节讨论你能观察什么、不能观察什么的原因。
WeakMap 接受哪些键
键必须是运行时能以弱方式持有的东西。探针尝试七种键类型,并记录哪些能通过 set:
weakmap-keys -> accepted=object,function,array,symbol rejected=null:TypeError,string:TypeError,number:TypeError
对象、函数与数组是显而易见的情形。符号才是有趣的那个,因为语言发生了变化:非注册符号成为合法的弱键,使以符号为键的记录也能像以对象为键的记录那样被回收。探针在这个运行时上确认了这一点,而在较旧引擎上阅读的读者应把符号那一行视为与版本相关,而不是普遍成立。例外是注册表:另一个探针把 Symbol()、Symbol.for() 和 Symbol.iterator 当作键,打印出 Symbol()=accepted、Symbol.for()=TypeError 和 Symbol.iterator=accepted,所以注册过的符号不是弱键,未注册的才是。
null 被拒绝,因为它不是对象;字符串与数字出于同样的原因被拒绝:它们是原始值,而原始值没有回收器能够观察的身份。因此,用字符串 id 给 WeakMap 做键的调用方,构建的是一个不可能工作的东西,而失败在 set 时到来,而不是更晚,这是问题中较友好的一半。
不友好的一半是读方法不会以同样方式失败。探针用字符串 text 调用每个方法:
primitive-key-methods -> get=undefined has=false delete=false set=throws:TypeError
只有 set 抛错。get 返回 undefined,has 返回 false,delete 返回 false,这些答案与一个合法但不存在的键所产生的结果完全相同。因此,从调用方接收 id 的读路径无法区分“这个键无效”与“这个键没有条目”,只有写路径是错误变得可见的地方。在触碰映射之前把每个 id 规范化为对象的辅助函数能补上这个缺口,而不这样做的辅助函数会在生产环境留下静默的未命中。
冻结的对象仍是合法的键,成员资格由身份而非形态决定。探针注册一个冻结对象,然后询问另一个内容相同的对象:
weakset-identity -> frozen=true copy=false
冻结的原件是成员,副本不是,尽管两者序列化结果完全相同。这与 Map 和 Set 遵循的规则相同,值得说明,因为以“id 为 1 的那个对象”而不是“这个对象”做键的调用方,会得到一个看起来像集合缺陷的未命中。
为什么无法计数或列出
弱集合无法报告自己的内容,因为报告本身就是让它们保持存活的那件事。探针询问每个集合是否具备 Map 或 Set 用户会去找的那些方法:
no-enumeration -> weakmap=none weakset=none
两个集合都没有 size、keys、values、entries 或 forEach。这不是缺少功能,而是定义本身。size 属性必须统计回收器可以自由丢弃的条目,因此这个数字会在没有任何代码运行的情况下变化;而 keys() 迭代器会把每个产出的键的强引用交给调用方。
实际代价是,页面无法在诊断信息中报告“这个弱映射持有十二个条目”。它能报告的是自己仍强引用持有的对象数量,那是另一个数字,而且往往更有用:如果页面保留一份已附加数据的元素列表,那份列表就是强的一侧,弱的一侧只能从它推断。
这个代价在测试中也会出现。想证明某个条目已被回收的测试无法对集合断言,因为没有什么可断言的。它必须对可观察的那一半断言,也就是 WeakRef.deref() 返回 undefined,而这要求回收器已经运行。下一节讨论正是为此存在的那对 API,再下一节讨论测试在完全没有集合的情况下仍能检查的那件事。
还有第三种代价,出现在审计中而不是代码中。想判断页面是否泄漏的审查者只能推理强的一侧,因为弱的一侧无法枚举。“这个映射持有多少条目”没有答案,而有答案的问题是“页面仍持有多少可能成为键的对象”。保留打开面板注册表、待处理请求列表或已挂载元素数组的页面,恰好持有那么多潜在键,附加在它们上面的弱条目会与这些列表同寿。因此阅读内存概况,关键在于找到强列表,而不是找到弱映射。
强引用对同一个键做了什么
只有当没有其他东西持有键时,弱条目才会消失。探针把同一个键同时放进强 Map 与 WeakMap,然后丢掉外部引用:
same-key-both -> strong=1 weak=value
strong-still-has-entry -> size=1
变量仍持有键时,弱查找依然有答案,这是预期的那一半。第二行才是关键:变量被重新赋值后,强映射仍报告一个条目。弱映射也没有忘掉那个值,因为强映射还持有键,所以通过那个键读弱映射仍然返回 value。让这一对存活的是强的那一侧,弱的那一侧只是跟随。
这种相互作用是页面意外破坏自己弱集合的最常见方式。一个把元数据存进 WeakMap、同时又把每个键推入调试面板数组的模块,让每个条目都永久可达,而调试面板正是什么都回收不掉的原因。那个数组不必被使用;它只需要存在,并从活跃作用域可达。
反向的错误是以为 delete 是条目消失的方式。探针删除一个条目,然后复用同一个键:
delete-then-read -> undefined
reset-after-delete -> second
delete 移除关联并返回布尔值,键本身不受影响,所以之后对同一个键执行 set 会正常工作。期待被删除的键变得不可用(像一次性令牌那样)的调用方会感到意外:集合遗忘的是值,不是键。
第三个错误是期待弱集合能限制内存。它什么都不限制,因为它没有可供限制的大小。通过 WeakMap 泄漏内存的页面,泄漏发生在值上,而不是键上:指回自己键的值,或指向大缓冲区的值,会让那个缓冲区在键存活期间一直存活。探针看不到这一点,因为探针不持有这样的循环,这也是内存审查必须像检查键一样仔细检查值的原因。
第四个错误更微妙,出现在单页应用中。以路由对象为 WeakMap 键的页面,在每次导航时都会创建新键,于是映射为每次没有其他引用的导航累积一个条目,回收器可以自由丢弃它们全部。这正是预期的行为,也解释了为什么建立在同一个映射上的计数器从不下降:计数器就是强的一侧。想衡量导航更替的页面必须统计导航次数,而不是条目数。
最后一个结构性的点是,弱键可以是代理、跨 realm 对象或冻结记录,三者在保留行为上完全一致。回收器追踪的是可达性,不是形态,所以页面能改变结果的唯一做法,是在别处增加或移除一个强引用。这让弱集合成为一个跟随者:它的行为完全由从不提及它的代码决定。
WeakRef 与 FinalizationRegistry 补充了什么
WeakRef 与 FinalizationRegistry 是弱保留中可观察的那一半,探针两者都用到了:
weakref-deref-before -> true
new WeakRef(target) 不会让 target 保持存活,而 deref() 在其他东西仍持有目标时返回目标。探针先检查肯定情形。反面情形只在回收器运行之后才出现,而脚本可以请求那次运行:v8.setFlagsFromString('--expose-gc') 之后再执行 vm.runInNewContext('gc'),在一个 globalThis.gc 为 undefined 的普通 Node 进程里得到一个可用的 gc 函数。
请求来的运行仍然不足以在同一个 job 里看到 undefined,一次请求的运行跨 job 也不够。第二个探针丢弃三个目标,调用 gc,立刻读每个引用,然后隔一轮再调用一次 gc 并再次读取:
deref-shape -> same_job=true after_second_gc=false | same_job=true after_second_gc=false | same_job=true after_second_gc=false
所有引用在请求回收的那个 job 里仍然作答,而在第二次请求运行之后才都返回 undefined。第一次运行把对象标记为可回收;引用要等到之后的某次回收真正收回它们才不再作答,所以一个在单次 gc 之后立刻断言 undefined 的测试,失败原因与保留无关。同一个探针还展示了请求运行之后 FinalizationRegistry 回调被触发,这就是为什么想证明回收的测试既要注册表回调,也要 deref() 检查。
FinalizationRegistry 是回调形式:页面用一个持有值注册目标,目标被回收后,注册表带着那个持有值回调。这个回调不是 promise,也不按页面能控制的节奏运行。它可能晚得多才运行,或者在页面卸载前根本不运行,因此依赖它来保证正确性的页面,拥有的是正确性缺陷,而不是性能问题。
这一对 API 对诊断有用,对清理无用,而诊断回调最终在什么顺序上执行属于调度问题,微任务与定时器 有完整说明。想知道元素缓存是否真的在缩小的页面,可以为每个键注册一个计数器并记录回调,从而把“我觉得这个被回收了”变成一个数字。想释放资源的页面应使用显式的 close、dispose 或 AbortController,因为它们按页面所说的时机运行,而不是按回收器决定的时机运行。
还有一处不对称值得记录。WeakRef 可以为任何对象创建,包括页面仍强引用持有的对象,而引用本身不增加任何保留。为每个元素创建一个 WeakRef 并把引用保存在数组里的页面,保留的是一个由小对象组成的数组,这通常没问题,但数组本身是强的,并且会随页面见过的元素数量增长。探针的单个引用掩盖了这一点,对真实页面的审查应统计引用数量,而不只是统计目标数量。
弱集合无法承受什么
弱集合不是数据。它无法被复制、记录或发送,探针展示了两种失败:
serialization -> json=ok:{} structuredClone=throws:DOMException
JSON.stringify 不抛错;它产出 {},因为 WeakMap 没有自有可枚举属性。两者中它更危险,因为输出是合法 JSON,记录它的调用方看到的是空对象而不是错误。因此,一条说“缓存是空的”的记录,可能由一个满的缓存产生,而输出中没有任何东西能区分两者。
structuredClone 则抛出 DOMException,这是诚实的答案:弱集合没有克隆语义,因为克隆必须决定副本是否以同样的方式为弱。把 WeakMap 穿过 worker 边界或通过 postMessage 传递的页面会得到那个异常,而不是静默的空对象,这正是值得为之设计的失败模式。
同样的限制适用于内部含有 WeakMap 的普通对象。JSON.stringify 遍历自有属性,找到弱集合,为该字段产出 {},而对象的其余部分正常序列化。探针的单个集合是简单情形;真实数据通常把集合嵌套在里面,而空字段在很长的日志行中很容易被忽略。
记录一次保留读数
一次保留读数支持一个范围很窄的断言:这个运行时、这些键类型、这种引用结构,产生了这些观察。五个字段让它可核查:
- 运行时,因为符号成为合法弱键时,可接受的键类型集合发生了变化。
- 尝试过的键类型,因为
set时的TypeError与get时的静默未命中是不同结果。 - 页面仍持有的强引用,因为其中任何一个都会让每个条目保持存活。
- 是否请求回收器运行,因为
deref()返回一个值并不能证明发生了回收。 - 序列化路径,因为
JSON.stringify返回空对象,而structuredClone抛错。
公开来源
- MDN:WeakMap
- MDN:WeakSet
- MDN:WeakRef
- MDN:FinalizationRegistry
- ECMAScript:符号作为 WeakMap 键
- BotBrowser:自动化一致性参考
实践结论
用弱集合给你已经持有的对象附加数据,不要期待它提供别的东西:没有大小,没有枚举,没有序列化,也没有按你的时间表运行的清理。在键进入映射之前把它们规范化为对象,让无效键在写入时失败,而不是在读取时消失。审计页面的强的一侧,因为一个被保留的数组就能击溃它列出的每一个弱条目。需要释放时,调用显式的 dispose 路径,把 WeakRef 与 FinalizationRegistry 留给诊断。至于你试图存储的数据在复制后还剩下什么,请阅读 structuredClone 与 JSON;至于触碰这些条目的回调按什么顺序运行,请阅读 微任务与定时器。
本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界