深入探討 JavaScript
請支持這本書:購買捐款
(廣告,請不要封鎖。)

8 共享可變狀態的問題以及如何避免它們



本章節將回答以下問題

8.1 什麼是共享可變狀態,為什麼它會造成問題?

共享可變狀態的運作方式如下

請注意,這個定義適用於函式呼叫、合作式多工處理(例如 JavaScript 中的非同步函式)等。每種情況中的風險都類似。

以下程式碼是一個範例。這個範例並非實際情況,但它示範了風險,而且容易理解

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(),並導致它在 A 行記錄一個空的陣列。

在本章節的其餘部分,我們將探討三種避免共享可變狀態問題的方法

特別是,我們將回顧剛才看過的範例並修正它。

8.2 透過複製資料來避免共用

複製資料是一種避免共用的方式。

  背景

有關在 JavaScript 中複製資料的背景,請參閱本書中的以下兩個章節

8.2.1 複製如何協助處理共用可變狀態?

只要我們只從共用狀態讀取,我們就沒有任何問題。在修改它之前,我們需要透過複製它(深度複製到必要的程度)來「取消共用」。

防禦性複製是一種技術,它會在可能出現問題時進行複製。它的目標是保持目前的實體(函式、類別等)安全

請注意,這些措施可以保護我們免於其他外部實體的影響,但也可以保護其他外部實體免於我們的影響。

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

8.2.1.1 複製共用輸入

請記住,在本章節開頭的激勵範例中,我們遇到問題是因為 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'
8.2.1.2 複製公開的內部資料

讓我們從一個 StringBuilder 類別開始,它不會複製它公開的內部資料(A 行)

class StringBuilder {
  _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 {
  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

8.3 透過非破壞性更新來避免突變

如果我們只非破壞性地更新資料,我們就可以避免突變。

  背景

有關更新資料的詳細資訊,請參閱 §7「破壞性地和非破壞性地更新資料」

8.3.1 非破壞性更新如何協助處理共用可變狀態?

透過非破壞性更新,共用資料變得沒有問題,因為我們從未突變共用資料。(這只有在每個存取資料的人員都這麼做時才有效!)

有趣的是,複製資料變得非常簡單

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

這是有效的,因為我們只進行非破壞性變更,因此會依需求複製資料。

8.4 透過讓資料不可變更來防止變異

我們可以透過讓資料不可變更來防止共用資料的變異。

  背景

有關如何在 JavaScript 中讓資料不可變更的背景知識,請參閱本書中的以下兩章

8.4.1 不可變更如何協助共用可變更狀態?

如果資料不可變更,則可以毫無風險地共用。特別是,不需要防禦性地複製。

  非破壞性更新是不可變資料的重要補充

如果我們結合這兩者,不可變資料在實際上會變得跟可變資料一樣多功能,但沒有相關風險。

8.5 避免共用可變更狀態的函式庫

有許多函式庫可供 JavaScript 使用,這些函式庫支援不可變資料與非破壞性更新。兩個熱門的函式庫為

這些函式庫會在接下來的兩個區段中更詳細地說明。

8.5.1 Immutable.js

函式庫 Immutable.js 的儲存庫中,它被描述為

JavaScript 的不可變持續資料集合,可提升效率和簡潔性。

Immutable.js 提供不可變資料結構,例如

在以下範例中,我們使用不可變的 對應

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

// We create a modified version of map0:
const map1 = map0.set(true, 'maybe');

// The modified version is different from the original:
assert.ok(map1 !== map0);
assert.equal(map1.equals(map0), false); // (A)

// We undo the change we just made:
const map2 = map1.set(true, 'yes');

// map2 is a different object than map0,
// but it has the same content
assert.ok(map2 !== map0);
assert.equal(map2.equals(map0), true); // (B)

備註

8.5.2 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 參照。另外,它具有深度不可變性。

assert.deepEqual() 會運作,因為 Immer 會傳回純粹物件和陣列。