共享可变状态中出现的问题以及如何避免

做者:Dr. Axel Rauschmayer

翻译:疯狂的技术宅html

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

未经容许严禁转载git

本文回答了如下问题:程序员

  • 么是共享可变状态?
  • 为何会出现问题?
  • 如何避免其问题?

标有“(高级)”的部分会更深刻,若是你想更快地阅读本文,能够跳过。es6


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

共享可变状态的解释以下:github

  • 若是两个或多个参与方能够更改相同的数据(变量,对象等),而且
  • 若是它们的生命周期重叠,

则可能会有一方修改会致使另外一方没法正常工做的风险。如下是一个例子:面试

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行输出一个空数组。正则表达式

在本文的剩余部分,咱们将介绍三种避免共享可变状态问题的方法:编程

  • 经过复制数据避免共享
  • 经过无损更新来避免数据变更
  • 经过使数据不可变来防止数据变更

针对每一种方法,咱们都会回到刚才看到的示例并进行修复。json

经过复制数据避免共享

在开始研究如何避免共享以前,咱们须要看一下如何在 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 中进行深拷贝时使用静态工厂方法,而不要重载构造函数。

扩展阅读


本文首发微信公众号:前端先锋

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎扫描二维码关注公众号,天天都给你推送新鲜的前端技术文章

欢迎继续阅读本专栏其它高赞文章:


相关文章
相关标签/搜索