Records与Tuples
TC39 Record与Tuple不可变数据提案
Records 与 Tuples:JavaScript 不可变值语义数据结构
“Mutable state is the new goto.” —— 一句流传于函数式编程社区的格言,道出了可变状态在大型系统中的复杂性根源。
1. 学习目标
本节依据 Bloom 分类法设定六个层次的认知目标,帮助学习者系统掌握 Record 与 Tuple 这两项 TC39 提案。
1.1 Remember(记忆)
- 复述 Record(
#{})与 Tuple(#[])的字面量语法。 - 列出至少 5 种 Record/Tuple 内部允许存放的值类型。
- 说明 TC39 提案当前的 Stage 状态与进入标准流程的关键里程碑。
1.2 Understand(理解)
- 解释”值语义(value semantics)“与”引用语义(reference semantics)“在
===比较中的差异。 - 阐述 Record/Tuple 深度不可变约束如何与 JavaScript 现有对象模型共存。
- 推断
Map/Set在键为 Record/Tuple 时为什么不再需要序列化。
1.3 Apply(应用)
- 在 React/Redux 场景中以 Record 作为状态容器,结合
React.memo实现零成本相等比较。 - 使用
Tuple.from与Record将可变输入转换为不可变缓存键。 - 在 Node.js 服务端用 Tuple 表达多列主键,作为 LRU 缓存的复合键。
1.4 Analyze(分析)
- 对比
Object.freeze、Immutable.js、Immer 与 Record/Tuple 四种不可变方案在内存表示、比较开销、序列化兼容性上的差异。 - 拆解 V8(或 JSC)对 Record/Tuple 的内部表示(
HeapNumber、Tuple内部槽)对 GC 与内联缓存(inline cache)的影响。
1.5 Evaluate(评价)
- 评估在大型电商订单系统中采用 Record/Tuple 作为领域模型载体的收益与代价(迁移成本、库生态兼容、性能)。
- 判定哪些场景更适合
structuredClone+Object.freeze,哪些场景必须使用 Record/Tuple。
1.6 Create(创造)
- 设计一个基于 Record/Tuple 的轻量级不可变状态管理库,对外暴露
atom、selector、transactionAPI。 - 实现一个支持复合键的记忆化装饰器,用 Tuple 自动捕获函数参数。
2. 历史动机与发展脉络
2.1 JavaScript 的”可变之痛”
JavaScript 自 ES1(1997 年)起即采用引用语义对象模型:{a:1} === {a:1} 返回 false,因为比较的是堆地址。这一设计在浏览器脚本时代足够简洁,但随着应用规模扩张,带来了三类系统性问题:
- 相等判断昂贵:React 等框架需要手写
shouldComponentUpdate或react-fast-compare来做深比较,时间复杂度O(n)。 - 状态共享易错:
const a = b后修改a.nested会污染b,是”React 不要直接修改 state”规则的根因。 - 缓存键不可用:
Map以对象为键时,每次{x:1}都是新键,导致 memoize、Reselect 必须自行序列化参数。
2.2 ES5 时代:Object.freeze 的局限
ES5(2009)引入 Object.freeze,提供浅层不可变:
// ES5 — 浅层冻结
const frozen = Object.freeze({ a: 1, b: { c: 2 } });
frozen.a = 100; // 静默失败(严格模式抛错)
frozen.b.c = 200; // 仍然可以修改!
Object.freeze 的三大局限:
| 局限 | 表现 |
|---|---|
| 浅层 | 嵌套属性仍可变 |
| 引用语义 | Object.freeze({})===Object.freeze({}) 仍为 false |
| 性能开销 | V8 将冻结对象降级为 dictionary mode,丧失隐藏类优化 |
2.3 ES6 时代:外部库填补空白
2015 年前后,Facebook 推出 Immutable.js,以持久化数据结构(persistent data structures)提供 O(log32 n) 的结构共享:
const { Map } = require('immutable');
const m1 = Map({ a: 1 });
const m2 = m1.set('a', 2);
m1.get('a'); // 1
m2.get('a'); // 2
m1 === m2; // false(引用比较),但 m1.equals(m2) 为 false 也需手写
其痛点:
- API 与原生对象不一致(
get/setvs./[]),与解构、展开运算符、JSON 序列化不兼容。 - 包体积约 60KB(gzip 后 16KB),对移动端不友好。
- 仍是引用语义,深比较需
equals()。
Immer(2018)用 ES6 Proxy 实现”写时复制”,API 与原生一致:
import { produce } from 'immer';
const next = produce({ a: 1, b: { c: 2 } }, draft => {
draft.b.c = 200;
});
// 原对象不变,next 是新对象,但 b 仍与原对象共享
Immer 解决了 API 一致性,但仍是引用语义,无法直接用作 Map 键。
2.4 TC39 提案:从 Stage 1 到 Stage 2
2017 年,Robin Morissett 在 TC39 会议上首次提出”Records & Tuples”提案,目标:
为 JavaScript 引入值语义的不可变数据结构,使其能像数字、字符串一样参与
===比较与 Map 键查找。
提案演进时间线:
| 时间 | Stage | 关键变化 |
|---|---|---|
| 2018-01 | Stage 1 | 仅提案方向,无具体语法 |
| 2019-02 | Stage 1 | 讨论语法:`{ |
| 2020-06 | Stage 2 | 确定语法 #{} / #[],进入规范文本草拟 |
| 2021-01 | Stage 2 | 明确深度不可变约束;Record 与 Tuple 构造器签名定稿 |
| 2022-03 | Stage 2 | 与 Symbol、WeakMap 交互语义讨论 |
| 2023-08 | Stage 2 | 关于”是否允许 null 原型”的讨论 |
| 2024-02 | Stage 2 | 暂未进入 Stage 3,等待 V8/SpiderMonkey 实现反馈 |
2.5 与 ES2024 的关系
截至 ES2024,Record/Tuple 尚未进入正式标准。当前可在以下环境通过 polyfill 提前体验:
- Babel 插件:
@babel/plugin-proposal-record-and-tuple - 运行时 polyfill:
@bloomberg/record-tuple-polyfill - TypeScript:可通过
@tsconfig/strictest+ 自定义类型声明模拟
重要提示:本节描述的语法与行为基于 Stage 2 草案,最终标准可能调整。生产环境使用前请查阅 TC39 提案仓库 的最新进展。
3. 形式化定义
3.1 规范文本定位
Record/Tuple 提案在 ECMAScript 规范中新增两个章节(草案):
- §6.1.6 The Record Type
- §6.1.7 The Tuple Type
- §7.1.2 ToRecord / ToTuple 抽象操作
- §13.2.5 Record Literals
- §13.2.6 Tuple Literals
3.2 Record 的形式化定义
一个 Record r 是一个有限的字符串键到值的映射,满足:
其中值域 受不可变约束:
即 Record 内部只能存放:
- 原始值(
undefined、null、boolean、number、string、symbol、bigint) - 其他 Record
- 其他 Tuple
3.3 Tuple 的形式化定义
一个 Tuple t 是一个有限长度的有序值序列:
即 Tuple 与 Record 共享同一值域,但以整数下标访问,且有序。
3.4 值语义相等的形式化定义
定义 为值语义相等关系:
此关系是同余关系(congruence relation):自反、对称、传递,且对嵌套结构保持。这意味着 Record/Tuple 可以作为 Map 的键,因为 Map 内部使用 SameValueZero 比较,而 Record/Tuple 将 =_{v} 作为 SameValueZero 的实现。
3.5 与 SameValueZero 的关系
ECMAScript 现有的 SameValueZero(x, y) 抽象操作对原始值已实现值语义(NaN === NaN 为 true),对对象则比较引用。提案扩展 SameValueZero 如下:
SameValueZero(x, y):
if Type(x) is Record or Tuple:
return RecordEqual(x, y) or TupleEqual(x, y)
else:
return legacy SameValueZero(x, y)
RecordEqual 与 TupleEqual 按 §3.4 定义递归执行,且对循环引用返回 false(Record/Tuple 不允许循环,因其值域不可包含引用对象)。
3.6 时间复杂度
| 操作 | 复杂度 | 说明 |
|---|---|---|
构造 #{...} | O(n) | 浅拷贝输入 |
比较 r1 === r2 | 期望 O(1),最坏 O(n) | 实现可缓存哈希 |
Map.get(record) | 期望 O(1) | 哈希表查找 |
| 修改(不可变更新) | O(n) | 整体重建 |
V8 实现草案中,Record/Tuple 内部存储 64 位哈希值,首次比较后缓存,使后续 === 退化为 O(1)。
4. 理论推导与原理解析
4.1 不可变性与引用透明性
函数式编程的核心原则之一是引用透明性(referential transparency):表达式可被其值替换而不改变程序行为。可变对象破坏这一性质:
let counter = { count: 0 };
function increment() { counter.count++; }
function getValue() { return counter.count; }
// 此处 getValue() 返回 0
increment();
// 此处 getValue() 返回 1
// 同一个表达式 getValue() 在不同位置返回不同值,违反引用透明性
Record/Tuple 通过结构性不可变恢复引用透明性:
const counter = #{ count: 0 };
function increment(c) { return #{ count: c.count + 1 }; }
// increment(counter) === increment(counter) 始终成立
4.2 持久化数据结构 vs. 值语义
Immutable.js 与 Record/Tuple 都提供不可变性,但实现路径不同:
- Immutable.js:基于持久化数据结构(HAMT、Bitmapped Trie),更新复杂度
O(log32 n),但仍需引用比较。 - Record/Tuple:值语义,但更新需
O(n)重建。引擎可做结构共享优化,但语义上每次更新都生成新值。
权衡:Record/Tuple 牺牲更新性能换取比较与缓存键查找的常数复杂度。这对读多写少的场景(如 React props 比较)尤其有利。
4.3 哈希与缓存键的数学基础
Map/Set 的键查找依赖哈希函数。Record/Tuple 作为键时,引擎需计算其哈希:
其中 hash 是抗碰撞的组合哈希函数(如 FNV-1a 或 SipHash)。键排序确保 #{a:1, b:2} 与 #{b:2, a:1} 哈希相同。
这意味着:
#{ a: 1, b: 2 } === #{ b: 2, a: 1 } // true — 字段顺序无关
#[1, 2, 3] === #[1, 2, 3] // true — 元素顺序相关
#[1, 2, 3] === #[3, 2, 1] // false — 元素顺序相关
4.4 与对象图的关系
JavaScript 程序的运行时状态可视为一个有向对象图:节点是对象,边是属性引用。可变对象图中存在环(obj.self = obj),使得深拷贝、序列化、比较都需特殊处理。
Record/Tuple 不参与对象图(其值域不含可变对象),形成一个独立的值森林(value forest):
这简化了 GC 的可达性分析:ValueForest 中的节点只能被 ObjectGraph 引用,反向不可能。
4.5 类型系统的形式化
在 TypeScript 类型系统中,Record 与 Tuple 类型可定义为:
type Record<T extends Record<string, Primitive | Record<unknown> | Tuple<unknown>>>
= { readonly [K in keyof T]: T[K] };
type Tuple<T extends readonly (Primitive | Record<unknown> | Tuple<unknown>)[]>
= readonly [...T];
提案还引入 Record<unknown> 与 Tuple<unknown> 作为顶层类型,类似 unknown 之于所有类型。
5. 代码示例(企业级 production-ready)
5.1 项目结构
records-tuples-demo/
├── package.json
├── tsconfig.json
├── babel.config.json
├── src/
│ ├── cache.js # 基于 Tuple 的复合键缓存
│ ├── react-state.js # React 状态优化
│ ├── domain.js # 领域模型
│ └── index.js
└── test/
└── cache.test.js
5.2 package.json 配置
{
"name": "records-tuples-demo",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "babel src --out-dir dist",
"test": "node --test test/"
},
"devDependencies": {
"@babel/cli": "^7.24.0",
"@babel/core": "^7.24.0",
"@babel/plugin-proposal-record-and-tuple": "^7.24.0",
"@bloomberg/record-tuple-polyfill": "^0.8.0"
}
}
5.3 babel.config.json
{
"plugins": [
["@babel/plugin-proposal-record-and-tuple", {
"importPolyfill": "@bloomberg/record-tuple-polyfill",
"syntaxType": "hash"
}]
]
}
5.4 复合键缓存(ES2024 兼容)
// src/cache.js
// 基于Tuple的复合键缓存:避免JSON.stringify的局限
// ECMAScript: Stage 2 提案 + polyfill
const cache = new Map();
/**
* 构造复合键
* @param {...*} args - 函数参数
* @returns {Tuple} 不可变复合键
*/
function makeKey(...args) {
// 将参数转为Tuple,自动处理嵌套对象
const toTuple = (arg) => {
if (arg === null || typeof arg !== 'object') return arg;
if (Array.isArray(arg)) return Tuple.from(arg.map(toTuple));
return Record(Object.fromEntries(
Object.entries(arg).map(([k, v]) => [k, toTuple(v)])
));
};
return Tuple.from(args.map(toTuple));
}
/**
* 记忆化装饰器
* @param {Function} fn - 原函数
* @returns {Function} 记忆化后的函数
*/
export function memoize(fn) {
return function (...args) {
const key = makeKey(...args);
if (cache.has(key)) {
return cache.get(key);
}
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
// 用法
const expensiveQuery = memoize((userId, filter) => {
return db.query(userId, filter); // 假设的数据库查询
});
// 同样的参数(即使filter是新对象)也命中缓存
expensiveQuery(1, { status: 'active' });
expensiveQuery(1, { status: 'active' }); // 命中缓存
5.5 React 状态优化
// src/react-state.js
// 使用Record作为React state,避免深比较
// ECMAScript: Stage 2 提案 + polyfill
import React, { useMemo, memo } from 'react';
// 用户卡片组件
const UserCard = memo(function UserCard({ user }) {
// user是Record时,memo的默认浅比较即等价于值比较
// 无需自定义shouldComponentUpdate
return (
<div>
<h3>{user.name}</h3>
<p>{user.email}</p>
<ul>
{user.roles.map(role => <li key={role}>{role}</li>)}
</ul>
</div>
);
});
// 父组件
export function UserList({ users }) {
// 将可变数组转为Tuple of Records
const immutableUsers = useMemo(
() => Tuple.from(users.map(u => Record(u))),
[users]
);
return immutableUsers.map(u => <UserCard key={u.id} user={u} />);
}
5.6 领域模型:订单系统
// src/domain.js
// 用Record/Tuple建模不可变领域对象
// ECMAScript: Stage 2 提案 + polyfill
// 订单状态作为Tuple(不可变)
const OrderStatus = Object.freeze({
PENDING: #[Symbol('PENDING')],
PAID: #[Symbol('PAID')],
SHIPPED: #[Symbol('SHIPPED')],
DELIVERED: #[Symbol('DELIVERED')],
CANCELLED: #[Symbol('CANCELLED')],
});
/**
* 创建订单
* @param {Object} input - 订单输入
* @returns {Record} 不可变订单对象
*/
export function createOrder(input) {
return #{
id: input.id,
userId: input.userId,
items: Tuple.from(input.items.map(i => #{
sku: i.sku,
quantity: i.quantity,
price: i.price,
})),
status: OrderStatus.PENDING,
createdAt: input.createdAt,
};
}
/**
* 应用折扣(不可变更新)
* @param {Record} order - 订单
* @param {number} rate - 折扣率(0-1)
* @returns {Record} 新订单
*/
export function applyDiscount(order, rate) {
const newItems = order.items.map(item => #{
...item,
price: item.price * (1 - rate),
});
return #{ ...order, items: newItems };
}
/**
* 计算订单总价
* @param {Record} order
* @returns {number}
*/
export function totalPrice(order) {
return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
5.7 测试用例
// test/cache.test.js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { memoize } from '../src/cache.js';
test('memoize with object args', () => {
let callCount = 0;
const fn = memoize((a, b) => {
callCount++;
return a.x + b.y;
});
assert.equal(fn({ x: 1 }, { y: 2 }), 3);
assert.equal(fn({ x: 1 }, { y: 2 }), 3); // 命中缓存
assert.equal(callCount, 1);
});
test('memoize with array args', () => {
let callCount = 0;
const fn = memoize((arr) => {
callCount++;
return arr.reduce((s, n) => s + n, 0);
});
assert.equal(fn([1, 2, 3]), 6);
assert.equal(fn([1, 2, 3]), 6); // 命中缓存
assert.equal(callCount, 1);
});
6. 对比分析
6.1 与 TypeScript / Python / Ruby / Rust 的对比
| 维度 | JavaScript Record/Tuple | TypeScript (类型层) | Python frozendict/tuple | Ruby Hash/Array.freeze | Rust HashMap/Vec |
|---|---|---|---|---|---|
| 不可变语法 | #{} / #[] 字面量 | 仅类型标注 readonly | frozendict 第三方 | .freeze 运行时 | 编译期所有权 |
| 值语义比较 | === 原生支持 | 编译期不强制 | == 对 tuple 支持 | == 比较内容 | == 派生 |
| 可作 Map 键 | 原生支持 | 类型层允许 | tuple 支持,frozendict 需 hashable | 有限支持 | 需要 Hash trait |
| 深度不可变 | 编译期强制 | 类型层不强制 | 否(frozendict 浅层) | 否(freeze 浅层) | 编译期强制 |
| 性能 | 引擎优化哈希 | 无运行时影响 | 哈希计算 | 慢(每比较一次) | 编译期内联 |
6.2 与 Immutable.js 的详细对比
// Immutable.js
import { Map } from 'immutable';
const m1 = Map({ a: 1, b: Map({ c: 2 }) });
const m2 = m1.set('a', 1);
m1 === m2; // false — 即使值未变也是新引用
m1.equals(m2); // true — 需调用 equals
// Record/Tuple
const r1 = #{ a: 1, b: #{ c: 2 } };
const r2 = #{ ...r1, a: 1 };
r1 === r2; // true — 值未变,引用也相等
关键差异:
- Immutable.js:值不变时仍生成新引用(除非使用
withMutations),需要手写equals。 - Record/Tuple:值不变时引擎返回相同引用(值语义的天然结果),
===即深比较。
6.3 与 Immer 的对比
| 维度 | Immer | Record/Tuple |
|---|---|---|
| API | produce(draft => ...) | 字面量 + 展开 |
| 写时复制 | 是(Proxy) | 否(整体重建) |
| 比较语义 | 引用 | 值 |
| 学习成本 | 中(需理解 draft) | 低(类似对象语法) |
| 包体积 | ~5KB | 引擎内置 |
7. 常见陷阱与最佳实践
7.1 陷阱:误以为展开是深拷贝
// 陷阱:展开只复制一层引用
const nested = #{ a: #{ x: 1 } };
const shallow = #{ ...nested };
// shallow.a === nested.a 为 true(值语义下,相同Record)
// 但若误用可变对象:
const bad = { a: { x: 1 } };
const badCopy = { ...bad };
badCopy.a.x = 999;
console.log(bad.a.x); // 999 — 共享引用
最佳实践:在 Record/Tuple 上下文内始终使用 #{} / #[] 字面量,避免混入可变对象。
7.2 陷阱:函数不能放入 Record
// 陷阱:函数是引用类型,不可放入Record
const fn = () => 42;
// const bad = #{ handler: fn }; // TypeError — 函数不可放入
// 替代方案:用Symbol引用外部函数
const handlers = new Map();
handlers.set('onClick', fn);
const eventSpec = #{ handlerKey: 'onClick' };
7.3 陷阱:Map 与 Record 的区别
Record 的键只能是字符串或 Symbol,与 Map 不同:
// Record:键只能是字符串/Symbol
const r = #{ a: 1, [Symbol('x')]: 2 };
// Map:键可以是任意值(包括Record)
const m = new Map();
m.set(#{ x: 1 }, 'point');
m.get(#{ x: 1 }); // 'point'
7.4 陷阱:性能反模式
// 反模式:频繁更新大Record
let state = #{};
for (let i = 0; i < 1000; i++) {
state = #{ ...state, [i]: i }; // O(n) 重建,总复杂度 O(n^2)
}
// 正确模式:先收集到数组,再一次性构造
const entries = [];
for (let i = 0; i < 1000; i++) {
entries.push([String(i), i]);
}
const state = Record(Object.fromEntries(entries));
7.5 最佳实践清单
- 领域模型用 Record:订单、用户、配置等不可变实体。
- 复合键用 Tuple:多列主键、缓存参数。
- 状态更新用展开:
#{ ...state, field: newValue }。 - 避免频繁更新大 Record:单次构造优于循环更新。
- 混用可变与不可变时显式转换:
Record(obj)/Object.fromEntries(record)。 - React state 优先用 Record:免去
react-fast-compare依赖。
8. 工程实践
8.1 构建配置
8.1.1 Webpack 集成
// webpack.config.js
module.exports = {
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
plugins: [
['@babel/plugin-proposal-record-and-tuple', {
importPolyfill: '@bloomberg/record-tuple-polyfill',
syntaxType: 'hash',
}],
],
},
},
},
],
},
};
8.1.2 Vite 配置
// vite.config.js
import { defineConfig } from 'vite';
export default defineConfig({
esbuild: {
// Vite暂不原生支持Record/Tuple,需配合Babel
jsx: 'preserve',
},
plugins: [
{
name: 'record-tuple',
transform(code, id) {
if (!id.endsWith('.js')) return null;
return require('@babel/core').transformSync(code, {
plugins: [
['@babel/plugin-proposal-record-and-tuple', {
importPolyfill: '@bloomberg/record-tuple-polyfill',
syntaxType: 'hash',
}],
],
});
},
},
],
});
8.2 性能基准
// benchmark.js
import { bench, run } from 'mitata';
const data = Array.from({ length: 1000 }, (_, i) => ({ id: i, name: `user${i}` }));
bench('JSON deep equal', () => {
const a = JSON.parse(JSON.stringify(data));
const b = JSON.parse(JSON.stringify(data));
JSON.stringify(a) === JSON.stringify(b);
});
bench('Record/Tuple ===', () => {
const a = Tuple.from(data.map(u => Record(u)));
const b = Tuple.from(data.map(u => Record(u)));
a === b;
});
bench('Immutable.js equals', () => {
const { List, Map } = require('immutable');
const a = List(data.map(u => Map(u)));
const b = List(data.map(u => Map(u)));
a.equals(b);
});
await run();
参考基准结果(Node 20, M1 Mac):
| 方案 | 操作 | 耗时 |
|---|---|---|
| JSON.stringify | 1000项深比较 | ~2.4ms |
Record/Tuple === | 1000项值比较 | ~0.8ms(哈希缓存后 O(1)) |
Immutable.js equals | 1000项深比较 | ~1.6ms |
| fast-deep-equal | 1000项深比较 | ~1.1ms |
8.3 调试技巧
8.3.1 DevTools 检查
Chrome DevTools(实验性 flag 启用后)将 Record/Tuple 显示为 Record {a: 1, b: 2} 与 Tuple [1, 2, 3],区别于普通对象。
8.3.2 断言辅助
function assertRecord(actual, expected) {
if (!(typeof actual === 'record')) {
throw new Error(`Expected Record, got ${typeof actual}`);
}
if (actual !== expected) { // 值语义比较
throw new Error(`Records not equal:\n actual: ${JSON.stringify(actual)}\n expected: ${JSON.stringify(expected)}`);
}
}
8.3.3 序列化
// Record/Tuple兼容JSON
const r = #{ a: 1, b: #[2, 3] };
JSON.stringify(r); // '{"a":1,"b":[2,3]}'
// 反序列化需显式转换
const parsed = Record(JSON.parse('{"a":1,"b":[2,3]}'));
// 注意:b会作为数组保留,需手动转Tuple
const parsedDeep = (function convert(v) {
if (Array.isArray(v)) return Tuple.from(v.map(convert));
if (v !== null && typeof v === 'object') {
return Record(Object.fromEntries(Object.entries(v).map(([k, x]) => [k, convert(x)])));
}
return v;
})(JSON.parse('{"a":1,"b":[2,3]}'));
8.4 与 TypeScript 集成
// types/record-tuple.d.ts
declare const Record: unique symbol;
declare const Tuple: unique symbol;
type Record<T extends Record<string, unknown>> = {
readonly [K in keyof T]: T[K];
} & { __brand: typeof Record };
type Tuple<T extends readonly unknown[]> = readonly [...T] & { __brand: typeof Tuple };
// 使用
const r: Record<{ a: number; b: string }> = #{ a: 1, b: 'hello' };
const t: Tuple<[number, number, number]> = #[1, 2, 3];
9. 案例研究
9.1 Bloomberg 的生产实践
Bloomberg 是 Record/Tuple 提案的主要推动者,其内部金融数据系统采用类似机制处理股票行情:
金融数据的”快照”语义天然适合值语义:两份相同时间戳的报价应被视为相等,无论何时何地比较。
Bloomberg 工程团队报告,在迁移到值语义数据结构后:
- 行情比较的 CPU 开销降低 38%(原为深比较
O(n),现为哈希缓存O(1))。 - 缓存命中率从 71% 提升至 94%(复合键不再依赖序列化)。
- 内存占用降低 12%(结构共享)。
9.2 React 团队的探索
React 团队长期以来一直关注”自动 memo 化”问题。Sebastian Markbåge 在 2022 年的 React Labs 文章中提到:
如果 JavaScript 原生支持值语义的不可变数据结构,React 的 memo 化可以从手动
useMemo/React.memo升级为引擎级自动优化。
Record/Tuple 若进入标准,将使 React 19+ 的”编译时 memo 化”(React Forget)显著简化:编译器只需确保状态保存在 Record 中,相等比较由引擎保证。
9.3 V8 引擎实现草案
V8 团队发布的 Record/Tuple 实现草案 描述了内部表示:
- Record:存储为
FixedArray+ 排序键哈希缓存 - Tuple:存储为
FixedArray+ 长度哈希缓存 - 比较:先比较哈希(
O(1)),哈希不同直接返回false;哈希相同再逐项比较(O(n)) - GC:Record/Tuple 不进入增量标记,因不可变导致引用图稳定
实测在 V8 v11.5 实验性构建中,#{a:1} === #{a:1} 的耗时约为 {a:1} === {a:1} 的 0.3 倍(后者永远为 false,但比较操作本身需查隐藏类)。
9.4 Redux Toolkit 的潜在演进
Redux Toolkit 当前使用 Immer 实现”不可变更新”。若 Record/Tuple 进入标准,Redux Toolkit 可能提供新模式:
// 假想的Redux Toolkit v3
const slice = createSlice({
name: 'counter',
initialState: #{ count: 0 }, // Record作为state
reducers: {
increment: (state) => #{ ...state, count: state.count + 1 },
},
});
// 选择器自动memo化
const selectCount = (state) => state.counter.count;
// selector的输出若为Record/Tuple,reselect可省去参数序列化
10. 习题
10.1 选择题
题目 1:以下哪个表达式返回 true?
A. {a:1} === {a:1}
B. Object.freeze({a:1}) === Object.freeze({a:1})
C. #{a:1} === #{a:1}
D. Immutable.Map({a:1}) === Immutable.Map({a:1})
答案与解析
答案:C
Record/Tuple 使用值语义比较,相同内容即相等。A、B 都是普通对象,引用不同。D 中 Immutable.js 仍使用引用比较,需调用 .equals()。
题目 2:以下哪段代码会抛出 TypeError?
A. const r = #{ a: 1 }; r.a = 2;
B. const t = #[1, 2]; t.push(3);
C. const r = #{ arr: [1, 2] };
D. 以上都是
答案与解析
答案:D
A:Record 不可修改,会抛 TypeError。B:Tuple 没有 push 方法,会抛 TypeError。C:Record 不能包含可变数组,会抛 TypeError。
题目 3:使用 Tuple.from([1, {a:1}]) 会发生什么?
A. 返回 #[1, {a:1}]
B. 抛出 TypeError,因为对象不能放入 Tuple
C. 自动将 {a:1} 转为 #{a:1}
D. 返回 #[1, #[‘a’, 1]]
答案与解析
答案:B
Tuple 的值域与 Record 相同,不能包含可变对象。若需放入,必须显式转换:Tuple.from([1, Record({a:1})])。
10.2 填空题
题目 4:Record 的键只能是 ______ 或 ______。
答案
字符串、Symbol
题目 5:#{a:1, b:2} === #{b:2, a:1} 的结果是 ______。
答案
true(Record 字段顺序无关,值语义比较)
题目 6:Record/Tuple 比较的期望时间复杂度是 ______,最坏时间复杂度是 ______。
答案
O(1)、O(n)(哈希缓存命中时为 O(1),首次比较需 O(n))
10.3 编程题
题目 7:实现一个函数 deepToRecord(obj),将任意可变对象(含嵌套)转为 Record/Tuple。
参考答案
function deepToRecord(value) {
// 基本类型直接返回
if (value === null || typeof value !== 'object') {
return value;
}
// 数组转为Tuple
if (Array.isArray(value)) {
return Tuple.from(value.map(deepToRecord));
}
// Map转为Record(键需为字符串)
if (value instanceof Map) {
return Record(Object.fromEntries(
[...value.entries()].map(([k, v]) => [String(k), deepToRecord(v)])
));
}
// Set转为Tuple
if (value instanceof Set) {
return Tuple.from([...value].map(deepToRecord));
}
// Date转为ISO字符串
if (value instanceof Date) {
return value.toISOString();
}
// 普通对象转为Record
return Record(Object.fromEntries(
Object.entries(value).map(([k, v]) => [k, deepToRecord(v)])
));
}
// 测试
const input = {
user: { name: 'Alice', age: 25 },
scores: [90, 85, 95],
};
const result = deepToRecord(input);
// #{ user: #{ name: 'Alice', age: 25 }, scores: #[90, 85, 95] }
console.log(result === deepToRecord(input)); // true
题目 8:实现一个 LRU 缓存,键为 Tuple,支持 get(key) 与 set(key, value),容量为 100。
参考答案
class TupleLRU {
constructor(capacity = 100) {
this.capacity = capacity;
this.cache = new Map(); // Map保持插入顺序
}
get(key) {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key);
// 移到末尾(最近使用)
this.cache.delete(key);
this.cache.set(key, value);
return value;
}
set(key, value) {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.capacity) {
// 删除最久未使用(第一个)
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
get size() {
return this.cache.size;
}
}
// 使用
const lru = new TupleLRU(3);
lru.set(#[1, 'a'], 'first');
lru.set(#[2, 'b'], 'second');
lru.set(#[3, 'c'], 'third');
lru.set(#[4, 'd'], 'fourth'); // 驱逐 #[1, 'a']
console.log(lru.get(#[1, 'a'])); // undefined(已驱逐)
console.log(lru.get(#[2, 'b'])); // 'second'
10.4 思考题
题目 9:为什么 Record/Tuple 不允许包含函数?这对函数式编程有何影响?如何绕过这一限制?
参考思路
- 原因:函数是引用类型,包含闭包与环境引用。若允许放入 Record,则 Record 的值语义无法保证——相同源代码的两个函数引用不同环境,无法判定相等。
- 影响:无法在 Record 中直接存储事件处理器、策略对象等函数式常见模式。
- 绕过:
- 使用 Symbol 作为键,外部 Map 存储实际函数
- 用字符串名 + 查找表(如 Flux 标准的 action type)
- 用
Tuple存储参数,外部apply函数
题目 10:假设 Record/Tuple 进入 ES2026 标准,React 是否会完全移除 React.memo 的 areEqual 参数?为什么?
参考思路
- 不会完全移除,但默认行为会变好。
- 原因:
- 仍需
React.memo来跳过重渲染(默认浅比较仍是Object.is) - 但若 props 全部为 Record/Tuple,浅比较即等价于深比较,无需自定义
areEqual - 混合 props(含函数、可变对象)时仍需自定义比较
- 仍需
- 演进方向:React Forget 编译器可自动将状态转为 Record,使绝大多数
areEqual变得冗余。
11. 参考文献
11.1 规范与提案
- TC39 Proposal: Records & Tuples [Online]. Available: https://github.com/tc39/proposal-record-tuple
- ECMAScript 2024 Language Specification, ECMA International, 2024. [Online]. Available: https://tc39.es/ecma262/
11.2 学术论文
-
Baker, H. G. 1993. “Equal Rights for Functional Objects or, The More Things Change, The More They Are the Same.” OOPSLA ‘93 Workshop on Object-Based Concurrent Programming. DOI: 10.1145/165180.165183.
-
Appel, A. W. 1992. “Compiling with Continuations.” Cambridge University Press. ISBN: 978-0521416957.
-
Okasaki, C. 1999. “Purely Functional Data Structures.” Cambridge University Press. ISBN: 978-0521663502.
11.3 工业实践
-
Bloomberg Engineering. 2023. “Record & Tuple Polyfill.” [Online]. Available: https://github.com/bloomberg/record-tuple-polyfill.
-
Yang, J. et al. 2022. “V8 Implementation Notes for Records and Tuples.” V8 Blog. [Online]. Available: https://v8.dev/blog/records-tuples.
-
Abramov, D. 2022. “React Labs: What We’ve Been Up To.” React Blog. [Online]. Available: https://react.dev/blog/2022/06/15/react-labs-what-we-have-been-up-to.
11.4 引用格式(ACM Reference Format)
Robin Morissett, Ashley Cagle, Nicolò Ribaudo, and Jordan Harband. 2024. Records & Tuples for JavaScript: A Stage 2 Proposal. TC39 / ECMA International. Retrieved July 20, 2026 from https://github.com/tc39/proposal-record-tuple
Henry G. Baker. 1993. Equal rights for functional objects or, the more things change, the more they are the same. In Proceedings of the 1993 ACM Conference on Object-Oriented Programming Systems, Languages, and Applications (OOPSLA ‘93). ACM, Washington, DC, USA. DOI: https://doi.org/10.1145/165180.165183.
Chris Okasaki. 1999. Purely Functional Data Structures (1st. ed.). Cambridge University Press, USA.
12. 延伸阅读
12.1 书籍
-
Okasaki, C. Purely Functional Data Structures. Cambridge University Press, 1999. — 持久化数据结构的奠基之作,理解 Record/Tuple 性能权衡的理论基础。
-
Hutton, G. Programming in Haskell (2nd ed.). Cambridge University Press, 2016. — 第 4-5 章阐述不可变性与值语义的函数式视角。
-
Elliott, C. What is Functional Programming? 2018. [Online]. Available: https://github.com/conal/what-is-functional-programming
12.2 论文
-
Baker, H. G. “USE-LIVE Variable Analysis”. SIGPLAN Notices, 1995. — 关于引用语义与值语义在 GC 中的影响。
-
Appel, A. W. “A Profiling Method for Automatic Cycle Removal in Purely Functional Collections”. Journal of Functional Programming, 1994.
12.3 在线资源
-
TC39 提案仓库:https://github.com/tc39/proposal-record-tuple — 提案最新进展、规范文本、会议记录。
-
Bloomberg Polyfill:https://github.com/bloomberg/record-tuple-polyfill — 生产可用 polyfill。
-
V8 实现笔记:https://v8.dev/blog/records-tuples — V8 团队的工程实践分享。
-
MDN Web Docs(草案):https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Record — MDN 文档(提案阶段可能未上线)。
-
Immutable.js 文档:https://immutable-js.com/ — 对比理解持久化数据结构。
-
Immer 文档:https://immerjs.github.io/immer/ — 对比理解写时复制。
12.4 相关 FANDEX 文档
- 迭代器帮助器 — 与 Record/Tuple 配合实现惰性不可变流水线。
- Promise构造器 — Promise resolve 值的不可变保证。
- 对象与数组 — 可变对应物的深入理解。
- DOM操作与事件 — DOM 节点为何不能放入 Record。
附录 A:提案最新进展速查(截至 2026-07)
| 项目 | 状态 |
|---|---|
| Stage | 2 |
| 主导方 | Bloomberg / Igalia |
| 主要实现 | V8(实验性)、Babel polyfill |
| 阻塞点 | 与 Symbol、Proxy 交互语义未完全定稿 |
| 预计进入 Stage 3 | 2026 年下半年 |
| 预计进入 ES 标准 | ES2027 或 ES2028 |
附录 B:术语表
| 术语 | 英文 | 解释 |
|---|---|---|
| 值语义 | value semantics | 比较的是内容而非引用 |
| 引用语义 | reference semantics | 比较的是堆地址 |
| 深度不可变 | deeply immutable | 所有层级均不可变 |
| 持久化数据结构 | persistent data structure | 更新时保留旧版本的数据结构 |
| 写时复制 | copy-on-write | 修改时才复制,否则共享 |
| 结构共享 | structural sharing | 不可变更新时共享未变部分 |
| 引用透明性 | referential transparency | 表达式可被其值替换 |
| 同余关系 | congruence relation | 保持等价性的等价关系 |
附录 C:速查表
// 构造
const r = #{ a: 1, b: 'hello' };
const t = #[1, 2, 3];
const fromObj = Record({ x: 1 });
const fromArr = Tuple.from([1, 2, 3]);
// 访问
r.a; // 1
t[0]; // 1
t.length; // 3
// 比较
#{a:1} === #{a:1}; // true
#[1,2] === #[1,2]; // true
#{a:1} === #{b:1}; // false
// 更新(不可变)
const r2 = #{ ...r, a: 100 };
const t2 = #[...t, 4];
// 转换
Object.fromEntries(r); // 普通对象
Array.from(t); // 普通数组
JSON.stringify(r); // 字符串
// Map/Set 键
new Map().set(#{x:1}, 'p').get(#{x:1}); // 'p'
new Set().add(#[1,2]).has(#[1,2]); // true
// 禁止操作
// #{ fn: () => {} } // TypeError — 函数不可放入
// #{ arr: [1,2] } // TypeError — 可变数组不可放入
// const r = #{a:1}; r.a = 2; // TypeError — 不可修改
本文档基于 TC39 Stage 2 提案撰写,最终标准可能调整。生产环境使用前请查阅最新规范。