JavaScript 中的 Record 与 Tuple:不可变数据结构终于来了

本文深入介绍 JavaScript 的 Record 与 Tuple 提案,解释它们如何实现深度不可变与值语义,对比 Object.freeze、Immutable.js 等方案,分析常见误区,并给出工程落地建议,适合关注前端状态管理与数据结构设计的开发者。

为什么我总觉得少了点什么

一个常见的前端状态管理事故是这样的:两个组件明明没有共享同一个 state,但修改 A 组件里的某个表单字段,B 组件也跟着变了。查到最后,发现是某处代码把一个嵌套对象引用直接赋值给了另一个 state 字段。这种事情在团队协作里太容易发生——大家都不想写深拷贝,但拷贝少了又容易“串味”。

AI technology illustration

根子在于 JavaScript 的默认数据结构:对象和数组都是引用类型。引用类型意味着,两个对象即使内容一模一样,在全等(===)下也是 false;意味着你不小心改了其中一个引用,另一个也受影响。于是社区里开始用 Object.freeze、深拷贝工具、Immutable.js 来模拟不可变。现在,TC39 的一个提案终于把这种需求放到了语言层面,这就是 Record 和 Tuple。

Record 和 Tuple:一撮 # 号实现深度不可变

Record 可以理解为“不可变的对象”,Tuple 是“不可变的数组”。语法上是给对象或数组字面量加一个 # 前缀。

const config = #{
  retries: 3,
  backoff: true,
  endpoints: #['/api/a', '/api/b']
};

const point = #[10, 20];

创建之后就没有办法再修改。没有 push、pop,也不能给属性重新赋值。更重要的是,这种不可变是深度生效的:Record 里嵌套的仍是 Record 或 Tuple,它们同样不可变。所以你把普通对象冻结在外层并没有用,Record 是本来就“冻住”的。

这里有一个必须记住的限制:Record 和 Tuple 里只能放原始值、Record 和 Tuple。普通对象、函数、Date、DOM 元素等都不能放进去。比如下面这种写法会直接抛错:

const bad = #{
  timestamp: new Date()
}; // TypeError

原因是 Record 和 Tuple 是原始值而不是对象。原始值没有原型链,也没有内置方法。你写 point.map(...) 会得到错误——它并不存在。访问、解构和展开倒是和普通对象数组一致:

const config = #{ retries: 3, backoff: true };
const { retries, backoff } = config;

const endpoints = #['/api/a', '/api/b'];
const [first, ...rest] = endpoints;

// 转成普通数组
const plainArray = [...endpoints];

这里有一个容易忽略的点:展开都是浅层展开。如果展开出来的元素本身还是 Record,它并不会自动变成普通对象。

值语义带来的连锁反应

原始值意味着相等的判断从“引用一致”变成了“内容一致”。同样结构的两个 Record 用 === 比较就是 true。

const a = #{ x: 1, y: #[2, 3] };
const b = #{ x: 1, y: #[2, 3] };
console.log(a === b); // true

const arr1 = #[1, 2];
const arr2 = #[1, 2];
console.log(arr1 === arr2); // true

这个特性立刻改善了四个场景。一是 Map 和 Set 的键,过去拿对象当键,必须持有同一个引用才能命中,现在内容一样就能命中。二是状态管理里的变更判断,以前要用 deepEqual 或者 JSON.stringify 来做“内容是否变了”,现在直接 === 得到可靠性更高的结果。三是跨模块传递数据,不用担心对方偷偷修改你的引用。四是序列化之后的还原,相同的 JSON 结构在解析后依然能比较相等。

不过别以为这就是标准的“深比较”。Record 和 Tuple 的 === 是比较结构,而不是“深度比较任意对象”。普通对象要比较内容,还需要自己写。

和现有的“不可变”方案放在一起看

如今代码里已经很常见的“不可变”手段,无非是 Object.freeze、深拷贝和 Immutable.js。把它们放到一张表里,能看到 Record/Tuple 的定位。

方案 不可变深度 值比较 修改方式 依赖
Object.freeze 浅层 不支持 手动替换属性
深拷贝工具 按次拷贝 需要 deep-equal 每次修改前复制 lodash.deepclone 等
Immutable.js 结构共享 需自身比较 API 调用 .set/.update immutable 包
Record/Tuple 语法级深度 原生 === 展开/解构创建新值

Object.freeze 的局限是只冻住了“属性不能重新赋值”,数组和嵌套对象该改还是能改。深拷贝每次都要复制整棵结构,如果数据量大,性能会快速劣化。Immutable.js 很成熟,但它带来了自己的类型系统和 API,团队一旦引入,代码风格会被推着往特定方向走。

Record/Tuple 试图在语言层面把这些共性抽出来。它不像 Immutable.js 那样有独立的 API 体系,而是把不可变更自然地嵌进解构、展开、比较等既有操作里。代价是灵活性差:你没法在里面放普通对象或自定义类,这让它在一开始就不适合做系统里的“万能容器”。

最容易踩的几个误区

  • 误区一:Object.freeze 已经实现了不可变,Record/Tuple 多此一举。
  • 误区二:Record/Tuple 比普通对象数组更快。
  • 误区三:Record/Tuple 就是不可变版的普通对象,可以随便往里放任何东西。

先说第一个。 Object.freeze 只是阻止属性被重新赋值。如果你 freeze 了一个含数组字段的对象,数组自身没有被 freeze,仍然可以 arr.push()。这还不算,freeze 后的对象依然没有值语义,两个内容相同的对象 === 仍然是 false。Record/Tuple 解决的核心问题不是“谁能不能修改”,而是“如何在不靠引用身份的前提下判断相等”。

第二个误区经常出现在刚接触到值语义的人身上。值比较看起来像是“不用深比较了,会快很多”,但真到运行时,两个 Record 比较时引擎仍然需要遍历所有属性。如果你的数据嵌套很深,或者频繁创建新 Record,性能未必比普通对象快。它的优势是正确性和可预测性,而不是性能。有些场景下,引擎可以通过结构哈希来优化,但目前还在实现阶段,不能拿原生对象的速度预期去衡量它。

第三个误区会直接让你在编译期拿到一支冰冷的红叉。前面说过,Record 里放 Date 或函数都会报错。这导致你不能直接把现有业务对象的实例塞进去。比如从 class 里取出来的对象,要么先转换成纯数据,要么就别用 Record。这个限制看起来严格,但也避免了很多隐式依赖。

工程上怎么接住它

目前提案还处于 Stage 2(尚未最终定稿),浏览器原生支持还早。好消息是 Babel 和 TypeScript 已经给了实验性支持路径,让你能在真实项目中跑起来。

// 使用 Babel 插件,比如 @babel/plugin-transform-record-tuple
// 配置文件中已经启用后,可以直接写:
const user = #{
  id: 1,
  name: 'Lily',
  roles: #['admin', 'editor']
};

// 如果想要普通对象,可以展开
const plainUser = { ...user };

注意,展开出来的 plainUser 并不是 Record,而是普通 Object。如果你把它真正接入现有代码,位于中间的边界函数应该显式处理这种转换,而不是让 Record 在系统里到处“变形”。

落地时建议从小范围、高价值的数据开始。比如把一个全局配置对象改成 Record,因为配置本来就是只读的;把事件 payload 改成 Tuple/Record,因为它天然需要被比较、传递。不要一上来重构整个 store,把 state 里的全是普通对象的嵌套结构硬改成 Record,那样会让框架内部的深层 diff 逻辑变得很别扭。

举一个经常发生的场景:某模块给外部提供了一个可以直接修改配置池的内部对象,后来某次维护时有人在某个回调里顺手改了一个选项,导致另一条链路的行为变化,排查了很久。如果这个配置池一开始就是 Record,任何写入行为都会在运行时报错,错误会被提前暴露在修改发生的地方。这种“程序员友好型的失败”正是不可变结构真正的价值。

收尾:现在就开始做准备

Record 和 Tuple 不会取代普通对象和数组。它们更像是给 JavaScript 的类型版图补上一块缺失的拼图:真正的、可以靠值比较的复合数据。这个特性的意义不在于速度,而在于让“不可变”成为一个默认选项,而不是靠约定或者库。

很多团队会因为“浏览器还不支持”而暂时忽略它。但即便还将等待,数据结构的设计思路已经开始影响我们:如何划分可变与不可变边界?如何让状态变化可追踪?如何在代码层面消灭“引用共享”这类低级事故?这些问题,比某个提案是否转正更早值得回答。

现在就可以打开一个在线 Repl,跑一段 Record/Tuple 的 Demo,感受一下“改不动”和“一比较就相等”的体验。也许用不了多久,它就会成为你工具箱里的常客。

原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/562/

(0)
上一篇 1小时前
下一篇 58分钟前

相关推荐