Javascript Immutable 新方向 —— Record & Tuple

790 阅读6分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 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}

上面的例子有两条规则需要说明:

  1. Record 和 Tuple 都只支持基本数据类型或 Record/Tuple 类型
  2. 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 的键是支持任何数据类型的,所以在整体比较时就遍历查找即可。具体代码可参考 Recordinterngraph,核心代码摘抄如下:

// 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 做代理实现数据更新,未来可期吧。欢迎评论区留言讨论。

以上,感谢阅读。

题图来源 Immutable final logo sign