共享可變狀態中出現的問題以及如何避免[每日前端夜話0xDB]

共享可變狀態中出現的問題以及如何避免[每日前端夜話0xDB]

瘋狂的技術宅 前端先鋒 html

每日前端夜話0xDB
每日前端夜話,陪你聊前端。
天天晚上18:00準時推送。
正文共:6189 字
預計閱讀時間:15 分鐘
做者:Axel Rauschmayer 博士
翻譯:瘋狂的技術宅
來源:2ality前端

共享可變狀態中出現的問題以及如何避免[每日前端夜話0xDB]

本文回答瞭如下問題:程序員

  • 麼是共享可變狀態?
  • 爲何會出現問題?
  • 如何避免其問題?
    標有「(高級)」的部分會更深刻,若是你想更快地閱讀本文,能夠跳過。

什麼是共享可變狀態,爲何會有問題?

共享可變狀態的解釋以下:es6

  • 若是兩個或多個參與方能夠更改相同的數據(變量,對象等),而且
  • 若是它們的生命週期重疊,
    則可能會有一方修改會致使另外一方沒法正常工做的風險。如下是一個例子:
1function logElements(arr) {
 2  while (arr.length > 0) {
 3    console.log(arr.shift());
 4  }
 5}
 6
 7function main() {
 8  const arr = ['banana', 'orange', 'apple'];
 9
10  console.log('Before sorting:');
11  logElements(arr);
12
13  arr.sort(); // changes arr
14
15  console.log('After sorting:');
16  logElements(arr); // (A)
17}
18main();
19
20// Output:
21// 'Before sorting:'
22// 'banana'
23// 'orange'
24// 'apple'
25// 'After sorting:'

這裏有兩個獨立的部分:函數logElements()和函數main()。後者想要在對數組進行排序的先後都打印其內容。可是它到用了 logElements() ,會致使數組被清空。因此 main() 會在A行輸出一個空數組。正則表達式

在本文的剩餘部分,咱們將介紹三種避免共享可變狀態問題的方法:編程

  • 經過複製數據避免共享
  • 經過無損更新來避免數據變更
  • 經過使數據不可變來防止數據變更
    針對每一種方法,咱們都會回到剛纔看到的示例並進行修復。

經過複製數據避免共享

在開始研究如何避免共享以前,咱們須要看一下如何在 JavaScript 中複製數據。json

淺拷貝與深拷貝


對於數據,有兩個可複製的「深度」:數組

  • 淺拷貝僅複製對象和數組的頂層條目。原始值和副本中的輸入值仍然相同。
  • 深拷貝還會複製條目值的條目。也就是說,它會完整遍歷樹,並複製全部節點。
    不幸的是,JavaScript 僅內置了對淺拷貝的支持。若是須要深拷貝,則須要本身實現。

JavaScript 中的淺拷貝


讓咱們看一下淺拷貝的幾種方法。安全

經過傳播複製普通對象和數組數據結構

咱們能夠擴展爲對象字面量和擴展爲數組字面量進行復制:

1const copyOfObject = {...originalObject};
2const copyOfArray = [...originalArray];

可是傳播有幾個限制:

  • 不復制原型:
1class MyClass {}
2
3const original = new MyClass();
4assert.equal(MyClass.prototype.isPrototypeOf(original), true);
5
6const copy = {...original};
7assert.equal(MyClass.prototype.isPrototypeOf(copy), false);
  • 正則表達式和日期之類的特殊對象有未複製的特殊「內部插槽」。

  • 僅複製本身的(非繼承)屬性。鑑於原型鏈的工做原理,這一般是最好的方法。可是你仍然須要意識到這一點。在如下示例中,copy 中沒有 original 的繼承屬性 .inheritedProp,由於咱們僅複製本身的屬性,而未保留原型。
1const proto = { inheritedProp: 'a' };
2const original = {__proto__: proto, ownProp: 'b' };
3assert.equal(original.inheritedProp, 'a');
4assert.equal(original.ownProp, 'b');
5
6const copy = {...original};
7assert.equal(copy.inheritedProp, undefined);
8assert.equal(copy.ownProp, 'b');
  • 僅複製可枚舉的屬性。例如數組實例本身的屬性 .length 不可枚舉,也不能複製:
1const arr = ['a', 'b'];
2assert.equal(arr.length, 2);
3assert.equal({}.hasOwnProperty.call(arr, 'length'), true);
4
5const copy = {...arr};
6assert.equal({}.hasOwnProperty.call(copy, 'length'), false);
  • 與 property 的 attributes無關,它的副本始終是可寫和可配置的 data 屬性,例如:
1const original = Object.defineProperties({}, {
 2prop: {
 3  value: 1,
 4  writable: false,
 5  configurable: false,
 6  enumerable: true,
 7},
 8});
 9assert.deepEqual(original, {prop: 1});
10
11const copy = {...original};
12// Attributes 「writable」 and 「configurable」 of copy are different:
13assert.deepEqual(Object.getOwnPropertyDescriptors(copy), {
14prop: {
15  value: 1,
16  writable: true,
17  configurable: true,
18  enumerable: true,
19},
20});

這意味着,getter 和 setter 都不會被如實地被複制:value 屬性(用於數據屬性),get 屬性(用於 getter)和set 屬性(用於 setter)是互斥的。

js const original = { get myGetter() { return 123 }, set mySetter(x) {}, }; assert.deepEqual({...original}, { myGetter: 123, // not a getter anymore! mySetter: undefined, });

  • 拷貝很淺:該副本具備原始版本中每一個鍵值條目的新版本,可是原始值自己不會被複制。例如:
1const original = {name: 'Jane', work: {employer: 'Acme'}};
 2const copy = {...original};
 3
 4// Property .name is a copy
 5copy.name = 'John';
 6assert.deepEqual(original,
 7{name: 'Jane', work: {employer: 'Acme'}});
 8assert.deepEqual(copy,
 9{name: 'John', work: {employer: 'Acme'}});
10
11// The value of .work is shared
12copy.work.employer = 'Spectre';
13assert.deepEqual(
14original, {name: 'Jane', work: {employer: 'Spectre'}});
15assert.deepEqual(
16copy, {name: 'John', work: {employer: 'Spectre'}});

這些限制有的能夠消除,而其餘則不能:

  • 咱們能夠在拷貝過程當中爲副本提供與原始原型相同的原型:
1class MyClass {}
2
3const original = new MyClass();
4
5const copy = {
6__proto__: Object.getPrototypeOf(original),
7...original,
8};
9assert.equal(MyClass.prototype.isPrototypeOf(copy), true);

另外,咱們能夠在副本建立後經過 Object.setPrototypeOf() 設置原型。

  • 沒有簡單的方法能夠通用地複製特殊對象。

  • 如前所述,僅複製本身的屬性是功能而非限制。

  • 咱們能夠用 Object.getOwnPropertyDescriptors() 和 Object.defineProperties() 複製對象(操做方法稍後說明):

  • 他們考慮了全部屬性(而不只僅是 value),所以正確地複製了getters,setters,只讀屬性等。

  • 用 Object.getOwnPropertyDescriptors() 檢索可枚舉和不可枚舉的屬性。

  • 咱們將在本文後面的內容中介紹深拷貝。

經過 Object.assign() 進行淺拷貝(高級)

Object.assign()的工做原理就像傳播到對象中同樣。也就是說如下兩種複製方式大體相同:

1const copy1 = {...original};
2const copy2 = Object.assign({}, original);

使用方法而不是語法的好處是能夠經過庫在舊的 JavaScript 引擎上對其進行填充。

不過 Object.assign() 並不徹底像傳播。它在一個相對微妙的方面有所不一樣:它以不一樣的方式建立屬性。

  • Object.assign() 使用 assignment 建立副本的屬性。
  • 傳播定義副本中的新屬性。
    除其餘事項外,assignment 會調用本身的和繼承的設置器,而 definition 不會(關於 assignment 與 definition 的更多信息)。這種差別不多引發注意。如下代碼是一個例子,但它是人爲設計的:
1const original = {['__proto__']: null};
2const copy1 = {...original};
3// copy1 has the own property '__proto__'
4assert.deepEqual(
5  Object.keys(copy1), ['__proto__']);
6
7const copy2 = Object.assign({}, original);
8// copy2 has the prototype null
9assert.equal(Object.getPrototypeOf(copy2), null);

經過 Object.getOwnPropertyDescriptors()Object.defineProperties() 進行淺拷貝(高級)

JavaScript 使咱們能夠經過屬性描述符建立屬性,這些對象指定屬性屬性。例如,經過 Object.defineProperties() ,咱們已經看到了它。若是將該方法與 Object.getOwnPropertyDescriptors()結合使用,則能夠更加忠實地進行復制:

1function copyAllOwnProperties(original) {
2  return Object.defineProperties(
3    {}, Object.getOwnPropertyDescriptors(original));
4}

這消除了經過傳播複製對象的兩個限制。

首先,可以正確複製本身 property 的全部 attribute。咱們如今能夠複製本身的 getter 和 setter:

1const original = {
2  get myGetter() { return 123 },
3  set mySetter(x) {},
4};
5assert.deepEqual(copyAllOwnProperties(original), original);

其次,因爲使用了 Object.getOwnPropertyDescriptors(),非枚舉屬性也被複制了:

1const arr = ['a', 'b'];
2assert.equal(arr.length, 2);
3assert.equal({}.hasOwnProperty.call(arr, 'length'), true);
4
5const copy = copyAllOwnProperties(arr);
6assert.equal({}.hasOwnProperty.call(copy, 'length'), true);

JavaScript 的深拷貝


如今該解決深拷貝了。首先咱們將手動進行深拷貝,而後再研究通用方法。

經過嵌套傳播手動深拷貝

若是嵌套傳播,則會獲得深層副本:

1const original = {name: 'Jane', work: {employer: 'Acme'}};
2const copy = {name: original.name, work: {...original.work}};
3
4// We copied successfully:
5assert.deepEqual(original, copy);
6// The copy is deep:
7assert.ok(original.work !== copy.work);

Hack:經過 JSON 進行通用深拷貝

儘管這是一個 hack,可是在緊要關頭,它提供了一個快速的解決方案:爲了對 `original 對象進行深拷貝」,咱們首先將其轉換爲 JSON 字符串,而後再解析該它:

1function jsonDeepCopy(original) {
2  return JSON.parse(JSON.stringify(original));
3}
4const original = {name: 'Jane', work: {employer: 'Acme'}};
5const copy = jsonDeepCopy(original);
6assert.deepEqual(original, copy);

這種方法的主要缺點是,咱們只能複製具備 JSON 支持的鍵和值的屬性。

一些不受支持的鍵和值將被忽略:

1assert.deepEqual(
2  jsonDeepCopy({
3    [Symbol('a')]: 'abc',
4    b: function () {},
5    c: undefined,
6  }),
7  {} // empty object
8);

其餘致使的例外:

1assert.throws(
2  () => jsonDeepCopy({a: 123n}),
3  /^TypeError: Do not know how to serialize a BigInt$/);

實現通用深拷貝

能夠用如下函數進行通用深拷貝:

1function deepCopy(original) {
 2  if (Array.isArray(original)) {
 3    const copy = [];
 4    for (const [index, value] of original.entries()) {
 5      copy[index] = deepCopy(value);
 6    }
 7    return copy;
 8  } else if (typeof original === 'object' && original !== null) {
 9    const copy = {};
10    for (const [key, value] of Object.entries(original)) {
11      copy[key] = deepCopy(value);
12    }
13    return copy;
14  } else {
15    // Primitive value: atomic, no need to copy
16    return original;
17  }
18}

該函數處理三種狀況:

  • 若是 original 是一個數組,咱們建立一個新的 Array,並將 original 的元素複製到其中。
  • 若是 original 是一個對象,咱們將使用相似的方法。
  • 若是 original 是原始值,則無需執行任何操做。
    讓咱們嘗試一下deepCopy():
1const original = {a: 1, b: {c: 2, d: {e: 3}}};
 2const copy = deepCopy(original);
 3
 4// Are copy and original deeply equal?
 5assert.deepEqual(copy, original);
 6
 7// Did we really copy all levels
 8// (equal content, but different objects)?
 9assert.ok(copy     !== original);
10assert.ok(copy.b   !== original.b);
11assert.ok(copy.b.d !== original.b.d);

注意,deepCopy() 僅解決了一個擴展問題:淺拷貝。而其餘全部內容:不復制原型,僅部分複製特殊對象,忽略不可枚舉的屬性,忽略大多數屬性。

一般徹底徹底實現複製是不可能的:並不是全部數據的都是一棵樹,有時你並不須要全部屬性,等等。

更簡潔的 deepCopy() 版本

若是咱們使用 .map() 和 Object.fromEntries(),可使之前的 deepCopy() 實現更加簡潔:

1function deepCopy(original) {
 2  if (Array.isArray(original)) {
 3    return original.map(elem => deepCopy(elem));
 4  } else if (typeof original === 'object' && original !== null) {
 5    return Object.fromEntries(
 6      Object.entries(original)
 7        .map(([k, v]) => [k, deepCopy(v)]));
 8  } else {
 9    // Primitive value: atomic, no need to copy
10    return original;
11  }
12}

在類中實現深拷貝(高級)

一般使用兩種技術能夠實現類實例的深拷貝:

  • .clone() 方法
  • 複製構造函數
    .clone() 方法

該技術爲每一個類引入了一個方法 .clone(),其實例將被深拷貝。它返回 this 的深層副本。如下例子顯示了能夠克隆的三個類。

1class Point {
 2  constructor(x, y) {
 3    this.x = x;
 4    this.y = y;
 5  }
 6  clone() {
 7    return new Point(this.x, this.y);
 8  }
 9}
10class Color {
11  constructor(name) {
12    this.name = name;
13  }
14  clone() {
15    return new Color(this.name);
16  }
17}
18class ColorPoint extends Point {
19  constructor(x, y, color) {
20    super(x, y);
21    this.color = color;
22  }
23  clone() {
24    return new ColorPoint(
25      this.x, this.y, this.color.clone()); // (A)
26  }
27}

A 行展現了此技術的一個重要方面:複合實例屬性值也必須遞歸克隆。

靜態工廠方法

拷貝構造函數是用當前類的另外一個實例來設置當前實例的構造函數。拷貝構造函數在靜態語言(例如 C++ 和 Java)中很流行,你能夠在其中經過 static 重載(static 表示它在編譯時發生)提供構造函數的多個版本。

在 JavaScript 中,你能夠執行如下操做(但不是很優雅):

1class Point {
 2  constructor(...args) {
 3    if (args[0] instanceof Point) {
 4      // Copy constructor
 5      const [other] = args;
 6      this.x = other.x;
 7      this.y = other.y;
 8    } else {
 9      const [x, y] = args;
10      this.x = x;
11      this.y = y;
12    }
13  }
14}

這是使用方法:

1const original = new Point(-1, 4);
2const copy = new Point(original);
3assert.deepEqual(copy, original);

相反,靜態工廠方法在 JavaScript 中效果更好(static 意味着它們是類方法)。

在如下示例中,三個類 Point,Color 和 ColorPoint 分別具備靜態工廠方法 .from():

1class Point {
 2  constructor(x, y) {
 3    this.x = x;
 4    this.y = y;
 5  }
 6  static from(other) {
 7    return new Point(other.x, other.y);
 8  }
 9}
10class Color {
11  constructor(name) {
12    this.name = name;
13  }
14  static from(other) {
15    return new Color(other.name);
16  }
17}
18class ColorPoint extends Point {
19  constructor(x, y, color) {
20    super(x, y);
21    this.color = color;
22  }
23  static from(other) {
24    return new ColorPoint(
25      other.x, other.y, Color.from(other.color)); // (A)
26  }
27}

在 A 行中,咱們再次使用遞歸複製。

這是 ColorPoint.from() 的工做方式:

1const original = new ColorPoint(-1, 4, new Color('red'));
2const copy = ColorPoint.from(original);
3assert.deepEqual(copy, original);

拷貝如何幫助共享可變狀態?


只要咱們僅從共享狀態讀取,就不會有任何問題。在修改它以前,咱們須要經過複製(必要的深度)來「取消共享」。

防護性複製是一種在問題可能出現時始終進行復制的技術。其目的是確保當前實體(函數、類等)的安全:

  • 輸入:複製(潛在地)傳遞給咱們的共享數據,使咱們可使用該數據而不受外部實體的干擾。
  • 輸出:在將內部數據公開給外部方以前複製內部數據,意味着不會破壞咱們的內部活動。
    請注意,這些措施能夠保護咱們免受其餘各方的侵害,同時也能夠保護其餘各方免受咱們的侵害。

下一節說明兩種防護性複製。

複製共享輸入

請記住,在本文開頭的例子中,咱們遇到了麻煩,由於 logElements() 修改了其參數 arr:

1function logElements(arr) {
2  while (arr.length > 0) {
3    console.log(arr.shift());
4  }
5}

讓咱們在此函數中添加防護性複製:

1function logElements(arr) {
2  arr = [...arr]; // defensive copy
3  while (arr.length > 0) {
4    console.log(arr.shift());
5  }
6}

如今,若是在 main() 內部調用 logElements() 不會再引起問題:

1function main() {
 2  const arr = ['banana', 'orange', 'apple'];
 3
 4  console.log('Before sorting:');
 5  logElements(arr);
 6
 7  arr.sort(); // changes arr
 8
 9  console.log('After sorting:');
10  logElements(arr); // (A)
11}
12main();
13
14// Output:
15// 'Before sorting:'
16// 'banana'
17// 'orange'
18// 'apple'
19// 'After sorting:'
20// 'apple'
21// 'banana'
22// 'orange'

複製公開的內部數據

讓咱們從 StringBuilder 類開始,該類不會複製它公開的內部數據(A行):

1class StringBuilder {
 2  constructor() {
 3    this._data = [];
 4  }
 5  add(str) {
 6    this._data.push(str);
 7  }
 8  getParts() {
 9    // We expose internals without copying them:
10    return this._data; // (A)
11  }
12  toString() {
13    return this._data.join('');
14  }
15}

只要不使用 .getParts(),一切就能夠正常工做:

1const sb1 = new StringBuilder();
2sb1.add('Hello');
3sb1.add(' world!');
4assert.equal(sb1.toString(), 'Hello world!');

可是,若是更改了 .getParts() 的結果(A行),則 StringBuilder 會中止正常工做:

1const sb2 = new StringBuilder();
2sb2.add('Hello');
3sb2.add(' world!');
4sb2.getParts().length = 0; // (A)
5assert.equal(sb2.toString(), ''); // not OK

解決方案是在內部 ._data 被公開以前防護性地對它進行復制(A行):

1class StringBuilder {
 2  constructor() {
 3    this._data = [];
 4  }
 5  add(str) {
 6    this._data.push(str);
 7  }
 8  getParts() {
 9    // Copy defensively
10    return [...this._data]; // (A)
11  }
12  toString() {
13    return this._data.join('');
14  }
15}

如今,更改 .getParts() 的結果再也不干擾 sb 的操做:

1const sb = new StringBuilder();
2sb.add('Hello');
3sb.add(' world!');
4sb.getParts().length = 0;
5assert.equal(sb.toString(), 'Hello world!'); // OK

經過無損更新來避免數據改變

咱們將首先探討以破壞性方式和非破壞性方式更新數據之間的區別。而後將學習非破壞性更新如何避免數據改變。

背景:破壞性更新與非破壞性更新


咱們能夠區分兩種不一樣的數據更新方式:

  • 數據的破壞性更新使數據被改變,使數據自己具備所需的形式。
  • 數據的非破壞性更新建立具備所需格式的數據副本。
    後一種方法相似於先複製而後破壞性地更改它,但二者同時進行。

示例:以破壞性和非破壞性的方式更新對象

這就是咱們破壞性地設置對象的屬性 .city 的方式:

1const obj = {city: 'Berlin', country: 'Germany'};
2const key = 'city';
3obj[key] = 'Munich';
4assert.deepEqual(obj, {city: 'Munich', country: 'Germany'});

如下函數以非破壞性的方式更改屬性:

1function setObjectNonDestructively(obj, key, value) {
2  const updatedObj = {};
3  for (const [k, v] of Object.entries(obj)) {
4    updatedObj[k] = (k === key ? value : v);
5  }
6  return updatedObj;
7}

它的用法以下:

1const obj = {city: 'Berlin', country: 'Germany'};
2const updatedObj = setObjectNonDestructively(obj, 'city', 'Munich');
3assert.deepEqual(updatedObj, {city: 'Munich', country: 'Germany'});
4assert.deepEqual(obj, {city: 'Berlin', country: 'Germany'});

傳播使 setObjectNonDestructively() 更加簡潔:

1function setObjectNonDestructively(obj, key, value) {
2  return {...obj, [key]: value};
3}

注意:setObject NonDestructively() 的兩個版本都進行了較淺的更新。

示例:以破壞性和非破壞性的方式更新數組

如下是破壞性地設置數組元素的方式:

1const original = ['a', 'b', 'c', 'd', 'e'];
2original[2] = 'x';
3assert.deepEqual(original, ['a', 'b', 'x', 'd', 'e']);

非破壞性地更新數組要複雜得多。

1function setArrayNonDestructively(arr, index, value) {
 2  const updatedArr = [];
 3  for (const [i, v] of arr.entries()) {
 4    updatedArr.push(i === index ? value : v);
 5  }
 6  return updatedArr;
 7}
 8
 9const arr = ['a', 'b', 'c', 'd', 'e'];
10const updatedArr = setArrayNonDestructively(arr, 2, 'x');
11assert.deepEqual(updatedArr, ['a', 'b', 'x', 'd', 'e']);
12assert.deepEqual(arr, ['a', 'b', 'c', 'd', 'e']);

.slice() 和擴展使 setArrayNonDestructively() 更加簡潔:

1function setArrayNonDestructively(arr, index, value) {
2  return [
3  ...arr.slice(0, index), value, ...arr.slice(index+1)]
4}

注意:setArrayNonDestructively() 的兩個版本都進行了較淺的更新。

手動深度更新

到目前爲止,咱們只是淺層地更新了數據。讓咱們來解決深度更新。如下代碼顯示瞭如何手動執行此操做。咱們正在更改 name 和 employer。

1const original = {name: 'Jane', work: {employer: 'Acme'}};
 2const updatedOriginal = {
 3  ...original,
 4  name: 'John',
 5  work: {
 6    ...original.work,
 7    employer: 'Spectre'
 8  },
 9};
10
11assert.deepEqual(
12  original, {name: 'Jane', work: {employer: 'Acme'}});
13assert.deepEqual(
14  updatedOriginal, {name: 'John', work: {employer: 'Spectre'}});

實現通用深度更新

如下函數實現了通用的深度更新。

1function deepUpdate(original, keys, value) {
 2  if (keys.length === 0) {
 3    return value;
 4  }
 5  const currentKey = keys[0];
 6  if (Array.isArray(original)) {
 7    return original.map(
 8      (v, index) => index === currentKey
 9        ? deepUpdate(v, keys.slice(1), value) // (A)
10        : v); // (B)
11  } else if (typeof original === 'object' && original !== null) {
12    return Object.fromEntries(
13      Object.entries(original).map(
14        (keyValuePair) => {
15          const [k,v] = keyValuePair;
16          if (k === currentKey) {
17            return [k, deepUpdate(v, keys.slice(1), value)]; // (C)
18          } else {
19            return keyValuePair; // (D)
20          }
21        }));
22  } else {
23    // Primitive value
24    return original;
25  }
26}

若是咱們將 value 視爲要更新的樹的根,則 deepUpdate() 只會深度更改單個分支(A 和 C 行)。全部其餘分支均被淺複製(B 和 D 行)。

如下是使用 deepUpdate() 的樣子:

1const original = {name: 'Jane', work: {employer: 'Acme'}};
2
3const copy = deepUpdate(original, ['work', 'employer'], 'Spectre');
4assert.deepEqual(copy, {name: 'Jane', work: {employer: 'Spectre'}});
5assert.deepEqual(original, {name: 'Jane', work: {employer: 'Acme'}});

非破壞性更新如何幫助共享可變狀態?


使用非破壞性更新,共享數據將變得毫無問題,由於咱們永遠不會改變共享數據。(顯然,這隻有在各方都這樣作的狀況下才有效。)

有趣的是,複製數據變得很是簡單:

1const original = {city: 'Berlin', country: 'Germany'};
2const copy = original;

僅在必要時以及在咱們進行無損更改的狀況下,才進行 original 的實際複製。

經過使數據不變來防止數據改變

咱們能夠經過使共享數據不變來防止共享數據發生改變。接下來,咱們將研究 JavaScript 如何支持不變性。以後,討論不可變數據如何幫助共享可變狀態。

背景:JavaScript 中的不變性


JavaScript 具備三個級別的保護對象:

  • Preventing extensions 使得沒法向對象添加新屬性。可是,你仍然能夠刪除和更改屬性。

    • 方法: Object.preventExtensions(obj)
  • Sealing 能夠防止擴展,並使全部屬性都沒法配置(大約:您沒法再更改屬性的工做方式)。

  • 方法: Object.seal(obj)

  • Freezing 使對象的全部屬性不可寫後將其密封。也就是說,對象是不可擴展的,全部屬性都是隻讀的,沒法更改它。

    • 方法: Object.freeze(obj)
      有關更多信息,請參見 「Speaking JavaScript」【】。

鑑於咱們但願對象是徹底不變的,所以在本文中僅使用 Object.freeze()。

淺層凍結

Object.freeze(obj) 僅凍結 obj 及其屬性。它不會凍結那些屬性的值,例如:

1const teacher = {
 2  name: 'Edna Krabappel',
 3  students: ['Bart'],
 4};
 5Object.freeze(teacher);
 6
 7assert.throws(
 8  () => teacher.name = 'Elizabeth Hoover',
 9  /^TypeError: Cannot assign to read only property 'name'/);
10
11teacher.students.push('Lisa');
12assert.deepEqual(
13  teacher, {
14    name: 'Edna Krabappel',
15    students: ['Bart', 'Lisa'],
16  });

實現深度凍結

若是要深度凍結,則須要本身實現:

1function deepFreeze(value) {
 2  if (Array.isArray(value)) {
 3    for (const element of value) {
 4      deepFreeze(element);
 5    }
 6    Object.freeze(value);
 7  } else if (typeof value === 'object' && value !== null) {
 8    for (const v of Object.values(value)) {
 9      deepFreeze(v);
10    }
11    Object.freeze(value);
12  } else {
13    // Nothing to do: primitive values are already immutable
14  } 
15  return value;
16}

回顧上一節中的例子,咱們能夠檢查 deepFreeze() 是否真的凍結了:

1const teacher = {
 2  name: 'Edna Krabappel',
 3  students: ['Bart'],
 4};
 5deepFreeze(teacher);
 6
 7assert.throws(
 8  () => teacher.name = 'Elizabeth Hoover',
 9  /^TypeError: Cannot assign to read only property 'name'/);
10
11assert.throws(
12  () => teacher.students.push('Lisa'),
13  /^TypeError: Cannot add property 1, object is not extensible$/);

不可變包裝器(高級)


用不可變的包裝器包裝可變的集合並提供相同的 API,但沒有破壞性的操做。如今對於同一集合,咱們有兩個接口:一個是可變的,另外一個是不可變的。當咱們具備要安全的公開內部可變數據時,這頗有用。

接下來展現了 Maps 和 Arrays 的包裝器。它們都有如下限制:

  • 它們比較簡陋。爲了使它們適合實際中的使用,須要作更多的工做:更好的檢查,支持更多的方法等。
  • 他們是淺拷貝。

    map的不變包裝器

類 ImmutableMapWrapper 爲 map 生成包裝器:

1class ImmutableMapWrapper {
 2  constructor(map) {
 3    this._self = map;
 4  }
 5}
 6
 7// Only forward non-destructive methods to the wrapped Map:
 8for (const methodName of ['get', 'has', 'keys', 'size']) {
 9  ImmutableMapWrapper.prototype[methodName] = function (...args) {
10    return this._self[methodName](...args);
11  }
12}

這是 action 中的類:

1const map = new Map([[false, 'no'], [true, 'yes']]);
 2const wrapped = new ImmutableMapWrapper(map);
 3
 4// Non-destructive operations work as usual:
 5assert.equal(
 6  wrapped.get(true), 'yes');
 7assert.equal(
 8  wrapped.has(false), true);
 9assert.deepEqual(
10  [...wrapped.keys()], [false, true]);
11
12// Destructive operations are not available:
13assert.throws(
14  () => wrapped.set(false, 'never!'),
15  /^TypeError: wrapped.set is not a function$/);
16assert.throws(
17  () => wrapped.clear(),
18  /^TypeError: wrapped.clear is not a function$/);
19

數組的不可變包裝器

對於數組 arr,常規包裝是不夠的,由於咱們不只須要攔截方法調用,並且還須要攔截諸如 arr [1] = true 之類的屬性訪問。JavaScript proxies 使咱們可以執行這種操做:

1const RE_INDEX_PROP_KEY = /^[0-9]+$/;
 2const ALLOWED_PROPERTIES = new Set([
 3  'length', 'constructor', 'slice', 'concat']);
 4
 5function wrapArrayImmutably(arr) {
 6  const handler = {
 7    get(target, propKey, receiver) {
 8      // We assume that propKey is a string (not a symbol)
 9      if (RE_INDEX_PROP_KEY.test(propKey) // simplified check!
10        || ALLOWED_PROPERTIES.has(propKey)) {
11          return Reflect.get(target, propKey, receiver);
12      }
13      throw new TypeError(`Property "${propKey}" can’t be accessed`);
14    },
15    set(target, propKey, value, receiver) {
16      throw new TypeError('Setting is not allowed');
17    },
18    deleteProperty(target, propKey) {
19      throw new TypeError('Deleting is not allowed');
20    },
21  };
22  return new Proxy(arr, handler);
23}

讓咱們包裝一個數組:

1const arr = ['a', 'b', 'c'];
 2const wrapped = wrapArrayImmutably(arr);
 3
 4// Non-destructive operations are allowed:
 5assert.deepEqual(
 6  wrapped.slice(1), ['b', 'c']);
 7assert.equal(
 8  wrapped[1], 'b');
 9
10// Destructive operations are not allowed:
11assert.throws(
12  () => wrapped[1] = 'x',
13  /^TypeError: Setting is not allowed$/);
14assert.throws(
15  () => wrapped.shift(),
16  /^TypeError: Property "shift" can’t be accessed$/);

不變性如何幫助共享可變狀態?


若是數據是不可變的,則能夠共享數據而沒有任何風險。特別是無需防護性複製。

非破壞性更新是對不變數據的補充,使其與可變數據同樣通用,但沒有相關風險。

用於避免共享可變狀態的庫

有幾種可用於 JavaScript 的庫,它們支持對不可變數據進行無損更新。其中流行的兩種是:

  • Immutable.js 提供了不變(版本)的數據結構,例如 List,Map,Set 和 Stack。
  • Immer 還支持不可變性和非破壞性更新,但僅適用於普通對象和數組。

    Immutable.js


在其存儲庫中,Immutable.js 的描述爲:

用於 JavaScript 的不可變的持久數據集,可提升效率和簡便性。

Immutable.js 提供了不可變的數據結構,例如:

  • List
  • Map (不一樣於JavaScript的內置Map)
  • Set (不一樣於JavaScript的內置 Set)
  • Stack

  • 在如下示例中,咱們使用不可變的 Map:
1import {Map} from 'immutable/dist/immutable.es.js';
 2const map0 = Map([
 3  [false, 'no'],
 4  [true, 'yes'],
 5]);
 6
 7const map1 = map0.set(true, 'maybe'); // (A)
 8assert.ok(map1 !== map0); // (B)
 9assert.equal(map1.equals(map0), false);
10
11const map2 = map1.set(true, 'yes'); // (C)
12assert.ok(map2 !== map1);
13assert.ok(map2 !== map0);
14assert.equal(map2.equals(map0), true); // (D)

說明:

  • 在 A 行中,咱們新建立了一個 map0 的不一樣版本 map1,其中 true 映射到了 'maybe'。
  • 在 B 行中,咱們檢查更改是否爲非破壞性的。
  • 在 C 行中,咱們更新 map1,並撤消在 A 行中所作的更改。
  • 在 D 行中,咱們使用 Immutable 的內置 .equals() 方法來檢查是否確實撤消了更改。

    Immer


在其存儲庫中,Immer 庫 的描述爲:

經過更改當前狀態來建立下一個不可變狀態。

Immer 有助於非破壞性地更新(可能嵌套)普通對象和數組。也就是說,不涉及特殊的數據結構。

這是使用 Immer 的樣子:

1import {produce} from 'immer/dist/immer.module.js';
 2
 3const people = [
 4  {name: 'Jane', work: {employer: 'Acme'}},
 5];
 6
 7const modifiedPeople = produce(people, (draft) => {
 8  draft[0].work.employer = 'Cyberdyne';
 9  draft.push({name: 'John', work: {employer: 'Spectre'}});
10});
11
12assert.deepEqual(modifiedPeople, [
13  {name: 'Jane', work: {employer: 'Cyberdyne'}},
14  {name: 'John', work: {employer: 'Spectre'}},
15]);
16assert.deepEqual(people, [
17  {name: 'Jane', work: {employer: 'Acme'}},
18]);

原始數據存儲在 people 中。produce() 爲咱們提供了一個變量 draft。咱們假設這個變量是 people,並使用一般會進行破壞性更改的操做。Immer 攔截了這些操做。代替變異draft,它無損地改變 people。結果由 modifiedPeople 引用。它是一成不變的。

致謝

相關文章
相關標籤/搜索