3

精读《Records & Tuples 提案》

 2 years ago
source link: https://zhuanlan.zhihu.com/p/450188771
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

精读《Records & Tuples 提案》

前端开发话题下的优秀答主

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

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

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" 也重渲染了,因为它的引用变化了。

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

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

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

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

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"]

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

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 用法与普通数组几乎一样:

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

由于 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

另外不能在 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))

语法

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

判等

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

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 都可与处理普通对象无异:

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 类型的:

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:

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 也都是可迭代的:

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 转化为普通对象:

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 也会新增三种返回结果:

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

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

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

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

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

这有非常强的割裂感。

为什么不使用全局 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 的认知。

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

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

为什么是深度不可变?

这个提案喷了一下 Object.freeze

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

由于只保障了一层,所以 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]

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

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 许可证


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK