WeakMap 与 WeakSet

用弱键集合关联对象元数据和成员状态,理解可达性、对象标识、不可枚举性与垃圾回收边界。

难度 进阶 时长 标准深度约 10分钟
版本 Node 24
what

WeakMap 按对象标识保存值,WeakSet 记录对象成员关系;集合中的弱键或弱成员不会单独阻止垃圾回收。

trap

两者都不可枚举,也没有 size。它们不能替代需要列出、计数或序列化条目的 MapSet

fix

只有当数据生命周期应跟随对象,而且查询方已经持有同一个对象时才用弱集合;其余情况优先使用普通集合。

是什么,为什么存在

WeakMapWeakSet 弱集合(weak collection) WeakMap 把可回收的键关联到任意值,WeakSet 只记录可回收值是否属于集合。它们最重要的性质不是 API 更少,而是集合本身不会让键或成员继续存活。

普通 Map 会强引用它的对象键,普通 Set 也会强引用成员。只要集合仍然可达,这些对象就仍然可达。若集合只是给外部对象附加元数据,这种所有权往往不合适:元数据不应反过来延长业务对象、语法树节点或 DOM 节点的生命周期。

弱集合解决的是这种附属数据的生命周期问题。常见用途包括按对象缓存纯计算结果、保存库内部元数据,以及标记某个对象是否已经处理。查询代码必须已经持有原来的对象;弱集合不会提供从标识符、对象内容或序列化文本找回键的能力。

在 Node 24 中,符合条件的键或成员是对象和未注册的 Symbol。数组、函数、类实例与代理都属于对象。字符串、数字、nullundefined 以及 Symbol.for() 返回的注册符号不能作为弱键。

这两个类型属于同一个概念,不需要拆成两篇。它们共享键资格、对象标识、不可枚举性和垃圾回收语义,区别只在于 WeakMap 保存关联值,而 WeakSet 保存成员状态。

工作原理

JavaScript 的垃圾回收器从运行时根开始沿强引用查找对象。能通过这条路径找到的对象具有 可达性(reachability) ,不能找到的对象才可能被回收。具体算法、回收轮次和发生时间不是语言 API 的承诺。

弱集合到键的边不会单独构成强可达路径。下图中,应用释放 request 后,WeakMap 不能凭自己的边保住键。键不可达后,它对应的元数据也可以一起回收。

WeakMap 的关联关系通常用 Ephemeron 语义解释。值是否保持可达取决于键是否通过弱集合之外的路径可达。这个特殊规则允许值引用自己的键,而不让 WeakMap → value → key 形成一条使整组对象永远存活的自证路径。

WeakMapWeakSet 都按 对象标识(object identity) 匹配。两个内容相同的对象仍是两个键。对象属性后来发生变化也不会改变键的标识,因此弱集合不按结构查找,也不需要根据属性变化重新索引。

两者故意不公开迭代器、键列表或元素数量。如果程序能枚举弱键,就能观察垃圾回收何时发生,而同一段程序可能因内存压力和引擎策略得到不同结果。不可枚举性把这种不确定性挡在正常控制流之外。

API 边界

类型写入查询删除不提供
WeakMapset(key, value)get(key)has(key)delete(key)size、迭代、clear()
WeakSetadd(value)has(value)delete(value)size、迭代、clear()

WeakMap.prototype.get() 在键不存在时返回 undefined,但条目本身也可以存储 undefined。需要区分这两种状态时先调用 has()set() 返回接收者,add() 也返回接收者,因此两者都可以链式调用。

构造函数可以从可迭代输入初始化集合,例如 new WeakMap([[key, value]])。初始化完成后的弱集合本身仍不可迭代。构造时遇到不合格的键或成员会抛出 TypeError,不会跳过该项。

示例

下面三个示例分别展示附属元数据、按对象缓存和递归路径标记。输出由本地 Node 24 执行对应文件得到。

元数据与处理标记

同一个订单对象同时充当 WeakMap 的键和 WeakSet 的成员。新建一个内容相同的对象不会命中,因为集合比较的是标识。

metadata-and-tags.js
const metadata = new WeakMap();
const processed = new WeakSet();

function inspect(order) {
  if (processed.has(order)) {
    return `${metadata.get(order).label}: skipped`;
  }

  const label = `order-${order.id}`;
  metadata.set(order, { label, fields: Object.keys(order).length });
  processed.add(order);
  return `${label}: ${metadata.get(order).fields} fields`;
}

const order = { id: 7, total: 42 };

console.log(inspect(order));
console.log(inspect(order));
console.log(metadata.has({ id: 7, total: 42 }));
order-7: 2 fields
order-7: skipped
false

第一次调用创建元数据并添加处理标记,第二次调用通过同一对象命中。第三次查询使用新对象,所以返回 false。冻结对象不会改变这种行为,因为弱集合不向键对象写属性。

这个模式适合库给调用方对象添加内部状态。调用方看不到额外的自有属性,库也不会仅因保存这份状态就取得对象所有权。不过,库仍需控制谁能访问 metadata 变量;WeakMap 不是授权机制。

按配置对象缓存

缓存以配置对象本身为键。has() 明确区分「尚未编译」和可能存储的任意返回值,重复传入同一对象时不会再次编译。

schema-cache.js
const validators = new WeakMap();
let compilations = 0;

function validatorFor(schema) {
  if (validators.has(schema)) {
    return validators.get(schema);
  }

  compilations += 1;
  const required = [...schema.required];
  const validate = (input) =>
    required.every((key) => Object.hasOwn(input, key));

  validators.set(schema, validate);
  return validate;
}

const invoiceSchema = { required: ['id', 'total'] };
const sameShape = { required: ['id', 'total'] };

console.log(validatorFor(invoiceSchema)({ id: 'A', total: 0 }));
console.log(validatorFor(invoiceSchema)({ id: 'A' }));
validatorFor(invoiceSchema);
validatorFor(sameShape);
console.log(compilations);
true
false
2

invoiceSchema 的第二次和第三次使用命中同一缓存。sameShape 看起来相同,却有不同标识,所以触发第二次编译。若需求是让结构相同的配置共享结果,应定义稳定的结构键并使用 Map,不能期待 WeakMap 做深比较。

示例在编译时复制了 required 数组。否则,调用方稍后修改原数组,已经缓存的验证器可能悄悄改变行为。弱键只解决缓存生命周期,不会提供输入不可变性或缓存失效策略。

区分共享引用与循环

循环检测需要记录当前递归路径,而不是记录整次遍历中见过的所有对象。离开一个节点时调用 delete(),这样同一子对象出现在两个分支中不会被误报为环。

cycle-path.js
function findCircularPath(root) {
  const ancestors = new WeakSet();

  function visit(value, path) {
    if (value === null || typeof value !== 'object') return null;
    if (ancestors.has(value)) return path;

    ancestors.add(value);
    for (const [key, child] of Object.entries(value)) {
      const found = visit(child, `${path}.${key}`);
      if (found !== null) return found;
    }
    ancestors.delete(value);
    return null;
  }

  return visit(root, 'root');
}

const address = { country: 'FR' };
const shared = { billing: address, shipping: address };
const circular = { id: 'A' };
circular.self = circular;

console.log(findCircularPath(shared));
console.log(findCircularPath(circular));
null
root.self

shared 是有向无环对象图,只是两条路径指向同一个地址对象。circular.self 才回到当前祖先。这里使用 WeakSet 表达成员关系很合适,但算法正确性来自入栈时 add()、出栈时 delete() 的成对操作。

发现循环后函数提前返回,没有删除当时路径中的成员。这不会影响结果,因为 ancestors 只属于本次函数调用,随后会整体变得不可达。若把集合提升到模块级复用,这个控制流就会污染下一次检测。

陷阱

把弱集合当成可观察缓存

修复方法: 若产品功能需要列出、计数、过期或序列化所有条目,就使用 Map 并明确清理策略。不要额外维护一个强引用键列表来「补上」弱集合的枚举能力,因为那会抵消弱键的生命周期优势。

使用不可回收的键

修复方法: 需要按稳定 ID 查询时使用 Map。只有查询方持有对象或未注册符号,而且集合不应延长其生命周期时才使用弱集合;不要为了满足类型要求临时装箱字符串。

用新对象重新构造键

修复方法: 在接口上明确键是哪个对象,并沿调用链传递同一引用。若调用方只能提供 ID 或结构值,选择 Map 和显式规范化的键,同时处理碰撞与序列化边界。

把 WeakMap 当作内存泄漏修复器

修复方法: 从运行时根画出所有强引用路径,并为监听器和资源提供明确的注销或关闭操作。用堆快照确认保留路径,而不是从集合类型推断对象已经可以回收。

让 WeakSet 混淆「见过」与「当前祖先」

修复方法: 检测循环时让集合表示当前递归路径,并用成对的 add()delete() 维护它。若任务只是避免重复处理,则保留全局「见过」集合,但不要把重复访问报告成环。

深入 可达性契约与 Ephemeron

可达性契约与 Ephemeron

回收从根路径开始

垃圾回收(garbage collection) 关心对象是否还能从根到达,而不是某个变量是否被赋成 null。一个对象可能仍被闭包、任务队列或宿主 API 引用。反过来,即使对象组成引用环,只要整组对象不再从根可达,垃圾回收器仍可以回收它们。

弱集合不会让程序直接观察弱键的生死。丢弃最后一个已知强引用后,代码也失去了查询该条目的键。引擎可以延后回收,甚至在进程结束前都不执行一次能被你间接注意到的回收。

因此,正确性不能依赖「下一行代码之前已经回收」。弱集合适合减少不必要的保留关系,不适合安排必须发生的清理。事务、锁、事件订阅和文件句柄仍需要确定性的释放协议。

值到键的回边

WeakMap 简化成「弱键加上强值」容易漏掉一个关键条件。只要键通过弱集合之外的路径可达,关联值就应保持可达;若键只能通过该关联值反向到达,弱集合不能让这条回边证明键存活。

这正是 Ephemeron 与普通弱引用对的差别。垃圾回收器需要反复计算可达集合:先找出外部可达的键,再把这些键对应的值加入可达集合,直到没有新对象加入。规范定义可观察结果,引擎可以采用不同内部算法实现它。

若应用在别处强引用关联值,而这个值又引用键,那么存在 root → value → key 的普通强路径。此时键当然仍存活。WeakMap 只忽略自己到键的边,不会削弱对象图中的其他引用。

未注册 Symbol

现代 JavaScript 允许未注册 Symbol 作为 WeakMap 键和 WeakSet 成员。Symbol('token') 每次产生新的唯一值,程序失去该值后无法凭描述重新取得它,因此它具有可回收的身份。

Symbol.for('token') 从全局符号注册表返回可重复取得的值。注册表让它不满足弱键要求,所以传给 set()add() 会抛出 TypeError。内置的 Symbol.iterator 等知名符号同样不是未注册符号。

旧教程常断言弱集合「只能放对象」。这在早期 ECMAScript 版本中成立,但不符合 Node 24 的行为。类型检查若写成 typeof key === 'object' && key !== null,还会错误拒绝函数和合格的未注册符号。

下表把 Node 24 中容易混淆的输入放在一起。WeakMap 键和 WeakSet 成员遵循同一资格规则。

输入可用原因
{}、数组、类实例都是对象
函数函数也是对象
Symbol('local')它是未注册符号
Symbol.for('shared')它存在于全局符号注册表
Symbol.iterator它是规范定义的知名符号
字符串、数字、布尔值这些原始值没有可回收身份
nullundefined它们不是可回收键

资格检查通常不应由业务代码重新实现。让 set()add() 在边界拒绝无效输入,或者根据接口契约先做明确校验。自制判断很容易漏掉函数、跨 realm 对象或未注册符号。

身份、修改与缓存失效

对象作为键后可以继续修改。它仍然命中原条目,因为弱集合不读取属性来计算结构键。代理对象与其目标也是两个标识;用目标写入、用代理读取不会命中,反过来也一样。

这项稳定性让对象键很适合附属元数据,却不会自动让缓存结果保持新鲜。若结果依赖对象的可变属性,调用方修改属性后,旧结果仍会命中。可以冻结输入、复制所需字段、在修改边界调用 delete(),或改用带版本的普通键。

缓存还要处理异常与重入。若计算在 set() 前抛错,下一次调用通常应重试;若计算会同步重入同一个键,代码可能重复工作或无限递归。弱集合没有内建的「计算中」状态,必要时要显式建模。

不可枚举性是语义边界

缺少 size 和迭代 API 不是功能遗漏。一个可枚举弱集合会让垃圾回收策略进入程序输出:内存压力不同,键列表就可能不同。它还可能在枚举期间临时强化键,从而改变本来想观察的生命周期。

开发者工具有时会为了调试显示弱集合内容,但这种显示不是程序可以依赖的 JavaScript API。控制台也可能在展开对象时持有临时引用。调试界面的快照不能证明生产代码能枚举条目或预测回收时间。

若确实需要观测缓存命中率,可以记录请求次数、命中次数和计算次数,而不记录所有键。若必须逐条管理缓存,就使用 Map,再通过容量上限、显式失效或定时清理约束生命周期。

测试可观察契约

大多数弱集合测试不需要触发垃圾回收。可以直接验证同一对象命中、不同对象不命中、delete() 的返回值,以及非法键抛出 TypeError。这些都是语言公开的确定行为。

契约稳定测试
按对象标识查询用原对象与内容相同的新对象分别调用 has()
可存储 undefined同时断言 has(key)get(key)
显式删除断言第一次 delete(key)true,第二次为 false
拒绝字符串键断言 set('id', value) 抛出 TypeError
不支持枚举检查需求设计,不靠垃圾回收时机断言条目数

--expose-gc 的 Node 可以把 global.gc() 暴露给诊断脚本,但调用它也不构成逐对象、立即回收的语言保证。此类测试容易受优化、调试器和局部变量存活范围影响,不应成为普通单元测试的正确性依据。

需要调查真实保留问题时,使用目标运行时的堆快照和分配分析工具。先找到从根到对象的强路径,再判断其中哪条边的所有者有误。内存曲线下降只能提供过程证据,不能替代接口级断言。

与 WeakRef 和终结器的区别

WeakMapWeakSet 不会把键交还给你。查询必须提供已有键,所以它们适合关联与成员检查。WeakRef 可以尝试取得目标,FinalizationRegistry 可以登记清理回调,但两者有更明显的时序不确定性。

不要为了观察弱集合条目何时消失,就额外建立 WeakRef 或终结器。那会把原本简单的附属数据设计变成依赖垃圾回收调度的协议。缓存正确性、资源释放和业务通知都应使用显式状态与生命周期事件。

终结器适合少数兜底场景,不能替代 finallydispose、取消订阅或事务结束。弱集合本身更克制:它只改变保留关系,不承诺执行用户回调。

私有状态与访问边界

模块内的 WeakMap 可以保存实例对应的状态,外部代码无法通过反射实例属性找到这份状态。只要外部拿不到该 WeakMap 变量,就只能通过模块公开的方法访问数据。这是词法作用域形成的封装。

这种模式不等于语言级私有字段。共享同一 WeakMap 的模块代码可以读取任意已知实例的状态,错误接收者通常只会让 get() 返回 undefined#field 则执行私有标记检查,错误接收者会抛出 TypeError

新代码应从接口需求选择表示。需要与外部创建的任意对象关联数据时,WeakMap 很自然;状态属于一个类且应由语法强制访问时,私有字段通常更清楚。两种方式都不能自动隐藏公开方法返回的秘密。

遍历状态的两种含义

WeakSet 很适合在对象图算法中记录成员,但「成员」必须先定义清楚。去重遍历需要记录所有已经处理的对象,循环检测则需要记录当前递归祖先。两种算法可能使用相同 API,却不能共享同一套删除规则。

去重时通常只 add(),后续再次看到对象就跳过。循环检测在离开节点时 delete(),只有回到仍在栈上的对象才算环。把这两个状态都命名为 visited,很容易让代码审查漏掉语义差别。

递归抛错时也要考虑清理。集合只属于本次调用,异常退出后整体丢弃即可;集合要被复用时,应使用 try...finally 配对删除,或者在每次顶层调用开始时创建新集合。弱引用不会修复脏状态。

集合选择表

需求选择原因
按字符串或数字 ID 查询Map原始值不能作为弱键,且 ID 本身是稳定查找键
枚举、计数或序列化条目Map / Set普通集合公开完整内容
给外部对象附加元数据WeakMap元数据不应让对象继续存活
标记外部对象是否处理过WeakSet只需要成员查询,不需要列出对象
按结构相等复用结果Map 加规范键弱集合只比较标识

选择时先写出所有权句子:「只要缓存存在,键就必须存在」对应普通集合;「只要键存在,附属数据才有意义」对应弱集合。这比从集合名字或模糊的内存优化目标出发更可靠。

不要把弱集合称为性能优化。本文没有给出查找耗时或内存占用数字,也不假定某个引擎的数据结构。它提供的是不同的可达性契约;具体性能需要在目标引擎、实际对象数量和真实生命周期下测量。

审查所有权变化

弱集合本身也有生命周期。若包含它的模块、实例或请求上下文变得不可达,集合及其内部关联可以一起回收,不需要逐项调用 delete()delete() 用于业务状态需要立即失效,而不是用于替代垃圾回收器。

键仍从外部可达时,WeakMap 会保留对应值。把大对象放进值中不会让它天然变弱;只要长期存在的键仍存活,大值也仍存活。审查缓存时必须同时核对键和值的预期寿命。

重构所有权边界后应重新评估集合选择。原本由外部管理的短命对象可能变成应用级单例,原本无需枚举的元数据也可能新增管理界面。类型没有错,生命周期契约却可能已经变化。

延伸阅读

检查点

4个问题 · 1 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?