谈一谈共享可变数据存在的问题以及如何避免它

58,304 阅读8分钟

本文是对shared-mutable-state这篇文章的一个解读分析,带你从头理解下共享可变数据的前世今生,这篇文章主要阐述了以下3个问题:

  • 什么是共享可变数据?
  • 它为什么存在问题?
  • 怎样避免这个问题?

1 什么是共享可变数据以及它存在的问题

共享可变数据就是超过2个以上的实例能够改变同一个数据,比如如下例子:

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:'

这个例子,main()logElements()这两个函数都引用了数组arr,其中logElements方法体内通过调用shift方法改变了arr数组,导致了后面一个logElements方法输出了空数组。

2 通过拷贝来避免共享数据存在的问题

文中提及了可以通过拷贝数据来解决这个问题,其中拷贝又分为浅拷贝和深拷贝:

  • 浅拷贝只会拷贝数组或者对象第一层级的实体(属性),实体的值依然和原始数据的值一摸一样,如果实体的值是引用类型(数组或者对象),那如果共享实体的值依然会存在上述问题。
  • 深拷贝不仅会拷贝数组或者对象的第一层级的实体,同时会拷贝实体的值,如果实体的值是数组或者对象,也会一层一层的深入进行拷贝,拷贝出来的值和原始值就是物理意义上隔绝的两个值。

不管是浅拷贝还是深拷贝,都有其使用的场景,比如如果一个对象或者数组层级很简单,值都是基本数据类型,那使用浅拷贝即可,相比深拷贝,代码执行效率更高且占用更少的内存。

文中同时还提到了很多实现拷贝的方式,我下面来一一介绍下,其中很多你可能听过但你不一定了解的很透彻。

2.1 ES6扩展符

首先提及的是通过...扩展符的方式,拷贝实现方式如下:

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

然而通过扩展符实现拷贝存在几个局限性:

  1. 无法拷贝原始值的原型(__proto__属性指向的值)
  2. 特殊对象(如正则表达式和日期)具有特殊的内部插槽,不会被复制。
  3. 只有自己的属性,不包括通过继承而来的属性能够被复制
  4. 只有enumerable(可枚举)属性能够被复制
  5. 一些特殊属性,比如writable``configurable
  6. 这种方式是浅拷贝

面对这些问题,文中也提供了一些解决的方式,感兴趣的可以查看原文

2.2 Object.assign()

接着,又介绍了通过对象的assign()方法的方式:

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

Object.assign()的使用方式以及局限性和扩展符差不多,但也有点区别:

  • Object.assign()通过从新赋值来修改原始对象来创建拷贝对象
  • ...扩展符通过使用现有对象的自身属性来创建新的普通对象

2.3 Object.getOwnPropertyDescriptors()和Object.defineProperties()

文中还列举了一些解决浅拷贝缺陷的一些解决方式,众所周知,浅拷贝本质上是通过Object.defineProperties()这个方法,直接在一个对象上定义新的属性或修改现有属性,并返回该对象来实现的,结合Object.getOwnPropertyDescriptors()方法,我们能够实现一种拷贝方式,可以轻松解决扩展符拷贝存在的局限性。

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

通过上述方式,我们现在不仅能够拷贝自己的属性,同时非枚举同样能够被拷贝。

3 深拷贝

然后,文中接着介绍了几种深拷贝的方式

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

3.2 通过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);

同时,Symbol和空值拷贝的时候都会被忽略

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

一个更加简洁的方式,如果拷贝的是对象,先通过Object.entries获取所有自身可枚举属性的键值对数组,遍历键值对数组,然后通过Object.fromEntries还原成对象。

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

3.4 深拷贝classes类

还介绍了深拷贝class类的方式

  • .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)
  }
}

这里需要注意的是,组合实例属性需要递归拷贝。

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

前面花了那么多时间来介绍拷贝的方式,那拷贝是如何帮助我们共享可变状态的呢?其实只要控制好两个方面就行,一个是进入,一个是输出。

假如,有一个共享数据,在访问这个数据前(进入),我们可以通过合适的拷贝这份数据,那不管我们怎么操作拷贝后的数据,都不会影响原始数据。另一个就是输出,假如我们将输入暴露出去供别人使用,我们不要直接暴露原始数据,可以暴露一份拷贝数据,这样不管他人如何操控这份暴露出去的数据,都不会影响我们的原始数据。

拿最开始的例子来讲:

初始是这样

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

现在再去执行后面的操作就不会发生数据为空的情况

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'

同理,输出拷贝的数据也是一样的原因。

5 非破坏性更新数据

数据的共享不仅是获取,有时候我们还会更新数据,那原则就是必须非破坏性的更新,不要破坏性的更新。

非破坏性就是指不要直接去操作原始数据,强行变化原始的数据结构,尽量通过拷贝的方式,对原始数据侵入性最弱的方式去更新数据。

看如下例子:

直接赋值改变原始值的方式就输入破坏性的操作方式

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

文中还提及了非破坏性的更新数组,通过深拷贝非破坏性的更新数据的方式,感兴趣的可以查看原文

为什么要通过非破坏性的方式更新数据呢?

因为通过非破坏性更新,共享数据就不会因为破坏性更新数据导致数据前后不一致的问题,同时也利于数据回溯。

既然不管怎样都不直接操纵原始数据,这里就引申出了现在越来越流行的一个概念,对原始数据的一个新称呼 不可变数据

6 不可变数据

那如何使数据不可变呢?javascript提供了3种方式:

  1. Object.preventExtensions(obj) 让一个对象变的不可扩展,也就是永远不能再添加新的属性
  2. Object.seal(obj) 封闭一个对象,阻止添加新属性并将所有现有属性标记为不可配置。当前属性的值只要可写就可以改变
  3. Object.freeze(obj) 冻结一个对象,一个被冻结的对象再也不能被修改,但需要注意的是只会冻结自己和自己的属性,不会冻结属性的值

那如何实现深度冻结?

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

7 Immutable.js和Immer

文中最后提及了两个提供了创建不可变数据以及非暴力更新数据的能力的库

  • Immutable.js提供了List Map Set Stack的不可变数据结构
  • Immer同样提供了类似的能力

8 总结

通过阅读全文,我们知道了在共享同一份数据,为何要保持数据不可变,这也是为什么使用Redux的进行状态管理的时候,不允许我们直接改变数据,以及我们一般会配套使用Immutable.js的真正原因。Redux只有一份全部的状态,那么多组件引用它,如果不保持数据的纯洁性,数据管理就会变得异常困难,遇到问题也会难以追溯。

最后,希望这篇文章能够提升你对数据管理的深度认知以及扩展管理数据的一些方式。