深入理解 JavaScript
请支持本书:购买捐赠
(广告,请不要屏蔽。)

6 复制对象和数组



在本章中,我们将学习如何在 JavaScript 中复制对象和数组。

6.1 浅拷贝 vs. 深拷贝

复制数据有两种“深度”

接下来的部分将介绍这两种复制方式。不幸的是,JavaScript 只内置了对浅拷贝的支持。如果我们需要深拷贝,就需要自己实现。

6.2 JavaScript 中的浅拷贝

让我们来看看几种浅拷贝数据的方法。

6.2.1 通过展开运算符复制普通对象和数组

我们可以将 展开运算符用于对象字面量数组字面量 来创建副本

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

然而,展开运算符有一些问题。这些问题将在接下来的小节中介绍。其中一些是真正的限制,而另一些仅仅是特殊情况。

6.2.1.1 对象展开运算符不会复制原型

例如

class MyClass {}

const original = new MyClass();
assert.equal(original instanceof MyClass, true);

const copy = {...original};
assert.equal(copy instanceof MyClass, false);

请注意,以下两个表达式是等效的

obj instanceof SomeClass
SomeClass.prototype.isPrototypeOf(obj)

因此,我们可以通过为副本指定与原始数据相同的原型来解决这个问题

class MyClass {}

const original = new MyClass();

const copy = {
  __proto__: Object.getPrototypeOf(original),
  ...original,
};
assert.equal(copy instanceof MyClass, true);

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

6.2.1.2 许多内置对象具有特殊的“内部槽”,这些槽不会被对象展开运算符复制

此类内置对象的示例包括正则表达式和日期。如果我们复制它们,就会丢失存储在其中的大部分数据。

6.2.1.3 对象展开运算符只复制自身(非继承)属性

考虑到 原型链 的工作原理,这通常是正确的方法。但我们仍然需要注意这一点。在下面的例子中,original 的继承属性 .inheritedPropcopy 中不可用,因为我们只复制了自身属性,而没有保留原型。

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');
6.2.1.4 对象展开运算符只复制可枚举属性

例如,数组实例的自身属性 .length 不可枚举,因此不会被复制。在下面的例子中,我们通过对象展开运算符(A 行)复制数组 arr

const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);

const copy = {...arr}; // (A)
assert.equal({}.hasOwnProperty.call(copy, 'length'), false);

这也很少是一个限制,因为大多数属性都是可枚举的。如果我们需要复制不可枚举的属性,可以使用 Object.getOwnPropertyDescriptors()Object.defineProperties() 来复制对象(稍后将解释如何做到这一点

有关可枚举性的更多信息,请参阅 §12 “属性的可枚举性”

6.2.1.5 对象展开运算符并不总是忠实地复制属性特性

属性的特性 无关,其副本始终是可写和可配置的数据属性。

例如,这里我们创建了属性 original.prop,其特性 writableconfigurablefalse

const original = Object.defineProperties(
  {}, {
    prop: {
      value: 1,
      writable: false,
      configurable: false,
      enumerable: true,
    },
  });
assert.deepEqual(original, {prop: 1});

如果我们复制 .prop,则 writableconfigurable 都为 true

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 也不会被忠实地复制

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

前面提到的 Object.getOwnPropertyDescriptors()Object.defineProperties() 始终会完整地传递自身属性及其所有特性(如下所示)。

6.2.1.6 复制是浅层的

副本中每个键值对都有原始数据的新版本,但原始数据的 value 本身不会被复制。例如

const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {...original};

// Property .name is a copy: changing the copy
// does not affect the original
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: changing the copy
// affects the original
copy.work.employer = 'Spectre';
assert.deepEqual(
  original, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(
  copy, {name: 'John', work: {employer: 'Spectre'}});

我们将在本章后面介绍深拷贝。

6.2.2 通过 Object.assign() 进行浅拷贝(可选)

Object.assign() 的工作原理与将数据展开到对象中基本相同。也就是说,以下两种复制方式基本等效

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

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

不过,Object.assign() 与展开运算符并不完全相同。它们在一个相对微妙的点上有所不同:创建属性的方式不同。

除其他外,赋值会调用自身和继承的 setter,而定义则不会(有关赋值与定义的更多信息)。这种差异很少被注意到。下面的代码是一个例子,但它是人为设计的

const original = {['__proto__']: null}; // (A)
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);

通过在 A 行使用计算属性键,我们将 .__proto__ 创建为一个自身属性,并且不调用继承的 setter。但是,当 Object.assign() 复制该属性时,它会调用 setter。(有关 .__proto__ 的更多信息,请参阅 “面向急性子的程序员的 JavaScript”。)

6.2.3 通过 Object.getOwnPropertyDescriptors()Object.defineProperties() 进行浅拷贝(可选)

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

6.3 JavaScript 中的深拷贝

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

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

6.3.2 技巧:通过 JSON 进行通用的深拷贝

这是一种技巧,但在紧急情况下,它提供了一种快速解决方案:为了深拷贝对象 original,我们首先将其转换为 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({
    // Symbols are not supported as keys
    [Symbol('a')]: 'abc',
    // Unsupported value
    b: function () {},
    // Unsupported value
    c: undefined,
  }),
  {} // empty object
);

其他则会导致异常

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

6.3.3 实现通用的深拷贝

以下函数可以对值 original 进行通用的深拷贝

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

该函数处理三种情况

让我们试试 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() 只解决了展开运算符的一个问题:浅拷贝。其他问题仍然存在:原型不会被复制,特殊对象只会被部分复制,不可枚举的属性会被忽略,大多数属性特性会被忽略。

完全通用的复制实现通常是不可能的:并非所有数据都是树形结构,有时我们不想复制所有属性,等等。

6.3.3.1 更简洁的 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;
  }
}

6.4 扩展阅读