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

根子在于 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/