Composites S2
中文标题:Composites 提案
- 阶段: Stage 2
- 状态: 进行中
- ECMAScript 版本: —
- 同步时间: 2026年10月11日
- English original · 官方仓库
该提案引入具有明确定义相等性的内置复合值,使 Map 和 Set 的键可以表示结构化的值组。它解决了 Map 和 Set 使用 SameValueZero 所带来的限制:所有对象只等于自身,无法让两个具有相同内容的不同对象在集合中被视为相等。复合值是由参数自身的可枚举字符串键属性创建的对象,不允许 symbol 键,并且会被驻留,因此具有相同具名值的复合值是同一个对象。该设计还涵盖键排序、-0 规范化以及跨 realm 行为。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
Composites 提案
用于 Map 和 Set 的键,表示结构化的值组。
状态
阶段:2
负责人:Ashley Claymore
草案规范:https://tc39.es/proposal-composites
问题
目前,Map 和 Set 总是使用 SameValueZero 来回答“这个值是否在这个集合中?”。
这意味着,当涉及对象时,所有对象只等于自身。没有办法覆盖此行为,使两个不同的对象在集合中被视为相等。
当前的变通方案
在 JavaScript 中绕过这一限制的一种方式是将值扁平化为字符串表示。
其缺点是:
- 很容易构造出错误的字符串。例如
JSON.stringify:- 如果对象的键以不同顺序枚举,会产生不同的字符串。
- 会省略没有 JSON 表示形式的值,例如函数和
undefined。 - 遇到
BigInt或循环引用时会抛出异常。
- 集合现在包含的是字符串,而不是结构化对象。要读回这些值,就需要解析它们。
或者可以使用两个集合,一个用于跟踪唯一性,另一个用于跟踪值:
其缺点是:
- 代码需要确保两个集合彼此保持同步。
- 遵循此模式会产生额外的噪音/样板代码。
- 与上面一样,存在将值扁平化为字符串的风险。
提案
引入具有明确定义相等性的内置“复合值”。
预计会发生变化。下面的设计是一个起点,会随着讨论的继续而演进。
最初的提案并非基于驻留(interning),可以在提交 1c8c3f2f 查看。
什么是“复合值”
它是一个对象。
它是具名值的集合。
具有相同一组具名值的两个复合值将是同一个对象(通常称为驻留)。
参数不会转换为复合值;它只提供值。
参数必须是对象。
只使用参数自身的可枚举属性。继承属性和不可枚举属性会被忽略。
自有可枚举 symbol 键会抛出异常,因为复合值不能有 symbol 键(参见 Symbol 键?)。
任何 getter 都会在创建期间被急切调用,且恰好一次——复合值存储的是返回的值,而不是 getter 本身。
它们不是类。
它们被冻结。
它们可以包含任何值……
……但 -0 除外,它会被规范化为 0。
什么决定两个输入是否会被驻留为同一个复合值?
给定两次调用 Composite(a) 和 Composite(b)。如果满足以下条件,第二次调用将返回与第一次相同的对象:
- 两次调用的是同一个
Composite函数(参见 复合值能跨 realm 工作吗?) a和b都是对象- 否则创建会失败
a和b必须具有相同数量的可枚举字符串键- 对于
b中的每个可枚举键- 该键必须是字符串
- 否则创建会失败
- 该键也必须存在于
a中(顺序无关) - 根据
SameValueZero,该键的值必须等于a中该键的值 - 如果任一值为
-0,两次调用都必须保留-0(参见preserveNegativeZero:true)
- 该键必须是字符串
相等的复合值:
不相等的复合值:
保证哪些相等性语义?
- 因为复合值被驻留,比较它们只是指针相等,因此总能终止且从不抛出异常。
- 两个复合值的相等性从不改变。
- 相等性是一种等价关系:
- 自反性:复合值总是等于自身(
c === c)。 - 对称性:如果
c1 === c2,那么c2 === c1。 - 传递性:如果
c1 === c2且c2 === c3,那么c1 === c3。
- 自反性:复合值总是等于自身(
其他语言
在 Python 中,冻结的 dataclass 具有基于值的相等性,并且可哈希:
在 Clojure 中,map 具有基于值的相等性,且不依赖于键的顺序:
常见问题
如何检查某个东西是否是复合值?
Composite.isComposite(arg) 仅对复合值返回 true。
以复合值为目标的 proxy 不被视为复合值。
该检查不限定 realm:对于任何 realm 创建的复合值,它都会返回 true,而不仅仅是调用它的 realm(参见 复合值能跨 realm 工作吗?)。
性能预期
一旦创建,比较两个复合值的成本是常数时间;运行时只需要比较它们的内存地址。
创建复合值的成本会随着其包含的键越多而增加,并且还可能受到内部复合值驻留缓存当前负载因子的影响。
垃圾回收器(GC)回收不再使用的复合值所占空间也会产生非零成本——该成本取决于 GC 的实现。
复合值是深度不可变的吗?
不一定。复合值是通用容器,因此可以包含任何值。只有当它们包含的所有内容都是深度不可变时,它们才是深度不可变的。
键是可枚举的吗?
是的,所有键都是:
- enumerable: true
- configurable: false
- writable: false
键会排序吗?
是的。复合值的键会被排序,因此复合值的枚举顺序不取决于键在参数中出现的顺序。
这赋予复合值一种规范形式:Composite({ a: 1, b: 2 }) 和 Composite({ b: 2, a: 1 }) 是同一个对象,具有相同的键顺序。
整数索引键首先出现,按数字升序排列,就像普通对象一样。随后是剩余的字符串键,按字典序排序。
为什么 -0 会被规范化为 0?
目前已经如此:-0 与 0 是 === 相等的,并且当用作 Set 值或 Map 键时会被规范化为 0。因此,按照最小惊讶原则,它在复合值中也会被规范化为 0,从而使以下成立:
规范化还使存储的值具有确定性。SameValueZero 已经将 0 和 -0 视为相等,因此 Composite({ v: 0 }) 和 Composite({ v: -0 }) 无论如何都会驻留为同一个对象。如果没有规范化,从 .v 读回的值将取决于两次调用中哪一次碰巧先创建了该对象。规范化为 0 消除了这种顺序依赖。
preserveNegativeZero:true
如果应用程序有用例需要保留 -0,可以通过 preserveNegativeZero 选项选择启用:
一旦使用此选项创建了复合值,如果将其传回 Composite 函数,其值会被保留:
仅当参数已经是复合值时,该选项才默认为 true,因为复合值只有在使用该选项启用的情况下创建时才能包含 -0。原始的保留请求会得到尊重。
显式传入 false 表示选择退出,改为得到规范化后的复合值:
这意味着以下情况对于由同一 realm 创建的复合值始终成立:
为什么 NaN 被视为相等?
这源自基于 SameValueZero 的驻留语义([NaN].includes(NaN) === true)。由相同键值对创建的复合值返回同一个对象,而对象等于自身。
若说 Composite({ v: NaN }) !== Composite({ v: NaN }),要么会破坏对象总是等于自身的规则(事实上,NaN 是唯一不等于自身的值,现有代码依赖这一点来检测 NaN)。要么就意味着,尝试驻留一个至少包含一个 NaN 的复合值将总是返回新对象,这既不太有用,也很可能导致内存泄漏。
WeakMap 和 WeakSet 呢?
复合值不能在弱位置使用。它们不能作为 WeakMap 的键、WeakSet 的值、WeakRef 的目标,也不能注册到 FinalizationRegistry。
允许它们在弱位置使用可能会导致内存泄漏。
如果你需要跟踪包含常规可跟踪对象的复合值的生命周期,可以通过一个用户态库来实现,该库遍历复合值的组成值并改为跟踪这些值。
复合值是新的“原始值”吗?
不是。复合值是一个对象。它的 typeof 是 "object"。
=== 相等性适用于复合值,因为对象已经 === 于自身。
Symbol 键?
复合值不能包含 symbol 键(Symbol 仍可用作值)。
复合值使用字符串键的原因是为了给键的组成部分一个具体的名称(参见 名义键)。
此外,复合值会对其键排序以生成规范形式(参见 键会排序吗?),而 symbol 没有稳定且隐藏信息的排序方式。
- 注册 symbol(
Symbol.for("..."))可以按其注册键排序,但仅支持注册 symbol 不会带来显著价值。 - 唯一 symbol(
Symbol())只有在具有不同描述时才能排序,这同样没有太大价值——而没有描述或描述重复的 symbol 根本无法排序。- 按唯一 symbol 的创建顺序排序会过于隐晦,并会泄露当前秘密信息。
- 最有价值支持的 symbol 是众所周知的内置 symbol,例如
Symbol.iterator。但它们也没有定义好的排序顺序,并且无法与唯一 symbol 直接区分。
鉴于这种复杂性,最简洁的规则是不允许任何 symbol 键。这为未来的提案留出了空间,以便在需要时探索支持那些技术上可以正确实现 symbol 键的情况。
为什么不使用新协议?
为什么将相等性仅限于这些复合值,而不是让任何对象实现一个新的 symbol 协议?
哈希值未被暴露
要让该协议对 Map 和 Set 键有效,它需要返回一个哈希值,但语言并未暴露任何现有值的哈希值——尤其是字符串。
无副作用
=== 和 Object.is 都应当是纯函数,不会触发用户代码。基于协议的相等性无法适用于这些。
可靠性
要能够作为 Map 键参与,相等性必须是纯粹、稳定且可靠的。运行任意代码的协议无法提供这些保证——例如,一个对象可能在已位于 map 中时被添加该 symbol 协议。
未被排除
语言仍然可以添加基于 symbol 的协议以及支持它的集合(例如 ProtocolMap、ProtocolSet)。本提案并不阻止这一点。
为什么使用具名属性而不是有序键?
一方面,从一个键是列表而不是字典的提案开始听起来更简单,它可能只是:
相反,我们鼓励为复合值的组成部分命名,以便代码更容易理解,并避免索引混淆导致的 bug。
那 元组、或者 序数 键而非 名义 键呢?
如果代码确实想要序数键,那么我们在这里能做的最简单的事情(除了什么都不做)就是为序数复合值提供一个便利 API。
话虽如此,这可能并不是特别有用,因为生成的复合值:
length会是可枚举的- 没有
Symbol.iterator
此外,由于创建复合值的成本会随着键的数量增加而增长,鼓励类似列表的键可能并不明智。根据用例,代码改用类似链表的结构可能更好。
语法?
可以有语法让创建复合值更符合人体工学、更易于阅读。
这类语法可能为一些运行时优化打开大门。
语法将是一个单独的后续提案——在 Composites API 在生态系统中独立运行一段时间并观察使用情况之后。
这可以被 polyfill 吗?
可以 "./polyfill"。
不过,像所有 JS polyfill 一样,它只有局部的内部状态。因此,两个独立的 polyfill 不会创建彼此相等的复合值,也不太可能识别由另一个 polyfill 实例创建的复合值。
复合值能跨 realm 工作吗?
复合值可以在 realm 之间传递(例如在同源 iframe 之间),并在那里继续工作,但它们不会跨 realm 驻留。
驻留是按 realm 进行的:每个 realm 有自己的注册表,因此两个 realm 永远不会返回同一个复合值,即使具名值相同。这与通过 Symbol.for 获取的 symbol 不同,后者来自一个由 agent 的所有 realm 共享的注册表。
如果需要测试跨 realm 相等性,应使用本地函数重新驻留该值:
重新驻留是浅层的,因此在处理嵌套复合值可能需要递归应用。
_作为_一个复合值不是按 realm 划分的。每个 realm 都识别任何 realm 创建的复合值,因此依赖于_作为_复合值的行为与 realm 无关。这与 Error.isError 一致。
为什么要在语言中原生实现?
能够创建多值 Map 和 Set 键是许多应用领域的常见需求。
作为语言的一部分意味着,应用程序不同部分创建的键仍将相等,而不会冒使用两个不同驻留库的风险。
此外,该提案的实验性实现表明,与在纯 JS 中实现相比,利用对引擎内部的直接访问来原生实现复合值具有显著优势。例如,引擎可以直接访问字符串的任何现有内部哈希值。
复合值会形成环吗?
复合值在创建时被冻结,并且只能直接引用在它创建之前已经存在的值。因此,嵌套复合值永远不会形成环。
复合值内部持有的非复合对象本身可能引用回该复合值,但复合值驻留在非复合对象处停止,因此不会导致复合值环。
遍历复合值时,代码可以使用 Composite.isComposite 确保在到达复合值_树_的_叶子_时停止递归。
这与 proposal-richer-keys 相比如何?
那个提案:
compositeKey接受有序列表,而不是具名属性- 返回的键是不透明的,没有任何属性
- 值中至少有一个必须是对象
- 键可以用于
WeakMap(以及其他弱 API)
本提案:
- 键由具名属性构成
- 返回的键会暴露数据
- 对值没有任何限制
- 键不能用于
WeakMap(或任何弱 API)
这与 proposal-record-tuple 相比如何?
那个提案:
- Records 是新的原始值,具有自定义
typeof - Records 只能包含原始值(深度不可变)
- 曾有语法
#{ ... }
本提案:
- Composites 是对象
- Composites 可以包含任何值(浅层不可变)
- 不引入语法