当前期刊数: 223

immutablejs、immer 等库已经让 js 具备了 immutable 编程的可能性,但还存在一些无解的问题,即 “怎么保证一个对象真的不可变”。

如果不是拍胸脯担保,现在还真没别的办法。或许你觉得 frozen 是个 good idea,但它内部仍然可以增加非 frozen 的 key。

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

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

proposal-record-tuple 解决的就是这个问题,它让 js 原生支持了 不可变数据类型(高亮、加粗)。

概述 & 精读

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,这非常令人头痛,也造成了许多困扰,下面我试图解释一下这个困扰。

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

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

但如果是下面的情况呢?

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

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

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
{
"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 变成了:

1
2
3
4
5
6
7
8
9
10
11
{
"id": "1",
"label": "root",
"children": [{
"id": "2",
"label": "apple",
}, {
"id": "3",
"label": "banana",
}]
}

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

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

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

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

Records & Tuples 提案说明,不可变数据结构除了定义时需要用 ## 符号申明外,使用时与普通对象、数组无异。

Record 用法与普通 object 几乎一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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"]

下面的例子说明,Records 与 object 在函数内处理时并没有什么不同,这个在 FAQ 里提到是一个非常重要的特性,可以让 immutable 完全融入现在的 js 生态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 并没有什么需要特别注意的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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))

语法

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

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

不支持空数组项:

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

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

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

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

也不能在里面定义方法:

1
##{ method() { } }  // SyntaxError

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

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

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

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

判等

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

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

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

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

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

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

1
2
3
4
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 是否相等取决于内部对象引用是否相等:

1
2
3
const obj = {};
assert(Box(obj) === Box(obj));
assert(Box({}) !== Box({}));

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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]));

对象模型如何处理 Records & Tuples

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

1
2
3
4
5
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 的标准库支持

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

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

还可通过 Record.fromEntriesTuple.from 方法把普通对象或数组转成 Record, Tuple:

1
2
3
4
5
6
7
8
9
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 一样。

Record 与 Tuple 也都是可迭代的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 会把 Record & Tuple 转化为普通对象:

1
2
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 无异。

Tuple.prototype 方法与 Array 很像,但也有些不同之处,主要区别是不会修改引用值,而是创建新的引用,具体可看 appendix

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

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

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

1
2
3
4
5
6
const record1 = ##{ a: 1, b: 2 };
const record2 = ##{ a: 1, b: 2 };

const map = new Map();
map.set(record1, true);
assert(map.get(record2));
1
2
3
4
5
6
7
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:

1
2
3
4
5
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);
1
2
3
4
5
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 的一致性,所以开发者上手应该会比较容易。

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

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

1
2
3
4
5
state.jobResult = Immutable.fromJS(
ExternalLib.processJob(
state.jobDescription.toJS()
)
);

这有非常强的割裂感。

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

下面给了两个对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// 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 的认知。

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

采用已有关键字可能导致歧义或者兼容性问题,另外其实还有 {| |} [| |]提案,但目前 ## 的赢面比较大。

为什么是深度不可变?

这个提案喷了一下 Object.freeze

1
2
3
4
5
6
7
const object = {
a: {
foo: "bar",
},
};
Object.freeze(object);
func(object);

由于只保障了一层,所以 object.a 依然是可变的,既然要 js 原生支持 immutable,希望的肯定是深度不可变,而不是只有一层。

另外由于这个语法会在语言层面支持不可变校验,而深度不可变校验是非常重要的。

FAQ

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 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 语法替换新建一个对象:

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

但在深度修改时也遇到了绕不过去的问题,目前有一个 提案 在讨论这件事,这里提到一个有意思的语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 的关系?

互补。

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

目前不考虑。

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

熟悉 TS 的同学都知道只是名字一样而已。

性能预期是?

这个问题挺关键的,如果这个提案性能不好,那也无法用于实际生产。

当前阶段没有对性能提出要求,但在 Stage4 之前会给出厂商优化的最佳实践。

总结

如果这个提案与嵌套更新提案一起通过,在 js 使用 immutable 就得到了语言层面的保障,包括 Immutablejs、immerjs 在内的库是真的可以下岗啦。

讨论地址是:精读《Records & Tuples 提案》· Issue ##384 · dt-fe/weekly

如果你想参与讨论,请 点击这里,每周都有新的主题,周末或周一发布。前端精读 - 帮你筛选靠谱的内容。

关注 前端精读微信公众号

版权声明:自由转载-非商用-非衍生-保持署名(创意共享 3.0 许可证


本站由 钟意 使用 Stellar 1.28.1 主题创建。
又拍云 提供CDN加速/云存储服务
vercel 提供托管服务
湘ICP备2023019799号-1
总访问 次 | 本页访问