专栏首页京程一灯共享可变状态中出现的问题以及如何避免[每日前端夜话0xDB]

共享可变状态中出现的问题以及如何避免[每日前端夜话0xDB]

共享可变状态的解释如下:

  • 如果两个或多个参与方可以更改相同的数据(变量,对象等),并且
  • 如果它们的生命周期重叠,

则可能会有一方修改会导致另一方无法正常工作的风险。以下是一个例子:

 1function logElements(arr) {
 2  while (arr.length > 0) {
 3    console.log(arr.shift());
 4  }
 5}
 6
 7function main() {
 8  const arr = ['banana', 'orange', 'apple'];
 9
10  console.log('Before sorting:');
11  logElements(arr);
12
13  arr.sort(); // changes arr
14
15  console.log('After sorting:');
16  logElements(arr); // (A)
17}
18main();
19
20// Output:
21// 'Before sorting:'
22// 'banana'
23// 'orange'
24// 'apple'
25// 'After sorting:'

这里有两个独立的部分:函数logElements()和函数main()。后者想要在对数组进行排序的前后都打印其内容。但是它到用了 logElements() ,会导致数组被清空。所以 main() 会在A行输出一个空数组。

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

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

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

通过复制数据避免共享

在开始研究如何避免共享之前,我们需要看一下如何在 JavaScript 中复制数据。

浅拷贝与深拷贝

对于数据,有两个可复制的“深度”:

  • 浅拷贝仅复制对象和数组的顶层条目。原始值和副本中的输入值仍然相同。
  • 深拷贝还会复制条目值的条目。也就是说,它会完整遍历树,并复制所有节点。

不幸的是,JavaScript 仅内置了对浅拷贝的支持。如果需要深拷贝,则需要自己实现。

JavaScript 中的浅拷贝

让我们看一下浅拷贝的几种方法。

通过传播复制普通对象和数组

我们可以扩展为对象字面量和扩展为数组字面量进行复制:

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

但是传播有几个限制:

  • 不复制原型: 1class MyClass {} 2 3const original = new MyClass(); 4assert.equal(MyClass.prototype.isPrototypeOf(original), true); 5 6const copy = {...original}; 7assert.equal(MyClass.prototype.isPrototypeOf(copy), false);
  • 正则表达式和日期之类的特殊对象有未复制的特殊“内部插槽”。
  • 仅复制自己的(非继承)属性。鉴于原型链的工作原理,这通常是最好的方法。但是你仍然需要意识到这一点。在以下示例中,copy 中没有 original 的继承属性 .inheritedProp,因为我们仅复制自己的属性,而未保留原型。 1const proto = { inheritedProp: 'a' }; 2const original = {__proto__: proto, ownProp: 'b' }; 3assert.equal(original.inheritedProp, 'a'); 4assert.equal(original.ownProp, 'b'); 5 6const copy = {...original}; 7assert.equal(copy.inheritedProp, undefined); 8assert.equal(copy.ownProp, 'b');
  • 仅复制可枚举的属性。例如数组实例自己的属性 .length 不可枚举,也不能复制: 1const arr = ['a', 'b']; 2assert.equal(arr.length, 2); 3assert.equal({}.hasOwnProperty.call(arr, 'length'), true); 4 5const copy = {...arr}; 6assert.equal({}.hasOwnProperty.call(copy, 'length'), false);
  • 与 property 的 attributes无关,它的副本始终是可写和可配置的 data 属性,例如: 1const original = Object.defineProperties({}, { 2prop: { 3 value: 1, 4 writable: false, 5 configurable: false, 6 enumerable: true, 7}, 8}); 9assert.deepEqual(original, {prop: 1}); 10 11const copy = {...original}; 12// Attributes “writable” and “configurable” of copy are different: 13assert.deepEqual(Object.getOwnPropertyDescriptors(copy), { 14prop: { 15 value: 1, 16 writable: true, 17 configurable: true, 18 enumerable: true, 19}, 20});

这意味着,getter 和 setter 都不会被如实地被复制:value 属性(用于数据属性),get 属性(用于 getter)和set 属性(用于 setter)是互斥的。

js const original = { get myGetter() { return 123 }, set mySetter(x) {}, }; assert.deepEqual({...original}, { myGetter: 123, // not a getter anymore! mySetter: undefined, });

  • 拷贝很浅:该副本具有原始版本中每个键值条目的新版本,但是原始值本身不会被复制。例如: 1const original = {name: 'Jane', work: {employer: 'Acme'}}; 2const copy = {...original}; 3 4// Property .name is a copy 5copy.name = 'John'; 6assert.deepEqual(original, 7{name: 'Jane', work: {employer: 'Acme'}}); 8assert.deepEqual(copy, 9{name: 'John', work: {employer: 'Acme'}}); 10 11// The value of .work is shared 12copy.work.employer = 'Spectre'; 13assert.deepEqual( 14original, {name: 'Jane', work: {employer: 'Spectre'}}); 15assert.deepEqual( 16copy, {name: 'John', work: {employer: 'Spectre'}});

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

  • 我们可以在拷贝过程中为副本提供与原始原型相同的原型: 1class MyClass {} 2 3const original = new MyClass(); 4 5const copy = { 6__proto__: Object.getPrototypeOf(original), 7...original, 8}; 9assert.equal(MyClass.prototype.isPrototypeOf(copy), true);

另外,我们可以在副本创建后通过 Object.setPrototypeOf() 设置原型。

  • 没有简单的方法可以通用地复制特殊对象。
  • 如前所述,仅复制自己的属性是功能而非限制。
  • 我们可以用 Object.getOwnPropertyDescriptors()Object.defineProperties() 复制对象(操作方法稍后说明):
  • 他们考虑了所有属性(而不仅仅是 value),因此正确地复制了getters,setters,只读属性等。
  • Object.getOwnPropertyDescriptors() 检索可枚举和不可枚举的属性。
  • 我们将在本文后面的内容中介绍深拷贝。

通过 `Object.assign()` 进行浅拷贝(高级)

Object.assign()的工作原理就像传播到对象中一样。也就是说以下两种复制方式大致相同:

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

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

不过 Object.assign() 并不完全像传播。它在一个相对微妙的方面有所不同:它以不同的方式创建属性。

  • Object.assign() 使用 assignment 创建副本的属性。
  • 传播定义副本中的新属性。

除其他事项外,assignment 会调用自己的和继承的设置器,而 definition 不会(关于 assignment 与 definition 的更多信息)。这种差异很少引起注意。以下代码是一个例子,但它是人为设计的:

1const original = {['__proto__']: null};
2const copy1 = {...original};
3// copy1 has the own property '__proto__'
4assert.deepEqual(
5  Object.keys(copy1), ['__proto__']);
6
7const copy2 = Object.assign({}, original);
8// copy2 has the prototype null
9assert.equal(Object.getPrototypeOf(copy2), null);

通过 `Object.getOwnPropertyDescriptors()` 和 `Object.defineProperties()` 进行浅拷贝(高级)

JavaScript 使我们可以通过属性描述符创建属性,这些对象指定属性属性。例如,通过 Object.defineProperties() ,我们已经看到了它。如果将该方法与 Object.getOwnPropertyDescriptors()结合使用,则可以更加忠实地进行复制:

1function copyAllOwnProperties(original) {
2  return Object.defineProperties(
3    {}, Object.getOwnPropertyDescriptors(original));
4}

这消除了通过传播复制对象的两个限制。

首先,能够正确复制自己 property 的所有 attribute。我们现在可以复制自己的 getter 和 setter:

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

其次,由于使用了 Object.getOwnPropertyDescriptors(),非枚举属性也被复制了:

1const arr = ['a', 'b'];
2assert.equal(arr.length, 2);
3assert.equal({}.hasOwnProperty.call(arr, 'length'), true);
4
5const copy = copyAllOwnProperties(arr);
6assert.equal({}.hasOwnProperty.call(copy, 'length'), true);

JavaScript 的深拷贝

现在该解决深拷贝了。首先我们将手动进行深拷贝,然后再研究通用方法。

通过嵌套传播手动深拷贝

如果嵌套传播,则会得到深层副本:

1const original = {name: 'Jane', work: {employer: 'Acme'}};
2const copy = {name: original.name, work: {...original.work}};
3
4// We copied successfully:
5assert.deepEqual(original, copy);
6// The copy is deep:
7assert.ok(original.work !== copy.work);

Hack:通过 JSON 进行通用深拷贝

尽管这是一个 hack,但是在紧要关头,它提供了一个快速的解决方案:为了对 `original 对象进行深拷贝”,我们首先将其转换为 JSON 字符串,然后再解析该它:

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

这种方法的主要缺点是,我们只能复制具有 JSON 支持的键和值的属性。

一些不受支持的键和值将被忽略:

1assert.deepEqual(
2  jsonDeepCopy({
3    [Symbol('a')]: 'abc',
4    b: function () {},
5    c: undefined,
6  }),
7  {} // empty object
8);

其他导致的例外:

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

实现通用深拷贝

可以用以下函数进行通用深拷贝:

 1function deepCopy(original) {
 2  if (Array.isArray(original)) {
 3    const copy = [];
 4    for (const [index, value] of original.entries()) {
 5      copy[index] = deepCopy(value);
 6    }
 7    return copy;
 8  } else if (typeof original === 'object' && original !== null) {
 9    const copy = {};
10    for (const [key, value] of Object.entries(original)) {
11      copy[key] = deepCopy(value);
12    }
13    return copy;
14  } else {
15    // Primitive value: atomic, no need to copy
16    return original;
17  }
18}

该函数处理三种情况:

  • 如果 original 是一个数组,我们创建一个新的 Array,并将 original 的元素复制到其中。
  • 如果 original 是一个对象,我们将使用类似的方法。
  • 如果 original 是原始值,则无需执行任何操作。

让我们尝试一下deepCopy()

 1const original = {a: 1, b: {c: 2, d: {e: 3}}};
 2const copy = deepCopy(original);
 3
 4// Are copy and original deeply equal?
 5assert.deepEqual(copy, original);
 6
 7// Did we really copy all levels
 8// (equal content, but different objects)?
 9assert.ok(copy     !== original);
10assert.ok(copy.b   !== original.b);
11assert.ok(copy.b.d !== original.b.d);

注意,deepCopy() 仅解决了一个扩展问题:浅拷贝。而其他所有内容:不复制原型,仅部分复制特殊对象,忽略不可枚举的属性,忽略大多数属性。

通常完全完全实现复制是不可能的:并非所有数据的都是一棵树,有时你并不需要所有属性,等等。

更简洁的 `deepCopy()` 版本

如果我们使用 .map()Object.fromEntries(),可以使以前的 deepCopy() 实现更加简洁:

 1function deepCopy(original) {
 2  if (Array.isArray(original)) {
 3    return original.map(elem => deepCopy(elem));
 4  } else if (typeof original === 'object' && original !== null) {
 5    return Object.fromEntries(
 6      Object.entries(original)
 7        .map(([k, v]) => [k, deepCopy(v)]));
 8  } else {
 9    // Primitive value: atomic, no need to copy
10    return original;
11  }
12}

在类中实现深拷贝(高级)

通常使用两种技术可以实现类实例的深拷贝:

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

该技术为每个类引入了一个方法 .clone(),其实例将被深拷贝。它返回 this 的深层副本。以下例子显示了可以克隆的三个类。

 1class Point {
 2  constructor(x, y) {
 3    this.x = x;
 4    this.y = y;
 5  }
 6  clone() {
 7    return new Point(this.x, this.y);
 8  }
 9}
10class Color {
11  constructor(name) {
12    this.name = name;
13  }
14  clone() {
15    return new Color(this.name);
16  }
17}
18class ColorPoint extends Point {
19  constructor(x, y, color) {
20    super(x, y);
21    this.color = color;
22  }
23  clone() {
24    return new ColorPoint(
25      this.x, this.y, this.color.clone()); // (A)
26  }
27}

A 行展示了此技术的一个重要方面:复合实例属性值也必须递归克隆。

静态工厂方法

拷贝构造函数是用当前类的另一个实例来设置当前实例的构造函数。拷贝构造函数在静态语言(例如 C++ 和 Java)中很流行,你可以在其中通过 static 重载static 表示它在编译时发生)提供构造函数的多个版本。

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

 1class Point {
 2  constructor(...args) {
 3    if (args[0] instanceof Point) {
 4      // Copy constructor
 5      const [other] = args;
 6      this.x = other.x;
 7      this.y = other.y;
 8    } else {
 9      const [x, y] = args;
10      this.x = x;
11      this.y = y;
12    }
13  }
14}

这是使用方法:

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

相反,静态工厂方法在 JavaScript 中效果更好(static 意味着它们是类方法)。

在以下示例中,三个类 PointColorColorPoint 分别具有静态工厂方法 .from()

 1class Point {
 2  constructor(x, y) {
 3    this.x = x;
 4    this.y = y;
 5  }
 6  static from(other) {
 7    return new Point(other.x, other.y);
 8  }
 9}
10class Color {
11  constructor(name) {
12    this.name = name;
13  }
14  static from(other) {
15    return new Color(other.name);
16  }
17}
18class ColorPoint extends Point {
19  constructor(x, y, color) {
20    super(x, y);
21    this.color = color;
22  }
23  static from(other) {
24    return new ColorPoint(
25      other.x, other.y, Color.from(other.color)); // (A)
26  }
27}

在 A 行中,我们再次使用递归复制。

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

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

拷贝如何帮助共享可变状态?

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

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

  • 输入:复制(潜在地)传递给我们的共享数据,使我们可以使用该数据而不受外部实体的干扰。
  • 输出:在将内部数据公开给外部方之前复制内部数据,意味着不会破坏我们的内部活动。

请注意,这些措施可以保护我们免受其他各方的侵害,同时也可以保护其他各方免受我们的侵害。

下一节说明两种防御性复制。

复制共享输入

请记住,在本文开头的例子中,我们遇到了麻烦,因为 logElements() 修改了其参数 arr

1function logElements(arr) {
2  while (arr.length > 0) {
3    console.log(arr.shift());
4  }
5}

让我们在此函数中添加防御性复制:

1function logElements(arr) {
2  arr = [...arr]; // defensive copy
3  while (arr.length > 0) {
4    console.log(arr.shift());
5  }
6}

现在,如果在 main() 内部调用 logElements() 不会再引发问题:

 1function main() {
 2  const arr = ['banana', 'orange', 'apple'];
 3
 4  console.log('Before sorting:');
 5  logElements(arr);
 6
 7  arr.sort(); // changes arr
 8
 9  console.log('After sorting:');
10  logElements(arr); // (A)
11}
12main();
13
14// Output:
15// 'Before sorting:'
16// 'banana'
17// 'orange'
18// 'apple'
19// 'After sorting:'
20// 'apple'
21// 'banana'
22// 'orange'

复制公开的内部数据

让我们从 StringBuilder 类开始,该类不会复制它公开的内部数据(A行):

 1class StringBuilder {
 2  constructor() {
 3    this._data = [];
 4  }
 5  add(str) {
 6    this._data.push(str);
 7  }
 8  getParts() {
 9    // We expose internals without copying them:
10    return this._data; // (A)
11  }
12  toString() {
13    return this._data.join('');
14  }
15}

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

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

但是,如果更改了 .getParts() 的结果(A行),则 StringBuilder 会停止正常工作:

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

解决方案是在内部 ._data 被公开之前防御性地对它进行复制(A行):

 1class StringBuilder {
 2  constructor() {
 3    this._data = [];
 4  }
 5  add(str) {
 6    this._data.push(str);
 7  }
 8  getParts() {
 9    // Copy defensively
10    return [...this._data]; // (A)
11  }
12  toString() {
13    return this._data.join('');
14  }
15}

现在,更改 .getParts() 的结果不再干扰 sb 的操作:

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

通过无损更新来避免数据改变

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

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

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

  • 数据的破坏性更新使数据被改变,使数据本身具有所需的形式。
  • 数据的非破坏性更新创建具有所需格式的数据副本。

后一种方法类似于先复制然后破坏性地更改它,但两者同时进行。

示例:以破坏性和非破坏性的方式更新对象

这就是我们破坏性地设置对象的属性 .city 的方式:

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

以下函数以非破坏性的方式更改属性:

1function setObjectNonDestructively(obj, key, value) {
2  const updatedObj = {};
3  for (const [k, v] of Object.entries(obj)) {
4    updatedObj[k] = (k === key ? value : v);
5  }
6  return updatedObj;
7}

它的用法如下:

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

传播使 setObjectNonDestructively() 更加简洁:

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

注意:setObject NonDestructively() 的两个版本都进行了较浅的更新。

示例:以破坏性和非破坏性的方式更新数组

以下是破坏性地设置数组元素的方式:

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

非破坏性地更新数组要复杂得多。

 1function setArrayNonDestructively(arr, index, value) {
 2  const updatedArr = [];
 3  for (const [i, v] of arr.entries()) {
 4    updatedArr.push(i === index ? value : v);
 5  }
 6  return updatedArr;
 7}
 8
 9const arr = ['a', 'b', 'c', 'd', 'e'];
10const updatedArr = setArrayNonDestructively(arr, 2, 'x');
11assert.deepEqual(updatedArr, ['a', 'b', 'x', 'd', 'e']);
12assert.deepEqual(arr, ['a', 'b', 'c', 'd', 'e']);

.slice() 和扩展使 setArrayNonDestructively() 更加简洁:

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

注意:setArrayNonDestructively() 的两个版本都进行了较浅的更新。

手动深度更新

到目前为止,我们只是浅层地更新了数据。让我们来解决深度更新。以下代码显示了如何手动执行此操作。我们正在更改 nameemployer

 1const original = {name: 'Jane', work: {employer: 'Acme'}};
 2const updatedOriginal = {
 3  ...original,
 4  name: 'John',
 5  work: {
 6    ...original.work,
 7    employer: 'Spectre'
 8  },
 9};
10
11assert.deepEqual(
12  original, {name: 'Jane', work: {employer: 'Acme'}});
13assert.deepEqual(
14  updatedOriginal, {name: 'John', work: {employer: 'Spectre'}});

实现通用深度更新

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

 1function deepUpdate(original, keys, value) {
 2  if (keys.length === 0) {
 3    return value;
 4  }
 5  const currentKey = keys[0];
 6  if (Array.isArray(original)) {
 7    return original.map(
 8      (v, index) => index === currentKey
 9        ? deepUpdate(v, keys.slice(1), value) // (A)
10        : v); // (B)
11  } else if (typeof original === 'object' && original !== null) {
12    return Object.fromEntries(
13      Object.entries(original).map(
14        (keyValuePair) => {
15          const [k,v] = keyValuePair;
16          if (k === currentKey) {
17            return [k, deepUpdate(v, keys.slice(1), value)]; // (C)
18          } else {
19            return keyValuePair; // (D)
20          }
21        }));
22  } else {
23    // Primitive value
24    return original;
25  }
26}

如果我们将 value 视为要更新的树的根,则 deepUpdate() 只会深度更改单个分支(A 和 C 行)。所有其他分支均被浅复制(B 和 D 行)。

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

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

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

使用非破坏性更新,共享数据将变得毫无问题,因为我们永远不会改变共享数据。(显然,这只有在各方都这样做的情况下才有效。)

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

1const original = {city: 'Berlin', country: 'Germany'};
2const 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 及其属性。它不会冻结那些属性的值,例如:

 1const teacher = {
 2  name: 'Edna Krabappel',
 3  students: ['Bart'],
 4};
 5Object.freeze(teacher);
 6
 7assert.throws(
 8  () => teacher.name = 'Elizabeth Hoover',
 9  /^TypeError: Cannot assign to read only property 'name'/);
10
11teacher.students.push('Lisa');
12assert.deepEqual(
13  teacher, {
14    name: 'Edna Krabappel',
15    students: ['Bart', 'Lisa'],
16  });

实现深度冻结

如果要深度冻结,则需要自己实现:

 1function deepFreeze(value) {
 2  if (Array.isArray(value)) {
 3    for (const element of value) {
 4      deepFreeze(element);
 5    }
 6    Object.freeze(value);
 7  } else if (typeof value === 'object' && value !== null) {
 8    for (const v of Object.values(value)) {
 9      deepFreeze(v);
10    }
11    Object.freeze(value);
12  } else {
13    // Nothing to do: primitive values are already immutable
14  } 
15  return value;
16}

回顾上一节中的例子,我们可以检查 deepFreeze() 是否真的冻结了:

 1const teacher = {
 2  name: 'Edna Krabappel',
 3  students: ['Bart'],
 4};
 5deepFreeze(teacher);
 6
 7assert.throws(
 8  () => teacher.name = 'Elizabeth Hoover',
 9  /^TypeError: Cannot assign to read only property 'name'/);
10
11assert.throws(
12  () => teacher.students.push('Lisa'),
13  /^TypeError: Cannot add property 1, object is not extensible$/);

不可变包装器(高级)

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

接下来展示了 Maps 和 Arrays 的包装器。它们都有以下限制:

  • 它们比较简陋。为了使它们适合实际中的使用,需要做更多的工作:更好的检查,支持更多的方法等。
  • 他们是浅拷贝。

map的不变包装器

ImmutableMapWrapper 为 map 生成包装器:

 1class ImmutableMapWrapper {
 2  constructor(map) {
 3    this._self = map;
 4  }
 5}
 6
 7// Only forward non-destructive methods to the wrapped Map:
 8for (const methodName of ['get', 'has', 'keys', 'size']) {
 9  ImmutableMapWrapper.prototype[methodName] = function (...args) {
10    return this._self[methodName](...args);
11  }
12}

这是 action 中的类:

 1const map = new Map([[false, 'no'], [true, 'yes']]);
 2const wrapped = new ImmutableMapWrapper(map);
 3
 4// Non-destructive operations work as usual:
 5assert.equal(
 6  wrapped.get(true), 'yes');
 7assert.equal(
 8  wrapped.has(false), true);
 9assert.deepEqual(
10  [...wrapped.keys()], [false, true]);
11
12// Destructive operations are not available:
13assert.throws(
14  () => wrapped.set(false, 'never!'),
15  /^TypeError: wrapped.set is not a function$/);
16assert.throws(
17  () => wrapped.clear(),
18  /^TypeError: wrapped.clear is not a function$/);
19

数组的不可变包装器

对于数组 arr,常规包装是不够的,因为我们不仅需要拦截方法调用,而且还需要拦截诸如 arr [1] = true 之类的属性访问。JavaScript proxies 使我们能够执行这种操作:

 1const RE_INDEX_PROP_KEY = /^[0-9]+$/;
 2const ALLOWED_PROPERTIES = new Set([
 3  'length', 'constructor', 'slice', 'concat']);
 4
 5function wrapArrayImmutably(arr) {
 6  const handler = {
 7    get(target, propKey, receiver) {
 8      // We assume that propKey is a string (not a symbol)
 9      if (RE_INDEX_PROP_KEY.test(propKey) // simplified check!
10        || ALLOWED_PROPERTIES.has(propKey)) {
11          return Reflect.get(target, propKey, receiver);
12      }
13      throw new TypeError(`Property "${propKey}" can’t be accessed`);
14    },
15    set(target, propKey, value, receiver) {
16      throw new TypeError('Setting is not allowed');
17    },
18    deleteProperty(target, propKey) {
19      throw new TypeError('Deleting is not allowed');
20    },
21  };
22  return new Proxy(arr, handler);
23}

让我们包装一个数组:

 1const arr = ['a', 'b', 'c'];
 2const wrapped = wrapArrayImmutably(arr);
 3
 4// Non-destructive operations are allowed:
 5assert.deepEqual(
 6  wrapped.slice(1), ['b', 'c']);
 7assert.equal(
 8  wrapped[1], 'b');
 9
10// Destructive operations are not allowed:
11assert.throws(
12  () => wrapped[1] = 'x',
13  /^TypeError: Setting is not allowed$/);
14assert.throws(
15  () => wrapped.shift(),
16  /^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

 1import {Map} from 'immutable/dist/immutable.es.js';
 2const map0 = Map([
 3  [false, 'no'],
 4  [true, 'yes'],
 5]);
 6
 7const map1 = map0.set(true, 'maybe'); // (A)
 8assert.ok(map1 !== map0); // (B)
 9assert.equal(map1.equals(map0), false);
10
11const map2 = map1.set(true, 'yes'); // (C)
12assert.ok(map2 !== map1);
13assert.ok(map2 !== map0);
14assert.equal(map2.equals(map0), true); // (D)

说明:

  • 在 A 行中,我们新创建了一个 map0 的不同版本 map1,其中 true 映射到了 'maybe'
  • 在 B 行中,我们检查更改是否为非破坏性的。
  • 在 C 行中,我们更新 map1,并撤消在 A 行中所做的更改。
  • 在 D 行中,我们使用 Immutable 的内置 .equals() 方法来检查是否确实撤消了更改。

Immer

在其存储库中,Immer 库 的描述为:

通过更改当前状态来创建下一个不可变状态。

Immer 有助于非破坏性地更新(可能嵌套)普通对象和数组。也就是说,不涉及特殊的数据结构。

这是使用 Immer 的样子:

 1import {produce} from 'immer/dist/immer.module.js';
 2
 3const people = [
 4  {name: 'Jane', work: {employer: 'Acme'}},
 5];
 6
 7const modifiedPeople = produce(people, (draft) => {
 8  draft[0].work.employer = 'Cyberdyne';
 9  draft.push({name: 'John', work: {employer: 'Spectre'}});
10});
11
12assert.deepEqual(modifiedPeople, [
13  {name: 'Jane', work: {employer: 'Cyberdyne'}},
14  {name: 'John', work: {employer: 'Spectre'}},
15]);
16assert.deepEqual(people, [
17  {name: 'Jane', work: {employer: 'Acme'}},
18]);

原始数据存储在 people 中。produce() 为我们提供了一个变量 draft。我们假设这个变量是 people,并使用通常会进行破坏性更改的操作。Immer 拦截了这些操作。代替变异draft,它无损地改变 people。结果由 modifiedPeople 引用。它是一成不变的。

致谢

  • Ron Korvig 提醒我在 JavaScript 中进行深拷贝时使用静态工厂方法,而不要重载构造函数。

本文分享自微信公众号 - 前端先锋(jingchengyideng),作者:疯狂的技术宅

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-10-28

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 七个简单但棘手的 JS 面试问题[每日前端夜话0xD4]

    如果你参加 JavaScript 高级开发面试,那么很有可能在编码面试中被问到一些棘手的问题。

    疯狂的技术宅
  • 用JavaScript把CSV与Excel转为Json[每日前端夜话0xC5]

    有两个 JavaScript 插件可用于读取和处理 CSV 和 Excel 文件,之后仅对自己的脚本进行编码即可。

    疯狂的技术宅
  • JavaScript的工作原理:V8引擎内部机制及优化代码的5个技巧 [每日前端夜话(0x15)]

    几个星期前,我们开始了一系列旨在深入挖掘 JavaScript 及其工作原理的系列:通过了解JavaScript的构建模块以及它们如何共同发挥作用,你将能够编写...

    疯狂的技术宅
  • 127个常用的JS代码片段,每段代码花30秒就能看懂(一)

    JavaScript 是目前最流行的编程语言之一,正如大多数人所说:“如果你想学一门编程语言,请学JavaScript。”

    前端达人
  • 速读原著-TCP/IP(IP分片)

    正如我们在2 . 8节描述的那样,物理网络层一般要限制每次发送数据帧的最大长度。任何时候I P层接收到一份要发送的 I P数据报时,它要判断向本地哪个接口发送数...

    cwl_java
  • 数据库分库分表,分片配置轻松入门!

    当我们把 MyCat + MySQL 的架构搭建完成之后,接下来面临的一个问题就是,数据库的分片规则:有那么多 MySQL ,一条记录通过 MyCat 到底要插...

    江南一点雨
  • 大数据的搜索引擎——ElasticSearch

    结果显示分片大都是因为 node_left 导致未分配,然后通过 explain API 查看分片 myindex[3] 不自动分配的具体原因:

    用户4143945
  • 解析 Elasticsearch 棘手问题,集群的 RED 与 YELLOW

    结果显示分片大都是因为 node_left 导致未分配,然后通过 explain API 查看分片 myindex[3] 不自动分配的具体原因:

    Java3y
  • 解析 Elasticsearch 棘手问题,集群的 RED 与 YELLOW

    结果显示分片大都是因为 node_left 导致未分配,然后通过 explain API 查看分片 myindex[3] 不自动分配的具体原因:

    用户1737318
  • 一文读懂分片基础原理, 数据分片, 跨分片交易, 区块链分片和缩放究竟是什么鬼?

    以太坊是所有区块链中一直与分片概念同步的底层平台,想要理解为什么以太坊开发者社区想要实现分片,重点是要理解分片是什么,以及这个解决方案为何如此有吸引力。

    区块链大本营

扫码关注云+社区

领取腾讯云代金券