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