共享可變狀態中出現的問題以及如何避免

作者:Dr. Axel Rauschmayer

翻譯:瘋狂的技術宅

原文:https://2ality.com/2019/10/sh...

未經允許嚴禁轉載

本文回答了以下問題:

  • 麼是共享可變狀態?
  • 爲什麼會出現問題?
  • 如何避免其問題?

標有“(高級)”的部分會更深入,如果你想更快地閱讀本文,可以跳過。


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

共享可變狀態的解釋如下:

  • 如果兩個或多個參與方可以更改相同的數據(變量,對象等),並且
  • 如果它們的生命週期重疊,

則可能會有一方修改會導致另一方無法正常工作的風險。以下是一個例子:

function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'

這裏有兩個獨立的部分:函數logElements()和函數main()。後者想要在對數組進行排序的前後都打印其內容。但是它到用了 logElements() ,會導致數組被清空。所以 main() 會在A行輸出一個空數組。

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

  • 通過複製數據避免共享
  • 通過無損更新來避免數據變動
  • 通過使數據不可變來防止數據變動

針對每一種方法,我們都會回到剛纔看到的示例並進行修復。

通過複製數據避免共享

在開始研究如何避免共享之前,我們需要看一下如何在 JavaScript 中複製數據。

淺拷貝與深拷貝

對於數據,有兩個可複製的“深度”:

  • 淺拷貝僅複製對象和數組的頂層條目。原始值和副本中的輸入值仍然相同。
  • 深拷貝還會複製條目值的條目。也就是說,它會完整遍歷樹,並複製所有節點。

不幸的是,JavaScript 僅內置了對淺拷貝的支持。如果需要深拷貝,則需要自己實現。

JavaScript 中的淺拷貝

讓我們看一下淺拷貝的幾種方法。

通過傳播複製普通對象和數組

我們可以擴展爲對象字面量擴展爲數組字面量進行復制:

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

但是傳播有幾個限制:

  • 不復制原型:
class MyClass {}

const original = new MyClass();
assert.equal(MyClass.prototype.isPrototypeOf(original), true);

const copy = {...original};
assert.equal(MyClass.prototype.isPrototypeOf(copy), false);
  • 正則表達式和日期之類的特殊對象有未複製的特殊“內部插槽”。
  • 僅複製自己的(非繼承)屬性。鑑於原型鏈的工作原理,這通常是最好的方法。但是你仍然需要意識到這一點。在以下示例中,copy 中沒有 original 的繼承屬性 .inheritedProp,因爲我們僅複製自己的屬性,而未保留原型。
const proto = { inheritedProp: 'a' };
const original = {__proto__: proto, ownProp: 'b' };
assert.equal(original.inheritedProp, 'a');
assert.equal(original.ownProp, 'b');

const copy = {...original};
assert.equal(copy.inheritedProp, undefined);
assert.equal(copy.ownProp, 'b');
  • 僅複製可枚舉的屬性。例如數組實例自己的屬性 .length 不可枚舉,也不能複製:
const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);

const copy = {...arr};
assert.equal({}.hasOwnProperty.call(copy, 'length'), false);
  • 與 property 的 attributes無關,它的副本始終是可寫和可配置的 data 屬性,例如:
const original = Object.defineProperties({}, {
  prop: {
    value: 1,
    writable: false,
    configurable: false,
    enumerable: true,
  },
});
assert.deepEqual(original, {prop: 1});

const copy = {...original};
// Attributes `writable` and `configurable` of copy are different:
assert.deepEqual(Object.getOwnPropertyDescriptors(copy), {
  prop: {
    value: 1,
    writable: true,
    configurable: true,
    enumerable: true,
  },
});

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

const original = {
  get myGetter() { return 123 },
  set mySetter(x) {},
};
assert.deepEqual({...original}, {
  myGetter: 123, // not a getter anymore!
  mySetter: undefined,
});
  • 拷貝很淺:該副本具有原始版本中每個鍵值條目的新版本,但是原始值本身不會被複制。 例如:
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {...original};

// Property .name is a copy
copy.name = 'John';
assert.deepEqual(original,
  {name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(copy,
  {name: 'John', work: {employer: 'Acme'}});

// The value of .work is shared
copy.work.employer = 'Spectre';
assert.deepEqual(
  original, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(
  copy, {name: 'John', work: {employer: 'Spectre'}});

這些限制有的可以消除,而其他則不能:

  • 我們可以在拷貝過程中爲副本提供與原始原型相同的原型:
class MyClass {}

const original = new MyClass();

const copy = {
  __proto__: Object.getPrototypeOf(original),
  ...original,
};
assert.equal(MyClass.prototype.isPrototypeOf(copy), true);

另外,我們可以在副本創建後通過 Object.setPrototypeOf() 設置原型。

  • 沒有簡單的方法可以通用地複製特殊對象。
  • 如前所述,僅複製自己的屬性是功能而非限制。
  • 我們可以用 Object.getOwnPropertyDescriptors()Object.defineProperties() 複製對象(操作方法稍後說明):

    • 他們考慮了所有屬性(而不僅僅是 value),因此正確地複製了getters,setters,只讀屬性等。
    • Object.getOwnPropertyDescriptors() 檢索可枚舉和不可枚舉的屬性。
  • 我們將在本文後面的內容中介紹深拷貝。

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

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

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

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

不過 Object.assign() 並不完全像傳播。它在一個相對微妙的方面有所不同:它以不同的方式創建屬性。

  • Object.assign() 使用 assignment 創建副本的屬性。
  • 傳播定義副本中的新屬性。

除其他事項外,assignment 會調用自己的和繼承的設置器,而 definition 不會(關於 assignment 與 definition 的更多信息)。這種差異很少引起注意。以下代碼是一個例子,但它是人爲設計的:

const original = {['__proto__']: null};
const copy1 = {...original};
// copy1 has the own property '__proto__'
assert.deepEqual(
  Object.keys(copy1), ['__proto__']);

const copy2 = Object.assign({}, original);
// copy2 has the prototype null
assert.equal(Object.getPrototypeOf(copy2), null);

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

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

function copyAllOwnProperties(original) {
  return Object.defineProperties(
    {}, Object.getOwnPropertyDescriptors(original));
}

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

首先,能夠正確複製自己 property 的所有 attribute。我們現在可以複製自己的 getter 和 setter:

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

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

const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);

const copy = copyAllOwnProperties(arr);
assert.equal({}.hasOwnProperty.call(copy, 'length'), true);

JavaScript 的深拷貝

現在該解決深拷貝了。首先我們將手動進行深拷貝,然後再研究通用方法。

通過嵌套傳播手動深拷貝

如果嵌套傳播,則會得到深層副本:

const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {name: original.name, work: {...original.work}};

// We copied successfully:
assert.deepEqual(original, copy);
// The copy is deep:
assert.ok(original.work !== copy.work);

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

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

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

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

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

assert.deepEqual(
  jsonDeepCopy({
    [Symbol('a')]: 'abc',
    b: function () {},
    c: undefined,
  }),
  {} // empty object
);

其他導致的例外:

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

實現通用深拷貝

可以用以下函數進行通用深拷貝:

function deepCopy(original) {
  if (Array.isArray(original)) {
    const copy = [];
    for (const [index, value] of original.entries()) {
      copy[index] = deepCopy(value);
    }
    return copy;
  } else if (typeof original === 'object' && original !== null) {
    const copy = {};
    for (const [key, value] of Object.entries(original)) {
      copy[key] = deepCopy(value);
    }
    return copy;
  } else {
    // Primitive value: atomic, no need to copy
    return original;
  }
}

該函數處理三種情況:

  • 如果 original 是一個數組,我們創建一個新的 Array,並將 original 的元素複製到其中。
  • 如果 original 是一個對象,我們將使用類似的方法。
  • 如果 original 是原始值,則無需執行任何操作。

讓我們嘗試一下deepCopy()

const original = {a: 1, b: {c: 2, d: {e: 3}}};
const copy = deepCopy(original);

// Are copy and original deeply equal?
assert.deepEqual(copy, original);

// Did we really copy all levels
// (equal content, but different objects)?
assert.ok(copy     !== original);
assert.ok(copy.b   !== original.b);
assert.ok(copy.b.d !== original.b.d);

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

通常完全完全實現複製是不可能的:並非所有數據的都是一棵樹,有時你並不需要所有屬性,等等。

更簡潔的 deepCopy() 版本

如果我們使用 .map()Object.fromEntries(),可以使以前的 deepCopy() 實現更加簡潔:

function deepCopy(original) {
  if (Array.isArray(original)) {
    return original.map(elem => deepCopy(elem));
  } else if (typeof original === 'object' && original !== null) {
    return Object.fromEntries(
      Object.entries(original)
        .map(([k, v]) => [k, deepCopy(v)]));
  } else {
    // Primitive value: atomic, no need to copy
    return original;
  }
}

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

通常使用兩種技術可以實現類實例的深拷貝:

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

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

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  clone() {
    return new Point(this.x, this.y);
  }
}
class Color {
  constructor(name) {
    this.name = name;
  }
  clone() {
    return new Color(this.name);
  }
}
class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y);
    this.color = color;
  }
  clone() {
    return new ColorPoint(
      this.x, this.y, this.color.clone()); // (A)
  }
}

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

靜態工廠方法

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

在 JavaScript 中,你可以執行以下操作(但不是很優雅):

class Point {
  constructor(...args) {
    if (args[0] instanceof Point) {
      // Copy constructor
      const [other] = args;
      this.x = other.x;
      this.y = other.y;
    } else {
      const [x, y] = args;
      this.x = x;
      this.y = y;
    }
  }
}

這是使用方法:

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

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

在以下示例中,三個類 PointColorColorPoint 分別具有靜態工廠方法 .from()

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  static from(other) {
    return new Point(other.x, other.y);
  }
}
class Color {
  constructor(name) {
    this.name = name;
  }
  static from(other) {
    return new Color(other.name);
  }
}
class ColorPoint extends Point {
  constructor(x, y, color) {
    super(x, y);
    this.color = color;
  }
  static from(other) {
    return new ColorPoint(
      other.x, other.y, Color.from(other.color)); // (A)
  }
}

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

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

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

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

只要我們僅從共享狀態讀取,就不會有任何問題。在修改它之前,我們需要通過複製(必要的深度)來“取消共享”。

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

  • 輸入:複製(潛在地)傳遞給我們的共享數據,使我們可以使用該數據而不受外部實體的干擾。
  • 輸出:在將內部數據公開給外部方之前複製內部數據,意味着不會破壞我們的內部活動。

請注意,這些措施可以保護我們免受其他各方的侵害,同時也可以保護其他各方免受我們的侵害。

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

複製共享輸入

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

function logElements(arr) {
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

讓我們在此函數中添加防禦性複製:

function logElements(arr) {
  arr = [...arr]; // defensive copy
  while (arr.length > 0) {
    console.log(arr.shift());
  }
}

現在,如果在 main() 內部調用 logElements() 不會再引發問題:

function main() {
  const arr = ['banana', 'orange', 'apple'];

  console.log('Before sorting:');
  logElements(arr);

  arr.sort(); // changes arr

  console.log('After sorting:');
  logElements(arr); // (A)
}
main();

// Output:
// 'Before sorting:'
// 'banana'
// 'orange'
// 'apple'
// 'After sorting:'
// 'apple'
// 'banana'
// 'orange'

複製公開的內部數據

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

class StringBuilder {
  constructor() {
    this._data = [];
  }
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // We expose internals without copying them:
    return this._data; // (A)
  }
  toString() {
    return this._data.join('');
  }
}

只要不使用 .getParts(),一切就可以正常工作:

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

但是,如果更改了 .getParts() 的結果(A行),則 StringBuilder 會停止正常工作:

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

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

class StringBuilder {
  constructor() {
    this._data = [];
  }
  add(str) {
    this._data.push(str);
  }
  getParts() {
    // Copy defensively
    return [...this._data]; // (A)
  }
  toString() {
    return this._data.join('');
  }
}

現在,更改 .getParts() 的結果不再幹擾 sb 的操作:

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

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

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

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

我們可以區分兩種不同的數據更新方式:

  • 數據的破壞性更新使數據被改變,使數據本身具有所需的形式。
  • 數據的非破壞性更新創建具有所需格式的數據副本。

後一種方法類似於先複製然後破壞性地更改它,但兩者同時進行。

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

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

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

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

function setObjectNonDestructively(obj, key, value) {
  const updatedObj = {};
  for (const [k, v] of Object.entries(obj)) {
    updatedObj[k] = (k === key ? value : v);
  }
  return updatedObj;
}

它的用法如下:

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

傳播使 setObjectNonDestructively() 更加簡潔:

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

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

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

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

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

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

function setArrayNonDestructively(arr, index, value) {
  const updatedArr = [];
  for (const [i, v] of arr.entries()) {
    updatedArr.push(i === index ? value : v);
  }
  return updatedArr;
}

const arr = ['a', 'b', 'c', 'd', 'e'];
const updatedArr = setArrayNonDestructively(arr, 2, 'x');
assert.deepEqual(updatedArr, ['a', 'b', 'x', 'd', 'e']);
assert.deepEqual(arr, ['a', 'b', 'c', 'd', 'e']);

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

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

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

手動深度更新

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

const original = {name: 'Jane', work: {employer: 'Acme'}};
const updatedOriginal = {
  ...original,
  name: 'John',
  work: {
    ...original.work,
    employer: 'Spectre'
  },
};

assert.deepEqual(
  original, {name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(
  updatedOriginal, {name: 'John', work: {employer: 'Spectre'}});

實現通用深度更新

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

function deepUpdate(original, keys, value) {
  if (keys.length === 0) {
    return value;
  }
  const currentKey = keys[0];
  if (Array.isArray(original)) {
    return original.map(
      (v, index) => index === currentKey
        ? deepUpdate(v, keys.slice(1), value) // (A)
        : v); // (B)
  } else if (typeof original === 'object' && original !== null) {
    return Object.fromEntries(
      Object.entries(original).map(
        (keyValuePair) => {
          const [k,v] = keyValuePair;
          if (k === currentKey) {
            return [k, deepUpdate(v, keys.slice(1), value)]; // (C)
          } else {
            return keyValuePair; // (D)
          }
        }));
  } else {
    // Primitive value
    return original;
  }
}

如果我們將 value 視爲要更新的樹的根,則 deepUpdate() 只會深度更改單個分支(A 和 C 行)。所有其他分支均被淺複製(B 和 D 行)。

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

const original = {name: 'Jane', work: {employer: 'Acme'}};

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

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

使用非破壞性更新,共享數據將變得毫無問題,因爲我們永遠不會改變共享數據。 (顯然,這只有在各方都這樣做的情況下才有效。)

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

const original = {city: 'Berlin', country: 'Germany'};
const 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 及其屬性。它不會凍結那些屬性的值,例如:

const teacher = {
  name: 'Edna Krabappel',
  students: ['Bart'],
};
Object.freeze(teacher);

assert.throws(
  () => teacher.name = 'Elizabeth Hoover',
  /^TypeError: Cannot assign to read only property 'name'/);

teacher.students.push('Lisa');
assert.deepEqual(
  teacher, {
    name: 'Edna Krabappel',
    students: ['Bart', 'Lisa'],
  });

實現深度凍結

如果要深度凍結,則需要自己實現:

function deepFreeze(value) {
  if (Array.isArray(value)) {
    for (const element of value) {
      deepFreeze(element);
    }
    Object.freeze(value);
  } else if (typeof value === 'object' && value !== null) {
    for (const v of Object.values(value)) {
      deepFreeze(v);
    }
    Object.freeze(value);
  } else {
    // Nothing to do: primitive values are already immutable
  } 
  return value;
}

回顧上一節中的例子,我們可以檢查 deepFreeze() 是否真的凍結了:

const teacher = {
  name: 'Edna Krabappel',
  students: ['Bart'],
};
deepFreeze(teacher);

assert.throws(
  () => teacher.name = 'Elizabeth Hoover',
  /^TypeError: Cannot assign to read only property 'name'/);

assert.throws(
  () => teacher.students.push('Lisa'),
  /^TypeError: Cannot add property 1, object is not extensible$/);

不可變包裝器(高級)

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

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

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

map的不變包裝器

ImmutableMapWrapper 爲 map 生成包裝器:

class ImmutableMapWrapper {
  constructor(map) {
    this._self = map;
  }
}

// Only forward non-destructive methods to the wrapped Map:
for (const methodName of ['get', 'has', 'keys', 'size']) {
  ImmutableMapWrapper.prototype[methodName] = function (...args) {
    return this._self[methodName](...args);
  }
}

這是 action 中的類:

const map = new Map([[false, 'no'], [true, 'yes']]);
const wrapped = new ImmutableMapWrapper(map);

// Non-destructive operations work as usual:
assert.equal(
  wrapped.get(true), 'yes');
assert.equal(
  wrapped.has(false), true);
assert.deepEqual(
  [...wrapped.keys()], [false, true]);

// Destructive operations are not available:
assert.throws(
  () => wrapped.set(false, 'never!'),
  /^TypeError: wrapped.set is not a function$/);
assert.throws(
  () => wrapped.clear(),
  /^TypeError: wrapped.clear is not a function$/);

數組的不可變包裝器

對於數組 arr,常規包裝是不夠的,因爲我們不僅需要攔截方法調用,而且還需要攔截諸如 arr [1] = true 之類的屬性訪問。 JavaScript proxies 使我們能夠執行這種操作:

const RE_INDEX_PROP_KEY = /^[0-9]+$/;
const ALLOWED_PROPERTIES = new Set([
  'length', 'constructor', 'slice', 'concat']);

function wrapArrayImmutably(arr) {
  const handler = {
    get(target, propKey, receiver) {
      // We assume that propKey is a string (not a symbol)
      if (RE_INDEX_PROP_KEY.test(propKey) // simplified check!
        || ALLOWED_PROPERTIES.has(propKey)) {
          return Reflect.get(target, propKey, receiver);
      }
      throw new TypeError(`Property "${propKey}" can’t be accessed`);
    },
    set(target, propKey, value, receiver) {
      throw new TypeError('Setting is not allowed');
    },
    deleteProperty(target, propKey) {
      throw new TypeError('Deleting is not allowed');
    },
  };
  return new Proxy(arr, handler);
}

讓我們包裝一個數組:

const arr = ['a', 'b', 'c'];
const wrapped = wrapArrayImmutably(arr);

// Non-destructive operations are allowed:
assert.deepEqual(
  wrapped.slice(1), ['b', 'c']);
assert.equal(
  wrapped[1], 'b');

// Destructive operations are not allowed:
assert.throws(
  () => wrapped[1] = 'x',
  /^TypeError: Setting is not allowed$/);
assert.throws(
  () => wrapped.shift(),
  /^TypeError: Property "shift" can’t be accessed$/);

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

如果數據是不可變的,則可以共享數據而沒有任何風險。特別是無需防禦性複製。

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

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

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

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

Immutable.js

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

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

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

  • List
  • Map (不同於JavaScript的內置Map
  • Set (不同於JavaScript的內置 Set
  • Stack

在以下示例中,我們使用不可變的 Map

import {Map} from 'immutable/dist/immutable.es.js';
const map0 = Map([
  [false, 'no'],
  [true, 'yes'],
]);

const map1 = map0.set(true, 'maybe'); // (A)
assert.ok(map1 !== map0); // (B)
assert.equal(map1.equals(map0), false);

const map2 = map1.set(true, 'yes'); // (C)
assert.ok(map2 !== map1);
assert.ok(map2 !== map0);
assert.equal(map2.equals(map0), true); // (D)

說明:

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

Immer

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

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

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

這是使用 Immer 的樣子:

import {produce} from 'immer/dist/immer.module.js';

const people = [
  {name: 'Jane', work: {employer: 'Acme'}},
];

const modifiedPeople = produce(people, (draft) => {
  draft[0].work.employer = 'Cyberdyne';
  draft.push({name: 'John', work: {employer: 'Spectre'}});
});

assert.deepEqual(modifiedPeople, [
  {name: 'Jane', work: {employer: 'Cyberdyne'}},
  {name: 'John', work: {employer: 'Spectre'}},
]);
assert.deepEqual(people, [
  {name: 'Jane', work: {employer: 'Acme'}},
]);

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

致謝

  • Ron Korvig 提醒我在 JavaScript 中進行深拷貝時使用靜態工廠方法,而不要重載構造函數。

擴展閱讀


本文首發微信公衆號:前端先鋒

歡迎掃描二維碼關注公衆號,每天都給你推送新鮮的前端技術文章

歡迎掃描二維碼關注公衆號,每天都給你推送新鮮的前端技術文章

歡迎繼續閱讀本專欄其它高贊文章:


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章