关于javascript:精读Records-Tuples-提案

52次阅读

共计 11832 个字符,预计需要花费 30 分钟才能阅读完成。

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

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

关注 前端精读微信公众号

<img width=200 src=”https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg”>

版权申明:自在转载 - 非商用 - 非衍生 - 放弃署名(创意共享 3.0 许可证)

正文完
 0