一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第 3 天,点击查看活动详情。
TC39
关于 TC39 的介绍可以看前一篇文章,这里不再赘述。
Immutable
众所周知,JavaScript 主要有两大数据类型:基础数据类型和引用数据类型,其中像对象(Object),数组(Array)等引用类型在使用过程中需要注意小心。假设我们有个任务列表,原数据其大致结构如下:
const tasks = [
{name:"采购", done: false},
{name:"下单", done: false},
{name:"发货", done: false}
]
每个人认领一份,假设张三现在完成了采购,他需要更新自己的任务列表:
let zTask = tasks.map(task=>{
if(task.name==='采购'){
task.done = true;
}
return task
})
console.log(zTask[0].done) // true
console.log(tasks[0].done) // true
张三的任务单子看似处理成功了,但原数据 tasks 的采购状态也发生了改变,如果其他人要使用 tasks 数据进行处理,就会出现数据的混乱。所以张三处理单子,我们目前一般这样操作:
let zTask = tasks.slice()
zTask[0] ={...zTask[0],done:true}
console.log(zTask[0].done) // true
console.log(tasks[0].done) // false
目前 tasks 的每一项还没有嵌套,如果键值是对象,变更数据会更麻烦,现有的方案一般是把元数据做一次深拷贝,生成一个新的数据对象后做后续操作。
为了解决这个问题,2014 年 Facebook 推出了 Immutable.js 库,近几年 Mobox 作者也推出了 Immer 工具库,都是旨在为 JavaScript 创建不可变更对象。数据不可变性可谓是万众期盼,所以在 2021 年 12 月,TC39 收到了关于 Record-Tuple 的提案,短短几个月的时间,目前已经处在 stage-2 的阶段,ECMAScript 内置化数据不可变性指日可待。
Record & Tuple
目前该提案有可操作的网址,文章内容均在官方 playground 验证说明,大家可以猛戳试试。
该提案包含两个主要概念:Record 用于处理类对象结构,Tuple 用于处理类数组结构(看到 Tuple 大家可能会想到 TS 的元组,但两者基本没啥关系)。先看下基本用法:
// Record
const rObj = #{
name: 'sam',
age: 20
}
// Tuple
const tArr = #['chord',22,rObj]
console.log(tArr) // Tuple {0: "chord", 1: 22, 2: Record}
上面的例子有两条规则需要说明:
- Record 和 Tuple 都只支持基本数据类型或 Record/Tuple 类型
- Record 对象的键值只能是 string 类型,这一特性和 Object 保持一致,
另外我们注意到它的使用只不过在 Object 或者 Array 前面加了个
#(注意不要有空格) 就自动识别成 Record 或 Tuple 了,那你可能会想我在数据类型变量前面加一个能不能行呢,比如:
let iObj = {name:'sam'}
const rObj = #iObj // SyntaxError: unknown operate
那肯定是直接报错的,你可以考虑使用结构:
let iObj = {name:'sam'}
const rObj = #{...iObj}
但其实也是定义了新的引用类型,提案推荐的 polyfill 做了两个类的定义,用法大致如下:
import { Record, Tuple } from "@bloomberg/record-tuple-polyfill";
// Record
const rObj = Record({
name: 'sam',
age: 20
})
// Tuple
const tArr = Tuple('chord',22,rObj)
这里注意 Tuple 调用时是传入多个值,这和之前说的规则 1 是保持一致的,不能传入数组这种引用类型。
另外值得说明的是 Record 和 Tuple 的值是相等的,而且支持嵌套,即:
const base = [1, 2];
const temp = #{test:'test'}
const obj = {
name:'ad', age:23, cc: temp
}
console.log(#[...base, 3] === #[1, 2, 3]); // true
console.log(#{...obj} === #{name:'ad', age:23, cc:temp}); // true
应用和进阶
定义好的 Record 和 Tuple 除了不可变性外,用法和基本的对象及数组基本没啥区别。回到我们最开始的例子,我们将原数据改造一下:
const tasks = #[
#{name:"采购", done: false},
#{name:"下单", done: false},
#{name:"发货", done: false}
]
现在张三如果认领了这份任务列表,完成采购任务之后,如果误操作修改了原数据,代码则会报错,避免之前说的数据错乱问题。
值相等
前面说到值相等,主要是因为 Record 和 Tuple 只支持基本数据类型,其内部实现都是把每个键值对用 Map 的形式存储起来,而 Map 的键是支持任何数据类型的,所以在整体比较时就遍历查找即可。具体代码可参考 Record 和 interngraph,核心代码摘抄如下:
// record 部分
const RECORD_GRAPH = new InternGraph(createFreshRecordFromProperties);
function createRecordFromObject(value) {
...
return RECORD_GRAPH.get(properties);
}
export function Record(value) {
return createRecordFromObject(value);
}
// InternGraph 部分
this._map = new ArrayKeyedMap();
...
const maps = [this._map];
for (const value of values) {
...
map = map.get(value);
maps.push(map);
}
for (const map of maps) {
const refcount = map.get(GRAPH_REFCOUNT) || 0;
map.set(GRAPH_REFCOUNT, refcount + 1);
}
const value = this._creator(values);
ref = new WeakRef(value);
map.set(GRAPH_VALUE, ref);
return value
Record 键值排序
假设现在有这样两个对象,使用 record 包装一下:
let obj1 = #{name:'sam',age:24}
let obj2 = #{age:24, name:'sam'}
console.log(obj1 === obj2) // true
测试发现这两个对象值是相等的,或者看个更实际的例子:
const record = #{ z: 1, a: 2 };
console.log(#[...Object.keys(record)] === #["a", "z"]); // true
console.log([...Object.values(record)]); // [2, 1]
console.log([...Object.entries(record)[0]]); // ["a", 2]
从这个例子可以看到,我们的原对象明明第一个键值是 z:1,但操作出来后得到的结果都可以发现实际是 a:2 位于第一位。这样做的目的也是为了实现值的相等,具体内容可以参考前文,那这里的排序实现就比较简单,polyfill 的实现内容如下:
const properties = Object.entries(value)
.sort(function([a], [b]) {
if (a < b) return -1;
else if (a > b) return 1;
return 0;
})
.map(([name, value]) => [name, validateProperty(value)]);
这里的 sort 方法比较了键值,然后返回排序结果。其实提案规定了键只能是 string 类型,其实可以用更通用的 String.prototype.localeCompare() 方法:
const properties = Object.entries(value)
.sort(function([a], [b]) {
return a.localeCompare(b)
})
.map(([name, value]) => [name, validateProperty(value)]);
思考和总结
虽然该提案发展迅速,但就目前的实现来看,我觉得存在两点问题:
1.定义过于简单
如果就说基本数据类型的话,使用 JSON.stringify 好像也有不错的效果,毕竟它也是基础的”深拷贝“解决方案:
console.log(
JSON.stringify({name:'sam', age:34,list:[3,2]})
===
JSON.stringify({name:'sam', age:34,list:[3,2]})
) // true
2.没有解决痛点问题
就像最开始说的,要避免污染原数据,所以希望原数据拥有不可变性。不管是 Immutable.js 还是 Immer 都提供了更新数据而不污染原数据的方法。
// Immutable.js
const { Map } = require('immutable@4.0.0');
const map1 = Map({ a: 1, b: 2, c: 3 });
const map2 = map1.set('b', 4);
map1 === map2; // false
// Immer
import produce from "immer"
const baseState = [
{
title: "Learn TypeScript",
done: true
}
]
const nextState = produce(baseState, draft => {
draft[0].done = true
})
baseState === nextState; // false
而现在提案里好像没有提及这方面的内容,当然后续可以增强内部 Map 能力,或者采用 proxy 做代理实现数据更新,未来可期吧。欢迎评论区留言讨论。
以上,感谢阅读。