在 JavaScript 這個萬物皆物件的語言中,完美複製物件是開發中頻繁出現的需求。本章探討深拷貝(Deep Clone)、淺拷貝(Shallow Copy)的實作與背後機制。

資料複製#

如果我們想要複製資料,而資料型態是 JavaScript 的基本型別,那麼我們可以直接這樣寫:

let num1 = 123;
let num2 = num1;
num1 = 456;
console.log(num2); // 123

上面的範例中,變數 num2 複製自變數 num1,從複製之後,num1 的值便再也不會和 num2 的數值有所關聯。

那如果複製的資料是物件呢?

let obj1 = {
  foo: "bar",
};
let obj2 = obj1;
obj1.foo = "changed";
console.log(obj2.foo); // changed

這次我們宣告了變數 obj2,複製自 obj1,但當 obj1 內的屬性 foo 改變時,obj2foo 也跟著改變了。為什麼會這樣呢?

Call by value#

這是因為 JavaScript 中,物件變數所儲存的值,實際上存的是物件所在的記憶體位置。

當我們將 obj2 賦值為 obj1 時,實際上複製的只有那個記憶體位置,但記憶體位置所儲存的物件,還是同一個,也因此當物件改變時,兩個變數都會被影響。

物件拷貝#

最直觀的方法,就是建立一個新物件,將原本資料全部的屬性都複製進去。例如我們可以透過 Object.assign,將物件全部的屬性倒過去:

let obj1 = {
  foo: "bar",
};
let obj2 = Object.assign({}, obj1);
obj1.foo = "changed";
console.log(obj2.foo); // bar

或著使用 ES9 的物件展開運算子,快速簡短的寫成這樣:

let obj1 = {
  foo: "bar",
};
let obj2 = { ...obj1 };
obj1.foo = "changed";
console.log(obj2.foo); // bar

但如果屬性的值也是物件,這種方法只能複製第一層屬性,無法複製到屬性值內的物件,因此稱為「淺拷貝」。

深拷貝#

有淺拷貝,自然就會有與之對應的「深拷貝」;實務上最快最簡單的方法,就是將物件依序經過 JSON.stringifyJSON.parse 兩個方法,建立全新的物件:

let obj1 = {
  foo: "bar",
  arr: [0, 1],
};
let obj2 = { ...obj1 };
let obj3 = JSON.parse(JSON.stringify(obj1));
obj1.foo = "changed";
obj1.arr[0] = 99;

console.log(obj2.foo); // bar
console.log(obj2.arr); // [99, 1]

console.log(obj3.foo); // bar
console.log(obj3.arr); // [0, 1]

如上面的範例,透過 JSON.parse(JSON.stringify(obj)) 的處理,物件屬性值的物件,甚至更深層的物件,都可以正確的被複製出來。

由於 JSON.stringify 的先天限制,有少部分特定情況是無法這樣處理的;雖然這不是一個最完美的解決方案,但通常這樣就足以應付絕大多數的使用情境了。

JSON.stringify 在處理物件時,處理結果可以被物件內的 toJSON 函式覆寫;另外預設的處理會自動過濾掉屬性 key 為 symbol 的值、當值為 undefined 時也會自動忽略,以及無法處理物件循環參考的情況。

如果想要更進一步的實現完美的深拷貝,就必須由開發者自行撰寫(或使用其他開發者寫好的套件)。

實作#

那麼我們今天就來實作個深拷貝;首先來思考需要注意的規格:

  1. 完全複製整個物件,包含 key 為 symbol 或值為 undefined 的屬性。
  2. 考慮物件循環引用的可能性。

考慮到第一點,撰寫時需要使用 typeof 判斷物件型別,並只對結果為 object 的屬性做額外處理;第二點則需要儲存一個物件的記憶體位置快取,將複製過的物件都快取起來。另外也由於物件屬性層層相依的關係,用遞迴撰寫會比較合適。

別忘了要特別考慮值為 null 的情況,typeof null 會得到 object

function deepCopy(obj, cache = new WeakMap()) {
  // 基本型別 & function
  if (obj === null || typeof obj !== "object") return obj;
  // Date 及 RegExp
  if (obj instanceof Date || obj instanceof RegExp) return obj.constructor(obj);
  // 檢查快取
  if (cache.has(obj)) return cache.get(obj);

  // 使用原物件的 constructor
  const copy = new obj.constructor();

  // 先放入 cache 中
  cache.set(obj, copy);
  // 取出所有一般屬性 & 所有 key 為 symbol 的屬性
  [
    ...Object.getOwnPropertyNames(obj),
    ...Object.getOwnPropertySymbols(obj),
  ].forEach((key) => {
    copy[key] = deepCopy(obj[key], cache);
  });

  return copy;
}

這樣就完成了深拷貝實作。先使用 typeof 過濾掉基本型別,接著判斷是否為 JavaScript 的原生物件;若都不是,再藉由 cache 暫存物件的參考,並將所有可能有的屬性過一次這個函式,遞迴的反覆進行,完成複製整個物件的過程。

結語#

本章從物件複製開始,逐步理解深、淺拷貝的差異與用法,並實作深拷貝函式,希望幫助讀者理解物件複製。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10223178