深入 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 提供不可变数据结构,例如

在以下示例中,我们使用不可变的 Map

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 返回普通对象和数组。