精讀《Records & Tuples 提案》

黃子毅發表於2021-12-27

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 許可證

相關文章