深入了解 JavaScript
請支持這本書:購買捐款
(廣告,請不要封鎖。)

6 複製物件和陣列



在本章中,我們將學習如何複製 JavaScript 中的物件和陣列。

6.1 淺層複製與深度複製

資料可以有兩種「深度」的複製方式

下一節將介紹兩種複製方式。很遺憾的是,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 不可列舉,而且不會被複製。在以下範例中,我們透過物件擴散複製陣列 arr(A 行)

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 複製是淺層的

副本具有原件中每個鍵值條目的最新版本,但原件的值本身並未複製。例如

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 引擎上透過函式庫進行多型填充。

不過,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 for impatient programmers”。)

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 進一步閱讀