Record & Tuple ?
中文标题:记录(Record)与元组(Tuple)
- 阶段: 未分阶段
- 状态: 已撤回
- ECMAScript 版本: —
- 同步时间: 2026年8月26日
- English original · 官方仓库
该提案旨在为 JavaScript 引入 Record 和 Tuple 两种新的深度不可变复合基本数据结构,使用 #{} 和 #[] 语法。它们按值而非身份进行比较,且只能包含基本类型以及其他 Record/Tuple。根据其 README 所述,该提案已被正式撤回。
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
JavaScript 记录(Records)与元组(Tuples)提案
此提案已被撤回 #394
作者:
- Robin Ricard (Bloomberg)
- Rick Button (Bloomberg)
- Nicolò Ribaudo (Babel)
提案负责人:
- Robin Ricard (Bloomberg)
- Rick Button (Bloomberg)
顾问
- Philipp Dunkel (Bloomberg)
- Dan Ehrenberg (Bloomberg)
- Maxwell Heiber
阶段: 已撤回
在游乐场中尝试 Record 和 Tuple!
规范文本
教程
示例集
内容
概述
本提案为 JavaScript 引入了两种新的深度不可变数据结构:
Record,一种类似 Object 的深度不可变结构#{ x: 1, y: 2 }Tuple,一种类似 Array 的深度不可变结构#[1, 2, 3, 4]
Record 和 Tuple 只能包含基本类型以及其他 Record 和 Tuple。你可以将 Record 和 Tuple 视为“复合基本类型”。通过完全基于基本类型而非对象,Record 和 Tuple 实现了深度不可变。
Record 和 Tuple 支持与操作对象和数组类似的舒适习惯用法,用于构造、操作和使用。它们通过内容进行深度比较,而非通过身份。
JavaScript 引擎可以对 Record 和 Tuple 的构造、操作和比较执行某些优化,类似于 JavaScript 引擎中实现字符串的方式。(应当理解,这些优化并不能得到保证。)
Record 和 Tuple 的目标是与诸如 TypeScript 或 Flow 等外部类型系统超集兼容且易于理解。
关于 JavaScript 中不可变数据结构的先前工作
如今,用户库实现了类似的概念,例如 Immutable.js。此外,一个先前的提案 也曾尝试过,但因为提案的复杂性和缺乏足够的用例而被放弃。
这个新提案仍然受到先前提案的启发,但引入了一些重大变化:Record 和 Tuple 现在是深度不可变的。这一特性从根本上基于这样一个观察:在大型项目中,混合可变和不可变数据结构的风险随着存储和传递的数据量增长而增长,因此你更有可能处理大型的 record 和 tuple 结构。这可能会引入难以发现的错误。
作为内置的深度不可变数据结构,本提案相比用户库也提供了一些可用性优势:
- Record 和 Tuple 在调试器中易于检查,而库提供的不可变类型通常难以检查,因为必须检查数据结构细节。
- 因为它们是通典型的对象和数组习惯用法访问的,所以编写一个同时消费不可变和 JS 对象的通用库时,不需要额外的分支;而对于用户库,可能仅在不可变情况下需要方法调用。
- 我们通过使其更容易始终使用不可变结构,避免了开发者在普通 JS 对象和不可变结构之间进行昂贵转换的情况。
Immer 是一种值得注意的不可变数据结构方法,并规定了一种通过生产者和 reducer 进行操作的模式。然而,它是否生成冻结对象是可配置的。同样的模式可以适用于本提案中定义的结构以及冻结对象。
用户库中定义的深度相等可能差异很大,部分原因是对可变对象的可能引用。通过在只深度包含基本类型、Record 和 Tuple 之间划清界限,并递归遍历整个结构,本提案为比较定义了简单、统一的语义。
示例
Record
函数可以大体上以相同的方式处理 Record 和对象:
在此处查看更多示例。
Tuple
与 record 类似,我们可以将 tuple 视为类数组:
在此处查看更多示例。
禁止的情况
如前所述,Record & Tuple 是深度不可变的:尝试在其中插入对象将导致 TypeError:
语法
这定义了本提案向语言添加的新语法部分。
我们通过在正常的对象或数组表达式前添加 # 修饰符来定义 record 或 tuple 表达式。
示例
语法错误
语法上禁止空洞,这与允许空洞的数组不同。参见 issue #84 了解更多讨论。
语法上禁止使用 __proto__ 标识符作为属性。参见 issue #46 了解更多讨论。
Record 语法中禁止简洁方法。
运行时错误
由于 #15 中描述的问题,Record 只能有字符串键,不能有 Symbol 键。使用 Symbol 键创建 Record 会抛出 TypeError。
Record 和 Tuple 只能包含基本类型以及其他 Record 和 Tuple。尝试创建包含 Object(null 不是对象)或 Function 的 Record 或 Tuple 会抛出 TypeError。
相等性
Record 和 Tuple 的相等性与其他 JS 基本类型(如 Boolean 和 String 值)类似,按内容而非身份进行比较:
这与 JS 对象的相等性工作方式不同:对象的比较会观察到每个对象是不同的:
Record 键的插入顺序不影响 record 的相等性,因为无法观察到键的原始顺序,因为它们是隐式排序的:
如果它们的结构和内容深度相同,那么根据所有相等操作,Record 和 Tuple 值被认为是相等的:Object.is、==、=== 以及内部的 SameValueZero 算法(用于比较 Map 和 Set 的键)。它们在处理 -0 方面有所不同:
Object.is将-0和0视为不相等==、===和 SameValueZero 将-0与0视为相等
请注意,对于嵌套在 Record 和 Tuple 中的其他类型的值,== 和 === 更直接——当且仅当内容相同时返回 true(0/-0 除外)。这种直接性对 NaN 以及跨类型比较都有影响。请参见下面的示例。
在 #65 中查看更多讨论。
Record 和 Tuple 的对象模型
通常,你可以将 Record 视为对象。例如,Object 命名空间和 in 运算符可以与 Record 一起使用。
高级内部细节:Record 和 Tuple 包装对象
JS 开发者通常不必考虑 Record 和 Tuple 包装对象,但它们是 Record 和 Tuple 在 JavaScript 规范中“底层”工作的关键部分。
通过 . 或 [] 访问 Record 或 Tuple 遵循典型的 GetValue 语义,该语义会隐式转换为相应包装类型的实例。你也可以通过 Object() 显式进行转换:
Object(record)创建一个 Record 包装对象Object(tuple)创建一个 Tuple 包装对象
(人们可以想象 new Record 或 new Tuple 可以像 new Number 和 new String 那样创建这些包装器,但 Record 和 Tuple 遵循 Symbol 和 BigInt 设定的新约定,使这些情况抛出错误,因为这不是我们鼓励程序员采取的路径。)
Record 和 Tuple 包装对象的所有自有属性都具有属性 writable: false, enumerable: true, configurable: false。包装对象不可扩展。总而言之,它们的行为类似于冻结对象。这与 JavaScript 中现有的包装对象不同,但这是为了给出对 Record 和 Tuple 进行普通操作时预期会出现的错误所必需的。
Record 的实例具有与底层 record 值相同的键和值。每个 Record 包装对象的 __proto__ 为 null(讨论:#71)。
Tuple 的实例具有与底层 tuple 值中每个索引对应的整数键。每个键的值是原始 tuple 中的对应值。此外,还有一个不可枚举的 length 键。总的来说,这些属性与 String 包装对象的属性相匹配。也就是说,Object.getOwnPropertyDescriptors(Object(#["a", "b"])) 和 Object.getOwnPropertyDescriptors(Object("ab")) 各自返回一个如下所示的对象:
Tuple 包装对象的 __proto__ 是 Tuple.prototype。请注意,如果你在不同的 JavaScript 全局对象(“Realms”)中工作,Tuple.prototype 的选择是基于执行 Object 转换时当前 Realm 的,类似于其他基本类型的 .prototype 的行为——它不附加在 Tuple 值本身上。Tuple.prototype 上有各种方法,类似于数组。
为了完整性,对 Tuple 的越界数字索引返回 undefined,而不是像 TypedArrays 那样通过原型链向上转发。非数字属性键的查找会转发到 Tuple.prototype,这对于找到它们的类数组方法很重要。
Record 和 Tuple 标准库支持
Tuple 值具有与 Array 大致类似的功能。类似地,Record 值受不同的 Object 静态方法支持。
查看 附录 以了解有关 Record 和 Tuple 命名空间的更多信息。
从对象和数组转换
你可以使用 Record()、Tuple()(使用展开运算符)、Record.fromEntries() 或 Tuple.from() 转换结构:
请注意,Record()、Tuple()、Record.fromEntries() 和 Tuple.from() 期望集合由 Record、Tuple 或其他基本类型(如数字、字符串等)组成。嵌套的对象引用会导致 TypeError。由调用者决定以适合应用程序的方式转换内部结构。
注意:当前提案草案不包含递归转换例程,仅包含浅层的。参见 #122 中的讨论。
迭代协议
与数组一样,Tuple 是可迭代的。
与对象类似,Record 只能与 Object.entries 等 API 结合使用进行迭代。
JSON.stringify
JSON.stringify(record)的行为等价于对递归转换 record 为不包含任何 record 或 tuple 的对象后调用JSON.stringify。JSON.stringify(tuple)的行为等价于对递归转换 tuple 为不包含任何 record 或 tuple 的数组后调用JSON.stringify。
JSON.parseImmutable
请参阅 https://github.com/tc39/proposal-json-parseimmutable
Tuple.prototype
Tuple 支持与 Array 类似的实例方法,但有一些变化:
- Tuple 和 Array 方法的机制略有不同;Array 方法通常依赖于能够增量修改数组,并且是为子类化而构建的,这两者都不适用于 Tuple。
- 不支持改变数组的操作。例如,没有
Tuple.prototype.push方法。 - Tuple 包含 Change Array by copy 提案引入的方法,例如
Tuple.prototype.withAt。
附录 包含 Tuple 原型的完整描述。
typeof
typeof 将 Record 和 Tuple 识别为不同的类型:
在 {Map|Set|WeakMap|WeakSet} 中的使用
可以在 Map 中用作键,在 Set 中用作值。在此处使用 Record 或 Tuple 时,它们是按值比较的。
不能将 Record 或 Tuple 用作 WeakMap 中的键或 WeakSet 中的值,因为 Record 和 Tuple 不是 Object,并且它们的生命周期不可观察。
示例
Map
Set
WeakMap 和 WeakSet
WeakSet
原理
为什么要引入新的基本类型?为什么不直接在不可变数据结构库中使用对象?
Record 和 Tuple 提案的一个核心好处是它们按内容而不是身份进行比较。同时,JavaScript 中对象上的 === 具有非常清晰、一致的语义:按身份比较对象。使 Record 和 Tuple 成为基本类型可以实现基于其值的比较。
在高层面上,对象/基本类型的区分有助于在深度不可变、无上下文、无身份的世界和其上的可变对象世界之间划清界限。这种类别划分使设计和心智模型更加清晰。
将 Record 和 Tuple 实现为基本类型的一个替代方案是使用运算符重载通过实现一个重载的抽象相等(==)运算符来深度比较对象,以达到类似的结果。虽然这是可能的,但它不能满足完整的用例,因为运算符重载不提供对 === 运算符的覆盖。我们希望严格相等(===)运算符成为对象“身份”和基本类型“可观察值”(模 -0/+0/NaN)的可靠检查。
另一种选择是执行所谓的_实习_:我们全局跟踪 Record 或 Tuple 对象,如果我们尝试创建一个恰好与现有 Record 对象相同的新对象,我们现在引用这个现有 Record 而不是创建一个新对象。这基本上就是 polyfill 所做的。我们现在将值和身份等同起来。这种方法在将这种行为扩展到多个 JavaScript 上下文时会产生问题,并且不会天然提供深度不可变性,而且它特别慢,这会使使用 Record & Tuple 成为一种性能负面的选择。
开发者会熟悉这个新概念吗?
Record & Tuple 被设计为与对象和数组良好地互操作:你可以像读取对象和数组一样读取它们。主要变化在于深度不可变性以及按值而不是身份进行比较。
习惯于以不可变方式操作对象(例如转换 Redux 状态的片段)的开发者将能够继续像以前对对象和数组那样进行相同的操作,而这次,有了更多的保证。
我们将通过访谈和调查进行实证研究,以确定这是否如我们想象的那样有效。
为什么 Record & Tuple 不基于像 Immutable.js 这样的 .get()/.set() 方法?
如果我们想像前一节所述那样保持对 Record & Tuple 的访问类似于对象和数组,我们就不能依赖方法来执行该访问。这样做将要求我们在尝试创建能够接受对象/数组/Record/Tuple 的“通用”函数时分支代码。
这是一个支持 Immutable.js Record 和普通对象的示例函数:
这容易出错,因为两个分支很容易随着时间的推移而不同步...
以下是我们如何编写该函数以接受本提案中的 Record 和普通对象:
此函数在单条代码路径中同时支持对象和 Record,并且不强制使用者选择使用哪种数据结构。
为什么我们还需要同时支持两者?这主要是为了避免生态系统分裂。假设我们使用 Immutable.js 进行状态管理,但我们需要将状态提供给一些不支持它的外部库:
toJS() 和 fromJS() 都可能成为非常昂贵的操作,具体取决于子结构的大小。生态系统分裂意味着转换,进而意味着可能的性能问题。
为什么要引入新语法?为什么不只引入 Record 和 Tuple 全局对象?
提议的语法显著提高了在代码中使用 Record 和 Tuple 的人体工程学。例如:
提议的语法旨在更简单、更容易理解,因为它有意类似于对象和数组字面量的语法。这利用了用户对对象和数组的现有熟悉度。此外,第二个示例引入了额外的临时对象字面量,这增加了表达式的复杂性。
为什么特别是 #{}/#[] 语法?现有的或新的关键字怎么样?
使用关键字作为标准对象/数组字面量语法的前缀在向后兼容性方面存在问题。此外,重用现有关键字可能会引入歧义。
ECMAScript 定义了一组保留关键字,可用于语言的未来扩展。 定义一个新的非保留关键字在理论上是可能的,但需要付出大量努力来验证 新关键字不太可能破坏向后兼容性。
使用保留关键字使此过程更容易,但这并非完美解决方案,因为没有保留关键字
匹配该功能的“意图”,除了 const。const 关键字也很棘手,因为它描述
了一个类似的概念(变量引用不可变性),而本提案旨在添加新的不可变数据结构。
虽然不可变性是这两个功能的共同点,但已有大量社区反馈
表明在这两种情况下都使用 const 是不可取的。
除了使用关键字,{| |} 和 [||] 也被建议作为可能的替代方案。目前,提案负责人小组倾向于 #[]/#{},但讨论仍在 #10 进行中。
为什么深度不可变?
将 Record & Tuple 定义为复合基本类型强制 Record & Tuple 中的一切都是非对象。这带来了一些缺点(引用对象变得更难,但仍然可能),但也带来了更多保证,以避免常见的编程错误。
在上面的例子中,我们试图用 Object.freeze 创建不可变性的保证。不幸的是,由于我们没有深度冻结对象,没有告诉我们 object.a 未被修改。对于 Record & Tuple,该约束是天生的,并且毫无疑问结构未被修改:
最后,深度不可变性抑制了对深度克隆对象以保持保证的常见模式的需求:
常见问题解答
如何制作一个基于现有 Record 或 Tuple 但更改或添加了一部分的新 Record 或 Tuple?
通常,展开运算符对此很有效:
如果你要更改 Tuple 中的某些内容,Tuple.prototype.with 方法可以工作:
某些对“深层路径”的操作可能有点笨拙。为此,Records 的深度路径属性 提案为 Record 字面量添加了额外的简写语法。
我们正在将深度路径属性提案作为一个单独的后续提案来开发,因为我们不认为它是使用 Records 的核心,而 Records 可以独立工作得很好。这是一种语法补充,随着时间的推移,在转译器中原型化会很好,并且我们有很多与 Records 和 Tuples 无关的决策点(例如,它如何与对象一起工作)。
这与 Readonly Collections 提案有何关系?
我们已经与 Readonly Collections 的提案负责人进行了交谈,两个小组都认为这些是互补的:
- Readonly collections 是浅不可变的,并且可以指向对象;它们可以在构造期间被修改,并且支持可变对象的只读视图。
- Records 和 Tuples 是深度不可变的,并且仅由基本类型组成。
在功能方面,两者都不是对方的子集。充其量,它们是平行的,就像每个提案与其他集合类型并行一样。
因此,两个提案负责人小组已决定确保这些提案相互平行。例如,本提案添加了一个新的 Tuple.prototype.withReversed 方法。其想法是在设计过程中检查这个签名对于只读数组(如果存在)是否有意义:我们将这些新方法提取到 Change Array by copy 提案中,以便我们能够讨论建立一个一致的、共享的心智模型的 API。
在当前的提案草案中,对于同类型数据没有任何重叠的类型,但两个提案将来都可以朝这些方向发展,我们正试图提前考虑这些事情。谁知道呢,也许有一天 TC39 会决定添加原始 RecordMap 和 RecordSet 类型,作为 Set 和 Map 的深度不可变版本!并且这些将与 Readonly Collections 类型并行。
我们能有实例是 Record 的类吗?
TC39 多年来一直在断断续续地讨论“值类型”,这将是某种基本类型的类声明。此提案的早期版本 甚至尝试过。本提案试图从简单和最小化开始,仅提供核心结构。希望它能为一个未来的类提案提供数据模型。
本提案与更广泛的一系列提案松散相关,包括运算符重载 和扩展数字字面量:这些共同旨在提供一种方式,让用户定义的类型可以像 BigInt 一样工作。然而,我们的想法是,如果我们确定它们有独立的动机,就添加这些功能。
如果我们有用户定义的基本/值类型,那么在CSS Typed OM 或 Temporal 提案等内置功能中使用它们可能是有意义的。然而,即使发生,这也是很遥远的未来;目前,为这类功能使用对象效果很好。
此提案的 Record & Tuple 与 TypeScript 的 Record 和 Tuple 之间有什么关系?
尽管这两种 Record 都与对象相关,两种 Tuple 都与数组相关,但相似之处大致到此为止。
TypeScript 中的 Record 是一种通用的实用类型,用于表示一个对象,该对象采用与值类型匹配的键类型。它们仍然代表对象。
同样,TypeScript 中的 Tuple 是一种表示有限大小数组中的类型的表示法(从 TypeScript 4.0 开始,它们具有可变参数形式)。TypeScript 中的 Tuple 是一种表达具有异质类型的数组的方式。ECMAScript 元组可以轻松对应 TS 数组或 TS 元组,因为它们可以包含无限数量的相同类型的值,也可以包含有限数量的不同类型的值。
TS Records 或 Tuples 是与 ECMAScript Records 和 Tuples 正交的功能,两者可以同时表达:
这些数据结构的性能预期是什么?
本提案不提供任何性能保证,也不要求在实现中进行特定优化。根据实现者的反馈,预计他们将通过“线性时间”算法实现常见操作。 但是,本提案并不妨碍纯函数数据结构的一些经典优化,包括但不限于:
- 快速深度相等检查的优化:
- 为了快速返回 true,实习(“哈希共享”)一些数据结构
- 为了快速返回 false,维护某些结构内容的树的哈希
- 操作数据结构的优化
- 在某些情况下,重用现有数据结构(例如,当使用对象展开操作时),类似于 rope 或函数式数据结构的典型实现
- 在其他情况下,由引擎决定,使用像现有 JavaScript 对象实现那样的扁平表示
这些优化类似于现代 JavaScript 引擎处理字符串拼接的方式,使用各种不同的内部字符串类型。这些优化的有效性取决于 record 和 tuple 的身份的不可观察性。预计并非所有引擎在这些优化方面都会表现一致,而是它们各自决定使用哪些特定的启发式方法。在本提案进入第 4 阶段之前,我们计划根据届时我们将拥有的实施经验,发布一份关于跨引擎可优化使用 Record 和 Tuples 的最佳实践指南。
词汇表
Record
本文档中提出的一种新的、深度不可变的、复合基本类型数据结构,类似于 Object。#{ a: 1, b: 2 }
Tuple
本文档中提出的一种新的、深度不可变的、复合基本类型数据结构,类似于 Array。#[1, 2, 3, 4]
复合基本类型
行为类似于其他 JavaScript 基本类型,但由其他组成值构成的值。本文档提出了前两种复合基本类型:Record 和 Tuple。
简单基本类型
String、Number、Boolean、undefined、null、Symbol 和 BigInt
基本类型
要么是复合类型,要么是简单基本类型。JavaScript 中的所有基本类型都共享某些属性:
- 它们是深度不可变的
- 比较是按值,而不是按身份
- 它们在对象模型中不是对象——对象操作会导致异常或隐式创建包装器。
不可变数据结构
不接受改变其内部的操作,而是具有返回应用该操作后结果的新值的操作的数据结构。
在本提案中,Record 和 Tuple 是深度不可变的数据结构。
严格相等
运算符 === 使用严格相等比较算法定义。严格相等指的是这种特殊的相等概念。
结构共享
结构共享是一种用于限制不可变数据结构内存占用的技术。简而言之,当应用操作以派生不可变结构的新版本时,结构共享将尝试保持大部分内部结构完整,并由该结构的旧版本和派生版本同时使用。这大大限制了派生新结构所需的复制量。