Javascript 共享可变状态的问题及规避方案

1,230 阅读10分钟

这篇博文回答了以下问题:

  • 什么是共享可变状态?
  • 为什么会有问题?
  • 如何避免它的问题? 标记为“(advance)”的部分更深入,如果你想更快地阅读这篇博客文章,可以跳过它。

主要内容

  1. 什么是共享可变状态,为什么会有问题?
  2. 避免通过复制数据来共享
    • 浅拷贝vs.深拷贝
    • JavaScript中的浅拷贝
    • JavaScript深度复制
    • 复制如何帮助共享可变状态?
  3. 通过非破坏性更新来避免突变
    • 背景:破坏性更新与非破坏性更新
    • 非破坏性更新如何帮助共享可变状态?
  4. 通过使数据不可变来防止突变
    • 背景:JavaScript的不变性
    • 不可改变的包装器(advance)
    • 不变性如何帮助共享可变状态?
  5. 避免共享可变状态的库
    • Immutable.js
    • Immer
  6. 鸣谢
  7. 进一步的阅读

1 什么是共享可变状态,为什么会有问题?

共享可变状态工作如下:

  • 如果两个或多个部分可以更改相同的数据(变量、对象等)
  • 如果他们的生命周期重叠, 然后,存在这种情况,一方的修改妨碍另一方正确工作的风险。这是一个例子:
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行记录一个空数组。

在这篇文章的其余部分,我们将讨论三种避免共享可变状态问题的方法:

  • 避免通过复制数据来共享

  • 通过非破坏性更新来避免突变

  • 通过使数据不可变来防止突变

接下来,我们将回到我们刚刚看到的例子并修正它。

2 避免通过复制数据来共享

在讨论复制如何避免共享之前,我们需要看看如何在JavaScript中复制数据。

2.1 浅拷贝vs.深拷贝

数据复制有两个“深度”:

  • 浅复制只复制对象和数组的顶级条目。条目值在原始和复制时仍然相同。
  • 深度复制也复制条目的值,不同的是,它会从根节点遍历完整的树,并复制所有节点。

下一节将介绍这两种复制。不幸的是,JavaScript只内置了对浅拷贝的支持。如果我们需要深度复制,我们需要自己实现它。

2.2 JavaScript中的浅拷贝

让我们来看看几种简单复制数据的方法。

2.2.1 通过扩展复制普通对象和数组

我们可以扩展到对象文字和数组文字复制:

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);
  • 特殊的对象,如正则表达式和日期,具有特殊属性的“内部插槽”,不会被复制

  • 只复制自己的(非继承的)属性。考虑到原型链是如何工作的,这通常是最好的方法。但你仍然需要意识到这一点。在下面的示例中,original的继承属性. inheritedprop在copy中不可用,因为我们只复制自己的属性,不保留原型。

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);
  • 独立于属性的属性,它的副本将始终是一个可写和可配置的数据属性-例如:
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也不会被忠实地复制:属性值(用于数据属性)、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()来复制对象(后面将解释如何这样做):
    • 它们考虑所有属性(不仅仅是值),因此正确地复制getter、setter、只读属性等。
    • Object.getownpropertydescriptors()既检索可枚举的属性,也检索不可枚举的属性。
  • 我们将在这篇文章的后面讨论深度复制。

2.2.2 通过Object.assign()进行浅复制(advance)

assign()的工作方式大多类似于将对象扩展到对象中。也就是说,以下两种复制方式基本相同:

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

使用方法而不是语法的好处是,它可以通过库在旧的JavaScript引擎上填充。

不过,Object.assign()并不完全像spread那样。它有一个比较微妙的不同点:它以不同的方式创建属性。

  • assign()使用assign创建副本的属性。
  • 扩展定义了拷贝中的新属性。

在其他方面,赋值(assign)调用自己的和继承的setter,而定义(这里指的扩展)不调用(关于赋值与定义的更多信息)。这种差异很少被注意到。下面的代码是一个例子,但它是人为的:

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);

2.2.3 通过Object.getOwnPropertyDescriptors()和Object.defineProperties()(advance)进行浅复制

JavaScript允许我们通过属性描述符创建属性,即指定属性属性的对象。例如,通过Object.defineProperties(),我们已经在实际中看到了它。如果我们把这个方法和Object.getOwnPropertyDescriptors()结合起来,我们可以更忠实地复制:

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

这消除了通过扩展复制对象的两个限制。

首先,正确复制自己属性的所有属性。因此,我们现在可以复制自己的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);

2.3 JavaScript深拷贝

现在是时候解决深层复制了。首先,我们将手动深拷贝,然后我们将检查通用方法。

2.3.1 通过嵌套扩展手动深度复制

如果我们嵌套扩展,我们得到深层拷贝:

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 进行通用深度复制 这是一个骇客方法,但在紧要关头,它提供了一个快速解决方案:为了深度复制一个对象的原始,我们首先把它转换成一个JSON字符串和解析那个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$/);

2.3.3 实现通用深度复制

下面的函数一般深拷贝一个原始的值:

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是一个数组,我们创建一个新的数组,并深拷贝原始的元素到它。
  • 如果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;
  }
}

2.3.4 在类中实现深度复制(advance)

通常使用两种技术来实现类实例的深度复制:

  • .clone()方法
  • 复制构造函数

.clone() methods

该技术为每个要深度复制其实例的类引入了一个.clone()方法。它会返回一个深度副本。下面的示例显示了可以克隆的三个类。

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)中很流行,在这些语言中,可以通过静态重载(静态意味着在编译时发生)提供构造函数的多个版本。

在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中工作得更好(静态意味着它们是类方法)。

在下面的例子中,三个类Point, Color和ColorPoint都有一个静态的工厂方法.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);

2.4 复制如何帮助共享可变状态?

只要我们只从共享状态读取,就不会有任何问题。在我们修改它之前,我们需要“取消共享”它,通过复制它(尽可能深入)。

防御性复制是一种在可能出现问题时进行复制的技术。它的目标是保持当前实体(函数、类等)的安全

  • 输入:复制(可能的)传递给我们的共享数据,让我们在不受外部实体干扰的情况下使用这些数据。
  • 输出:在将内部数据暴露给外部方之前复制它,意味着该方不能破坏我们的内部活动。

请注意,这些措施保护我们不受其他方的侵害,但它们也保护其他方不受我们的侵害。

下一节将演示这两种防御性复制。

2.4.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());
  }
}

现在logElements()不再引起问题,如果它是调用main():

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'

2.4.2 复制公开的内部数据

让我们从一个不复制其公开的内部数据的类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

3 通过非破坏性地更新来避免突变

我们将首先探讨破坏性数据更新和非破坏性数据更新的区别。然后我们将学习非破坏性更新如何避免突变。

3.1 背景:破坏性更新与非破坏性更新

我们可以区分两种不同的数据更新方式:

  • 数据的破坏性更新会使数据发生变化,从而产生所需的表单。
  • 数据的非破坏性更新将创建具有所需表单的数据的副本。 后一种方法类似于首先复制一个副本,然后破坏性地更改它,但这两种方法同时进行。

3.1.1 示例:破坏性地和非破坏性地更新对象

这是我们如何破坏性地设置一个对象的属性.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'});

扩展使setobjectnondestrucative()更简洁:

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

注意:两个版本的setobjectnondestrucative()更新都很浅。

3.1.2 示例:破坏性地和非破坏性地更新数组

这是我们如何破坏性地设置一个数组的元素:

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()和spread使setarraynondestructive()更简洁:

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

注意:setarraynondestrucsive()的两个版本更新都很浅。

3.1.3 手动深更新

到目前为止,我们只是粗略地更新了数据。让我们来解决深层更新。下面的代码演示了如何手动执行此操作。我们正在更改姓名和雇主。

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'}});

3.1.4 实现通用深度更新

下面的函数实现了通用的深度更新。

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;
  }
}

如果我们将值视为正在更新的树的根,那么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'}});

3.2 非破坏性更新如何帮助共享可变状态?

使用非破坏性更新,共享数据就变得不成问题,因为我们从不改变共享数据。(显然,这只有在各方都这么做的情况下才有效。)

有趣的是,复制数据变得非常简单:

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

只有在必要的情况下,并且我们正在进行非破坏性的更改时,才会实际复制原件。

4 通过使数据不可变来防止突变

我们可以通过使数据不可变来防止共享数据的突变。接下来,我们将研究JavaScript如何支持不变性。然后,我们将讨论不可变数据如何帮助共享可变状态。

4.1 背景:JavaScript的不变性

JavaScript有三层保护对象:

  • 防止扩展使得向对象添加新属性变得不可能。不过,您仍然可以删除和更改属性。
    • 方法:Object.preventExtensions (obj)
  • 密封可以防止扩展,并使所有属性不可配置(大致:您不能再更改属性的工作方式)。
    • 方法:Object.seal (obj)
  • 冻结一个对象后,使其所有属性不可写。也就是说,对象是不可扩展的,所有属性都是只读的,没有办法改变。
    • 方法:Object.freeze (obj)

鉴于我们希望我们的对象是完全不可变的,我们在这篇博客文章中只使用Object.freeze()。

4.1.1 冻结很浅

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'],
  });

4.1.2 实现深冻结

如果我们想要深度冻结,我们需要自己来实施:

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$/);

4.2 不可改变的包装器(advance)

不可变包装器包装可变集合并提供相同的API,但没有破坏性操作。现在,对于同一个集合,我们有两个接口:一个是可变的,另一个是不可变的。当我们需要安全地公开可变的内部数据时,这是非常有用的。

接下来的两个部分将展示映射和数组的包装器。两者都有以下局限性:

  • 他们是草图。需要做更多的工作来使它们适合于实际应用:更好的检查,支持更多的方法,等等。
  • 他们浅浅地工作。

4.2.1 映射的不可变包装器

类 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);
  }
}

示例如下:

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$/);

4.2.2 数组的不可变包装器

对于数组arr,普通的包装是不够的,因为我们不仅需要拦截方法调用,还需要拦截属性访问,比如arr[1] = true。JavaScript代理使我们能够做到这一点:

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$/);

4.3 不变性如何帮助共享可变状态?

如果数据是不可变的,那么可以毫无风险地共享它。特别是,没有必要采取防御性的复制。

非破坏性的更新补充了不可变数据,并使其与可变数据一样多用途,但没有相关的风险。

5 避免共享可变状态的库

JavaScript有几个库可以使用,它们支持具有非破坏性更新的不可变数据。两个流行的是:

  • Immutable.js提供了不可变的(版本)数据结构,如列表、映射、设置和堆栈。
  • Immer还支持不变性和非破坏性更新,但只支持普通对象和数组。 这些库将在接下来的两个小节中进行更详细的描述。

5.1 Immutable.js

在其存储库中,Immutable.js被描述为:

用于JavaScript的不可变的持久数据收集,提高了效率和简单性。

js提供了不可变的数据结构,如:

  • 列表
  • Map(与JavaScript的内置Map不同)
  • Set(与JavaScript的内置Set不同)
  • 堆栈
  • 其他。

在下面的例子中,我们使用一个不可变的映射:

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行,我们使用了不可变的内置.equals()方法来检查我们是否真的取消了更改

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()为我们提供了一个可变的草稿。我们假设这个变量是people,并使用通常用于进行破坏性更改的操作。Immer拦截了这些行动。而不是突变草稿,它无损地改变people。结果被修改过的people引用。生成modifiedPeople,它是不可改变的。

6 鸣谢

Ron Korvig提醒我使用静态工厂方法,而不是重载构造函数来进行JavaScript的深度复制。

7 进一步阅读

  • 结构赋值(也是说扩展赋值): 《JavaScript for impatient programmers》 “Spreading into object literals” ,“Spreading into Array literals” exploringjs.com/impatient-j…

  • 属性: 《Speaking JavaScript》“Property Attributes and Property Descriptors” “Protecting Objects” speakingjs.com/es5/ch17.ht…

  • 原型链: 《JavaScript for impatient programmers》“Prototype chains” 《Speaking JavaScript》 “Properties: Definition Versus Assignment”

  • 《Speaking JavaScript》“Metaprogramming with proxies”