在 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 改變時,obj2 的 foo 也跟著改變了。為什麼會這樣呢?
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.stringify 及 JSON.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時也會自動忽略,以及無法處理物件循環參考的情況。
如果想要更進一步的實現完美的深拷貝,就必須由開發者自行撰寫(或使用其他開發者寫好的套件)。
實作#
那麼我們今天就來實作個深拷貝;首先來思考需要注意的規格:
- 完全複製整個物件,包含 key 為
symbol或值為undefined的屬性。 - 考慮物件循環引用的可能性。
考慮到第一點,撰寫時需要使用 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