前端精读周刊:Records & Tuples 提案全解析——JS 原生不可变数据类型的语法、判等与实战

原创2026-09-30 16:39:361,374 阅读
文章标签:文档技术博客教程

前端精读周刊:Records & Tuples 提案全解析——JS 原生不可变数据类型的语法、判等与实战

本篇技术指南以《Records & Tuples 提案》精读为主线,系统讲解 TC39 如何通过新增 Record、Tuple、Box 三种原始类型,让 JavaScript 在语言层面原生支持深度不可变数据,并给出完整的语法约束、判等语义、标准库支持与 FAQ 实践。读完本文,你将掌握 #{}、#[]、Box() 的完整用法与边界条件,理解值判等与引用判等的本质区别,并能结合本仓库的配套精读(如 Records & Tuples for React)评估其在状态管理、渲染优化等场景中的落地价值。

引言:JS 不可变编程的“最后一公里”问题

immutablejs、immer 等库已经让 JS 具备了 immutable 编程的可能性,但始终存在一个无解的问题:怎么保证一个对象真的不可变。

如果不是拍胸脯担保,现在还真没别的办法。或许你觉得 Object.freeze 是个好主意,但它内部仍然可以增加非 frozen 的 key——更致命的是,freeze 只冻结一层,深层的子对象依然可写。

另一个问题是调试层面的:当我们 debug 应用数据的时候,看到状态发生 [] -> [] 变化时,无论在控制台、断点、redux devtools 还是 .toString() 上都看不出来引用有没有变化,除非把变量值分别拿出来做 === 运行时判断。但引用变与没变可是一个大问题,它甚至能决定业务逻辑的正确与否。

现阶段我们没有任何处理办法:如果不能接受完全使用 Immutablejs 定义对象,就只能拍胸脯保证自己的变更一定是 immutable 的。这就是 JS 不可变编程被许多聪明人吐槽的原因——觉得在不支持 immutable 的编程语言下强行应用不可变思维,是一种很别扭的事。

proposal-record-tuple,后续配套的 React 场景展望则记录在 前沿技术/224.精读《Records & Tuples for React》.md。

概述:一次新增三种原始类型

JS 现有 7 种原始类型:string、number、bigint、boolean、undefined、symbol、null。而 Records & Tuples 提案一下子又增加了三种原始类型!这三种原始类型完全是为 immutable 编程环境服务的,也就是说,可以让 JS 开出一条原生 immutable 赛道。

这三种原始类型分别是 Record、Tuple、Box:

  • Record:类对象结构的深度不可变基础类型,如 #{ x: 1, y: 2 }。
  • Tuple:类数组结构的深度不可变基础类型,如 #[1, 2, 3, 4]。
  • Box:可以定义在上面两个类型中,用来存储对象,如 #{ prop: Box(object) }。

核心思想可以总结为一句话:因为这三个类型是基础类型,所以在比较时采用值对比(而非引用对比),因此 #{ x: 1, y: 2} === #{ x: 1, y: 2 }。这真的解决了大问题!如果你还不了解 JS 不支持 immutable 之痛,请先看下一节。

JS 不支持 immutable 之痛

虽然很多人都喜欢 mvvm 的 reactive 特征(作者本人也写了不少 mvvm 轮子和框架),但不可变数据永远是开发大型应用最好的思想:它可以非常可靠地保障应用数据的可预测性,同时不需要牺牲性能与内存。它使用起来没有 mutable 模式方便,但它永远不会出现预料外的情况,这对打造稳定的复杂应用至关重要,甚至比便捷性更加重要。可测试性也是不可变数据的一大优势,这里不详细展开。

然而 JS 并不原生支持 immutable,这非常令人头痛,也造成了许多困扰,下面试图解释这个困扰。

困扰一:引用对比的结果不确定

如果你觉得非原始类型按照引用对比很棒,那你一定一眼能看出下面的结果是正确的:

assert({ a: 1 } !== { a: 1 })

但如果是下面的情况呢?

console.log(window.a) // { a: 1 }
console.log(window.b) // { a: 1 }
assert(window.a === window.b) // ???

结果是不确定。虽然这两个对象长得一样,但我们拿到的 scope 无法推断其是否来自同一个引用:如果来自相同的引用,则断言通过;否则即便看上去值一样,也会 throw error。

更大的麻烦是,即便这两个对象长得完全不一样,我们也不敢轻易下结论:

console.log(window.a) // { a: 1 }
// do some change..
console.log(window.b) // { b: 1 }
assert(window.a === window.b) // ???

因为 b 的值可能在中途被修改,但确实与 a 来自同一个引用,我们无法断定结果到底是什么。

困扰二:状态回放与重渲染无法对齐

另一个问题是应用状态变更的扑朔迷离。试想我们开发了一个树形菜单,结构如下:

{
  "id": "1",
  "label": "root",
  "children": [{
    "id": "2",
    "label": "apple",
  }, {
    "id": "3",
    "label": "orange",
  }]
}

如果我们调用 updateTreeNode('3', { id: '3', title: 'banana' }),在 immutable 场景下我们仅更新 id 为 "1"、"3" 组件的引用,而 id 为 "2" 的引用不变,那么这棵树节点 "2" 就不会重渲染,这是血统纯正的 immutable 思维逻辑。

但当我们保存下这个新状态后,要进行“状态回放”,会发现应用状态进行了一次变更,整个描述 json 变成了:

{
  "id": "1",
  "label": "root",
  "children": [{
    "id": "2",
    "label": "apple",
  }, {
    "id": "3",
    "label": "banana",
  }]
}

但如果我们拷贝上面的文本,把应用状态直接设置为这个结果,会发现与“应用回放按钮”的效果不同,这时 id "2" 也重渲染了,因为它的引用变化了。

问题就是我们无法根据肉眼观察出引用是否变化了:即便两个结构一模一样,也无法保证引用是否相同,进而导致无法推断应用的行为是否一致。如果没有人为的代码质量管控,出现非预期的引用更新几乎是难以避免的。

关于“结构共享”为何能让 immutable 更新高效,本仓库的 精读《Immutable 结构共享》 一文详细介绍了 Hash maps tries 与 vector tries 的底层原理——更新时只重建更新路径上的节点,未修改的节点继续共用,正是“以空间换时间”的典型设计。

这就是 Records & Tuples 提案要解决问题的背景,带着这个理解去看它的定义,就更好学习了。

Records & Tuples 在用法上与对象、数组保持一致

Records & Tuples 提案说明,不可变数据结构除了定义时需要用 # 符号申明外,使用时与普通对象、数组无异。这一点在 FAQ 中被称为非常重要的特性,可以让 immutable 完全融入现在的 JS 生态。

Record 用法与普通 object 几乎一样

const proposal = #{
  id: 1234,
  title: "Record & Tuple proposal",
  contents: `...`,
  // tuples are primitive types so you can put them in records:
  keywords: #["ecma", "tc39", "proposal", "record", "tuple"],
};

// Accessing keys like you would with objects!
console.log(proposal.title); // Record & Tuple proposal
console.log(proposal.keywords[1]); // tc39

// Spread like objects!
const proposal2 = #{
  ...proposal,
  title: "Stage 2: Record & Tuple",
};
console.log(proposal2.title); // Stage 2: Record & Tuple
console.log(proposal2.keywords[1]); // tc39

// Object functions work on Records:
console.log(Object.keys(proposal)); // ["contents", "id", "keywords", "title"]

注意 Object.keys 返回的是普通数组(而非 Tuple),这一点在“标准库支持”一节还会展开。

下面的例子说明,Records 与 object 在函数内处理时并没有什么不同,这也是让 immutable 无缝融入现有生态的关键:

const ship1 = #{ x: 1, y: 2 };
// ship2 is an ordinary object:
const ship2 = { x: -1, y: 3 };

function move(start, deltaX, deltaY) {
  // we always return a record after moving
  return #{
    x: start.x + deltaX,
    y: start.y + deltaY,
  };
}

const ship1Moved = move(ship1, 1, 0);
// passing an ordinary object to move() still works:
const ship2Moved = move(ship2, 3, -1);

console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving

由于返回的是 Record,即便传入参数是普通对象,最终返回的两个 Record 也会因为“值相等”而全等——这正是值语义的威力。

Tuple 用法与普通数组几乎一样

const measures = #[42, 12, 67, "measure error: foo happened"];

// Accessing indices like you would with arrays!
console.log(measures[0]); // 42
console.log(measures[3]); // measure error: foo happened

// Slice and spread like arrays!
const correctedMeasures = #[
  ...measures.slice(0, measures.length - 1),
  -1
];
console.log(correctedMeasures[0]); // 42
console.log(correctedMeasures[3]); // -1

// or use the .with() shorthand for the same result:
const correctedMeasures2 = measures.with(3, -1);
console.log(correctedMeasures2[0]); // 42
console.log(correctedMeasures2[3]); // -1

// Tuples support methods similar to Arrays
console.log(correctedMeasures2.map(x => x + 1)); // #[43, 13, 68, 0]

在函数内处理时,拿到一个数组或 Tuple 并没有什么需要特别注意的区别:

const ship1 = #[1, 2];
// ship2 is an array:
const ship2 = [-1, 3];

function move(start, deltaX, deltaY) {
  // we always return a tuple after moving
  return #[
    start[0] + deltaX,
    start[1] + deltaY,
  ];
}

const ship1Moved = move(ship1, 1, 0);
// passing an array to move() still works:
const ship2Moved = move(ship2, 3, -1);

console.log(ship1Moved === ship2Moved); // true
// ship1 and ship2 have the same coordinates after moving

Box:把可变对象装进不可变结构

由于 Record 内不能直接定义普通对象(即必须使用 # 标记的不可变对象),如果非要使用普通对象,只能包裹在 Box 里,并且在获取值时需要调用 .unbox() 拆箱。即便修改了对象值,在 Record 或 Tuple 层面也不会认为发生了变化:

const myObject = { x: 2 };

const record = #{
  name: "rec",
  data: Box(myObject)
};

console.log(record.data.unbox().x); // 2

// The box contents are classic mutable objects:
record.data.unbox().x = 3;
console.log(myObject.x); // 3

console.log(record === #{ name: "rec", data: Box(myObject) }); // true

可以看到,Box 内部装的依然是经典的可变对象,record 的“值相等”只比较 Box 内对象的引用,所以 record.data.unbox().x = 3 之后 record 依然等于新构造的相同结构——它天然就是一个“带引用的坑位”。

禁止:Records & Tuples 内不能放普通对象或类实例

不能在 Records & Tuples 内使用任何普通对象或 new 出来的对象实例,除非已经转化为了普通对象:

const instance = new MyClass();
const constContainer = #{
    instance: instance
};
// TypeError: Record literals may only contain primitives, Records and Tuples

const tuple = #[1, 2, 3];

tuple.map(x => new MyClass(x));
// TypeError: Callback to Tuple.prototype.map may only return primitives, Records or Tuples

// The following should work:
Array.from(tuple).map(x => new MyClass(x))

注意最后一行:先 Array.from(tuple) 把 Tuple 转回普通数组,再对数组做 map 就没有限制了——这是一条非常实用的“逃逸通道”。

语法:明确的边界与约束

Records & Tuples 内只能使用 Record、Tuple、Box:

#{}
#{ a: 1, b: 2 }
#{ a: 1, b: #[2, 3, #{ c: 4 }] }
#[]
#[1, 2]
#[1, 2, #{ a: 3 }]

不支持空数组项(数组空洞):

const x = #[,]; // SyntaxError, holes are disallowed by syntax

为了防止引用追溯到上层、破坏不可变性质,不支持定义原型链:

const x = #{ __proto__: foo }; // SyntaxError, __proto__ identifier prevented by syntax

const y = #{ ["__proto__"]: foo }; // valid, creates a record with a "__proto__" property.

也不能在里面定义方法:

#{ method() { } }  // SyntaxError

同时,一些破坏不可变稳定结构的特性也是非法的,比如 key 不可以是 Symbol:

const record = #{ [Symbol()]: #{} };
// TypeError: Record may only have string as keys

不能直接使用对象作为 value,除非用 Box 包裹:

const obj = {};
const record = #{ prop: obj }; // TypeError: Record may only contain primitive values
const record2 = #{ prop: Box(obj) }; // ok

用表格总结一下 Record 字面量的合法性边界:

场景 结果
#{} / #{ a: 1 } / 嵌套 Record、Tuple 合法
#[1, 2, #{ a: 3 }] 合法
#[,](空洞) SyntaxError
#{ __proto__: foo } SyntaxError(["__proto__"] 写法合法)
#{ method() {} } SyntaxError
#{ [Symbol()]: #{} } TypeError(key 只能是 string)
#{ prop: {} }(裸对象 value) TypeError(需 Box(obj) 包裹)

这些约束的本质是一致的:任何可能引入可变引用、原型链或非确定性结构的写法都被挡在语法/类型层之外,从而保证不可变结构的纯度。

判等:值语义的核心

判等是最核心的地方。Records & Tuples 提案要求 == 与 === 原生支持 immutable 判等,是 JS 原生支持 immutable 的一个重要表现,所以其判等逻辑与普通的对象判等大相径庭。

首先,看上去值相等,就真的相等,因为基础类型仅做值对比:

assert(#{ a: 1 } === #{ a: 1 });
assert(#[1, 2] === #[1, 2]);

这与对象判等完全不同,而且把 Record 转换为对象后,判等就遵循对象的规则了:

assert({ a: 1 } !== { a: 1 });
assert(Object(#{ a: 1 }) !== Object(#{ a: 1 }));
assert(Object(#[1, 2]) !== Object(#[1, 2]));

另外,Records 的判等与 key 的顺序无关,因为存在一个隐式 key 排序规则:

assert(#{ a: 1, b: 2 } === #{ b: 2, a: 1 });

Object.keys(#{ a: 1, b: 2 })  // ["a", "b"]
Object.keys(#{ b: 2, a: 1 })  // ["a", "b"]

Box 是否相等取决于内部对象引用是否相等:

const obj = {};
assert(Box(obj) === Box(obj));
assert(Box({}) !== Box({}));

对于 +0、-0 之间,NaN 与 NaN 对比,都可以安全判定为相等。但 Object.is 因为是对普通对象的判断逻辑,会认为 #{ a: -0 } 不等于 #{ a: +0 }(因为 Object.is 认为 -0 不等于 +0),这里需要特别注意。另外 Records & Tuples 也可以作为 Map、Set 的 key,并且按照值相等来查找(Map 使用 SameValueZero 算法):

assert(#{ a:  1 } === #{ a: 1 });
assert(#[1] === #[1]);

assert(#{ a: -0 } === #{ a: +0 });
assert(#[-0] === #[+0]);
assert(#{ a: NaN } === #{ a: NaN });
assert(#[NaN] === #[NaN]);

assert(#{ a: -0 } == #{ a: +0 });
assert(#[-0] == #[+0]);
assert(#{ a: NaN } == #{ a: NaN });
assert(#[NaN] == #[NaN]);
assert(#[1] != #["1"]);

assert(!Object.is(#{ a: -0 }, #{ a: +0 }));
assert(!Object.is(#[-0], #[+0]));
assert(Object.is(#{ a: NaN }, #{ a: NaN }));
assert(Object.is(#[NaN], #[NaN]));

// Map keys are compared with the SameValueZero algorithm
assert(new Map().set(#{ a: 1 }, true).get(#{ a: 1 }));
assert(new Map().set(#[1], true).get(#[1]));
assert(new Map().set(#[-0], true).get(#[0]));

判等语义小结:Record/Tuple 之间按值判等(key 顺序无关,-0/+0、NaN 视为相等),Box 按内部引用判等,Object.is 与 ==/=== 在 -0 边界上存在差异,Map/Set 采用 SameValueZero 算法。

对象模型如何处理 Records & Tuples

对象模型是指 Object 模型。大部分情况下,所有能应用于普通对象的方法都可无缝应用于 Record,比如 Object.keys 或 in 都可与处理普通对象无异:

const keysArr = Object.keys(#{ a: 1, b: 2 }); // returns the array ["a", "b"]
assert(keysArr[0] === "a");
assert(keysArr[1] === "b");
assert(keysArr !== #["a", "b"]);
assert("a" in #{ a: 1, b: 2 });

值得一提:如果 wrapper 了 Object 在 Record 或 Tuple 上(即 Object(record) / Object(tuple)),提案准备了一套完备的实现方案——会冻结所有属性,并将原型链最高指向 Tuple.prototype;对于数组跨界访问(如越界索引),也只能返回 undefined 而不是沿着原型链追溯。这意味着把不可变结构包装成对象后,依然无法“解锁”出可变能力。

Records & Tuples 的标准库支持

操作返回 immutable 类型

对 Record 与 Tuple 进行原生数组或对象操作后,返回值也是 immutable 类型的:

assert(Object.keys(#{ a: 1, b: 2 }) !== #["a", "b"]);
assert(#[1, 2, 3].map(x => x * 2), #[2, 4, 6]);

从普通对象/数组转换:Record.fromEntries 与 Tuple.from

可通过 Record.fromEntries 和 Tuple.from 方法把普通对象或数组转成 Record、Tuple:

const record = Record({ a: 1, b: 2, c: 3 });
const record2 = Record.fromEntries([#["a", 1], #["b", 2], #["c", 3]]); // note that an iterable will also work
const tuple = Tuple(...[1, 2, 3]);
const tuple2 = Tuple.from([1, 2, 3]); // note that an iterable will also work

assert(record === #{ a: 1, b: 2, c: 3 });
assert(tuple === #[1, 2, 3]);
Record.from({ a: {} }); // TypeError: Can't convert Object with a non-const value to Record
Tuple.from([{}, {} , {}]); // TypeError: Can't convert Iterable with a non-const value to Tuple

此方法不支持嵌套,因为标准 API 仅考虑一层,递归一般交给业务或库函数实现,就像 Object.assign 一样。遇到嵌套的普通对象时,直接抛 TypeError。

迭代行为

Record 与 Tuple 也都是可迭代的,但注意 Record 本身不可迭代,需要借助 Object.entries:

const tuple = #[1, 2];

// output is:
// 1
// 2
for (const o of tuple) { console.log(o); }

const record = #{ a: 1, b: 2 };

// TypeError: record is not iterable
for (const o of record) { console.log(o); }

// Object.entries can be used to iterate over Records, just like for Objects
// output is:
// a
// b
for (const [key, value] of Object.entries(record)) { console.log(key) }

序列化:JSON.stringify 与 JSON.parseImmutable

JSON.stringify 会把 Record & Tuple 转化为普通对象:

JSON.stringify(#{ a: #[1, 2, 3] }); // '{"a":[1,2,3]}'
JSON.stringify(#[true, #{ a: #[1, 2, 3] }]); // '[true,{"a":[1,2,3]}]'

同时提案建议实现 JSON.parseImmutable,将一个 JSON 直接转化为 Record & Tuple 类型,其 API 与 JSON.parse 无异。这意味着网络层返回的 JSON 可以直接解析为不可变结构(详见后文 React 场景中的取数优化)。

Tuple.prototype 与 Array 的差异

Tuple.prototype 方法与 Array 很像,但也有些不同之处,主要区别是:不会修改引用值,而是创建新的引用(Tuple 本身不可变,任何“修改”类方法如 with、map、slice 都会返回新 Tuple)。提案的附录文档(NS-Proto-Appendix 的 Tuple.prototype 章节)给出了完整方法清单与差异对照。

typeof 的新返回值

由于新增了三种原始类型,typeof 也会新增三种返回结果:

assert(typeof #{ a: 1 } === "record");
assert(typeof #[1, 2]   === "tuple");
assert(typeof Box({}) === "box");

Map、Set 与 WeakMap、WeakSet

Record、Tuple、Box 都支持作为 Map、Set 的 key,并按照其自身规则进行判等:

const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const map = new Map();
map.set(record1, true);
assert(map.get(record2));
const record1 = #{ a: 1, b: 2 };
const record2 = #{ a: 1, b: 2 };

const set = new Set();
set.add(record1);
set.add(record2);
assert(set.size === 1);

但不支持 WeakMap、WeakSet:

const record = #{ a: 1, b: 2 };
const weakMap = new WeakMap();

// TypeError: Can't use a Record as the key in a WeakMap
weakMap.set(record, true);
const record = #{ a: 1, b: 2 };
const weakSet = new WeakSet();

// TypeError: Can't add a Record to a WeakSet
weakSet.add(record);

原因是:不可变数据没有一个可预测的垃圾回收时机,如果用在 Weak 系列反而会导致无法及时释放,所以 API 不匹配。

理论基础:提案设计背后的权衡

提案还附赠了理论基础与 FAQ 章节,这些内容恰恰回答了“为什么必须这么设计”。

为什么要创建新的原始类型,而不是像其他库一样在上层处理?

一句话说就是:让 JS 原生支持 immutable 就必须作为原始类型。假如不作为原始类型,就不可能让 ==、=== 操作符原生支持这个类型的特定判等,也就会导致 immutable 语法与其他 JS 代码仿佛处于两套逻辑体系下,妨碍生态的统一。

开发者会熟悉这套语法吗?

由于最大程度保证了与普通对象、数组处理及 API 的一致性,所以开发者上手应该比较容易。本仓库后续的精读 精读《Records & Tuples for React》 也持类似观点:Record 相当于把“引用不会变”的顾虑从语法层面消除了,#{ a: 1 } 可以像 18、19 这样的数字一样被放心使用。

为什么不像 Immutablejs 一样使用 .get / .set 方法操作?

这会导致生态割裂,代码需要时刻关注对象到底是不是 immutable 的。一个最形象的例子是:当 Immutablejs 与普通 JS 操作库配合时,需要写出类似如下代码:

state.jobResult = Immutable.fromJS(
    ExternalLib.processJob(
        state.jobDescription.toJS()
    )
);

这种 .fromJS / .toJS 的来回切换有非常强的割裂感。而 Records & Tuples 因为是语言原生类型,普通函数可以直接接收 Record 并当作对象/数组处理,无需任何转换桥接。

为什么不使用全局 Record、Tuple 方法代替 # 申明?

下面给了两个对比:

// with the proposed syntax
const record = #{
  a: #{
    foo: "string",
  },
  b: #{
    bar: 123,
  },
  c: #{
    baz: #{
      hello: #[
        1,
        2,
        3,
      ],
    },
  },
};

// with only the Record/Tuple globals
const record = Record({
  a: Record({
    foo: "string",
  }),
  b: Record({
    bar: 123,
  }),
  c: Record({
    baz: Record({
      hello: Tuple(
        1,
        2,
        3,
      ),
    }),
  }),
});

很明显后者没有前者简洁,而且也打破了开发者对对象、数组 Like 的认知。

为什么采用 #[] / #{} 语法?

采用已有关键字可能导致歧义或者兼容性问题。另外其实还有 {| |}、[| |] 的备选方案被讨论过(见提案 issue #10),但 # 的赢面比较大。# 符号在 JS 中已用于私有字段(#field),作为“特殊标记”的语义也较为统一。

为什么是深度不可变?

提案直接吐槽了 Object.freeze:

const object = {
   a: {
       foo: "bar",
   },
};
Object.freeze(object);
func(object);

由于 Object.freeze 只保障了一层,所以 object.a 依然是可变的。既然要 JS 原生支持 immutable,希望的肯定是深度不可变,而不是只有一层。另外,由于这个语法会在语言层面支持不可变校验,深度不可变校验就非常重要——如果只校验一层,深层引用依然可以逃逸,校验便失去了意义。

FAQ:常见问题与边界场景

如何基于已有不可变对象创建一个新不可变对象?

大部分语法都是可以使用的,比如解构:

// Add a Record field
let rec = #{ a: 1, x: 5 }
#{ ...rec, b: 2 }  // #{ a: 1, b: 2, x: 5 }

// Change a Record field
#{ ...rec, x: 6 }  // #{ a: 1, x: 6 }

// Append to a Tuple
let tup = #[1, 2, 3];
#[...tup, 4]  // #[1, 2, 3, 4]

// Prepend to a Tuple
#[0, ...tup]  // #[0, 1, 2, 3]

// Prepend and append to a Tuple
#[0, ...tup, 4]  // #[0, 1, 2, 3, 4]

对于类数组的 Tuple,可以使用 with 语法替换索引并新建对象:

// Change a Tuple index
let tup = #[1, 2, 3];
tup.with(1, 500)  // #[1, 500, 3]

深度修改:绕不过去的问题与后续提案

但在深度修改时也遇到了绕不过去的问题,目前有一个关于 deep path properties 的讨论提案在处理这件事,它提出了一个很有意思的语法:

const state1 = #{
    counters: #[
        #{ name: "Counter 1", value: 1 },
        #{ name: "Counter 2", value: 0 },
        #{ name: "Counter 3", value: 123 },
    ],
    metadata: #{
        lastUpdate: 1584382969000,
    },
};

const state2 = #{
    ...state1,
    counters[0].value: 2,
    counters[1].value: 1,
    metadata.lastUpdate: 1584383011300,
};

assert(state2.counters[0].value === 2);
assert(state2.counters[1].value === 1);
assert(state2.metadata.lastUpdate === 1584383011300);

// As expected, the unmodified values from "spreading" state1 remain in state2.
assert(state2.counters[2].value === 123);

counters[0].value: 2 这种“路径属性”写法看上去还是蛮新颖的,它让深度更新的表达从“逐层展开再展开”简化为一条路径赋值。

与 Readonly Collections 的关系?

互补。Readonly Collections 提供的是“只读集合视图”,而 Records & Tuples 提供的是真正的值语义不可变数据,两者面向不同问题。

可以基于 Class 创建 Record 实例吗?

目前不考虑。Record 是纯数据载体,设计上不承载方法、继承等类语义。

TS 也有 Record 与 Tuple 关键字,之间的关系是?

熟悉 TypeScript 的同学都知道只是名字一样而已。TS 的 Record<K, V> 是映射类型工具、Tuple 是元组类型,与运行时新增的 Record/Tuple 原始类型没有任何关系。本仓库 TS 类型体操 系列中关于 Record、Construct Tuple 的题目(如 252.精读《Unique, MapTypes, Construct Tuple...》)讲的就是类型层面的元组构造。

性能预期是?

这个问题挺关键:如果这个提案性能不好,那也无法用于实际生产。当前阶段没有对性能提出要求,但在 Stage 4 之前会给出厂商优化的最佳实践。

提案落地进度说明:本文写作时该提案处于 Stage 2(draft 草案) 阶段——上面的示例代码里 title: "Stage 2: Record & Tuple" 正是当时的提案状态标记。关于 TC39 提案从 stage0 到 stage4 的完整推进流程(strawman → proposal → draft → candidate → finished),可参考本仓库的 精读《TC39 与 ECMAScript 提案》。

从 精读《Records & Tuples for React》 一文的讨论看,性能优化的具体思路包括:

  • 快速判真:采用类似 hash-cons 的方式快速判断结构相等——把关键判断信息存进 hash 表,不需要真的对结构进行递归判断;
  • 快速判假:维护散列表快速判断,也可以借助数据结构经典算法(如布隆过滤器)实现高效判否。

配套展望:Records & Tuples 在 React 场景的落地

本仓库 精读《Records & Tuples for React》 一文(即本精读的下一篇)基于“浏览器能完美优化其性能”的前提,梳理了这个提案能解决的 React 痛点。这里摘其要点,帮助理解提案的实际价值:

  • 保证不可变性:用 #{...} 表示 state 或 props 后,任何赋值操作都会在 TS/JS 层面直接报错,从根源上阻止状态被意外修改;
  • 部分代替 useMemo:#{ userFilter, companyFilter } 这样的 Record 字面量天然引用稳定,传给 useApiData、放入 useEffect 依赖数组、作为 <ExpensiveChild> 的 props,都不需要手动 memo;
  • 避免循环产生的新引用:AllUsers.filter(() => true) 返回结果若值没变,其引用与 AllUsers 全等(AllUsers === filteredUsers),不会触发多余重渲染;
  • 作为 React key:既然引用严格不可变,可以直接拿 item 本身作为 key;
  • 取数结果转 Record:借助 JSON.parseImmutable 把接口返回值解析成 Record,返回结果完全不变时不重渲染,局部变化只导致局部重渲染;
  • 优化 css-in-js:用 Record 定义样式对象,避免每次渲染生成新引用导致 className 一直变化。

当然,这一切的前提都是浏览器能提供与“引用对比”大致相当的性能——这是该特性能否大规模应用的最关键因素。从本仓库对 immutable 生态的持续关注(如 精读《Immer.js》源码)可以看出,社区对“语言级不可变”期待已久。

总结

如果这个提案与嵌套更新提案一起通过,在 JS 中使用 immutable 就得到了语言层面的保障,包括 Immutablejs、immer 在内的库是真的可以“下岗”了——因为它们解决的核心问题(引用判等、深度不可变、结构共享)将由引擎原生提供,且语法与普通对象/数组无缝衔接。

概括本文要点:

  1. 问题:JS 引用语义导致“无法保证对象真不可变”,状态回放、引用对比存在天然不确定性;
  2. 方案:Record(#{})、Tuple(#[])、Box(Box(obj))三种新原始类型,深度不可变、按值判等;
  3. 语法边界:不允许空洞、原型链、方法、Symbol key、裸对象 value,一切以“结构纯度”为准;
  4. 判等语义:值相等即全等,key 顺序无关,-0/+0、NaN 特殊处理,Map/Set 用 SameValueZero,Weak 系列被禁止;
  5. 生态兼容:Object.keys、in、spread、JSON.stringify、迭代等标准能力全部可用,转换 API(Record.fromEntries、Tuple.from)与 JSON.parseImmutable 打通普通数据与不可变数据;
  6. 配套方案:深度更新的路径属性语法、Readonly Collections 互补,以及 React 场景下的 memo 替代、稳定引用、key 优化等应用路径。

对前端工程而言,Records & Tuples 的真正意义不是“又多了几个语法糖”,而是把不可变从一种需要纪律约束的编程习惯,变成一种语言强制保证的类型属性——这正是大型复杂应用最需要的稳定性底座。

登录后查看全文
weekly