在寫JavaScript程式的時候經常看到一些奇妙的程式碼被用來處理物件的複製,這篇文章會列舉筆者看到的一些作法,並提供較好的作法給大家參考。
在這篇文章開始之前,先來講一下為什麼「複製物件」這個看似基本且簡單的事情會有問題好了。
首先看看以下的程式碼:
let a = 0;
let b = a;
a = 1;
console.log(b);
以上程式,將0
指派給變數a
儲存,然後又將變數a
的值指派給變數b
儲存,之後指派1
給變數a
儲存,此時的變數b
所儲存的值是什麼?很明顯,就是變數a
一開始的0
。
那麼將以上程式改成以下這樣:
let a = [0];
let b = a;
a = [1];
console.log(b);
此時的變數b
會是什麼呢?相信絕大多數的人都能回答得出來,就是變數a
一開始的[0]
。
再將以上程式改成以下這樣:
let a = [0];
let b = a;
a[0] = 1;
console.log(b);
以上程式的第四行,將變數a
所儲存的陣列的索引0
的位置的元素值改成1
,變數b
會有變化嗎?會,變數b
此時是[1]
。所以變數a
和變數b
其實是一樣的陣列物件囉……?沒錯,所以不管是去改a
還是改b
的元素,都會影響到對方。
所以現在我們就遭遇到了第一個困難:要怎麼複製陣列物件,讓變數a
和變數b
是不一樣的陣列物件,使它們不會互相影響呢?
相信聰明的讀者很快就會想到,我們可以先指派一個空陣列給變數b
,再把變數a
的陣列元素一個一個放進變數b
的陣列,放完之後不就是兩個沒有關聯的陣列了嗎?程式如下:
let a = [0];
let b = [];
for (const e of a) {
b.push(e);
}
a[0] = 1;
console.log(b);
以上程式,最終,變數b
依然還是[0]
。我們成功了實現陣列的複製!
甚至我們可以利用...
蔓延語法(Spread Syntax)來快速填入元素,如下:
let a = [0];
let b = [...a];
a[0] = 1;
console.log(b);
我們再來看看下面的程式碼:
let a = [[0]];
let b = [];
for (const e of a) {
b.push(e);
}
a[0][0] = 1;
console.log(b);
以上程式中,變數a
和變數b
已經利用複製陣列的作法使它們成為不同的陣列,然而最終,變數b
還是變成[[1]]
了。這是因為我們雖然複製了最外層的陣列,但裡面的元素卻沒有跟著被複製。此時的a[0]
和b[0]
其實是一樣的陣列物件。
所以現在我們就遭遇到了第二個困難:要怎麼複製陣列物件,讓裡面就算有物件型別(Object)的元素,變數a
和變數b
依然還是不一樣的陣列物件,不會互相影響呢?
事實上,我們剛才實作出來的複製陣列的程式屬於淺複製(shallow copy),建立了新的記憶體空間,只複製了最外層的值,若最外層的值是物件的參考,則同樣會指到相同的物件。要做到完全複製,需要一一去看每層的的值,屬於深複製(deep copy)。
深複製
利用序列化再反序列化的方式可以輕易地做到深複製。如下:
const a = [[0]];
const b = JSON.parse(JSON.stringify(a));
a[0][0] = 1;
console.log(b);
以上方式雖然實作起來最簡單,但因要序列化成JSON格式的字串而導致效能會隨著資料量愈多而顯著變差,且只能支援JSON有的型別,像是NaN
、Infinity
、undefined
和Function
等就無法複製成功(NaN
、Infinity
會變成null
,其它JSON不支援的型別則會被忽略)。JSON.stringify
也不支援有循環參考的資料。
如果JavaScript程式只會運行在Node.js環境上,倒是可以改寫成以下這樣來支援更多的型別(function
、symbol
不能被複製,會拋出錯誤),效能不會太差,可以支援有循環參考的資料。
import v8 from "node:v8";
const a = [[0]];
const b = v8.deserialize(v8.serialize(a));
a[0][0] = 1;
console.log(b);
ES2021之後可以改用structuredClone
函數。
const a = [[0]];
const b = structuredClone(a);
a[0][0] = 1;
console.log(b);
不過不管是什麼場合,都不太建議用序列化再反序列化的方式來做複製。最好的辦法還是根據複製需求,來撰寫我們需要的程式。
如果是使用TypeScript,還能撰寫相對安全且能重複使用的函數,因為我們可以利用TypeScript的型別檢查機制來確保被傳入函數的參數的型別是我們預期的。如下:
const cloneArrayOfArraysOfNumbers = (array: (number[])[]) => {
return array.map((e) => [...e]);
};
const a = [[0]];
const b = cloneArrayOfArraysOfNumbers(a);
a[0][0] = 1;
console.log(b);
如果您還是需要一個最通用且支援所有型別的深複製函數,也不用自己造車,直接使用Lodash提供的cloneDeep
函數即可。不過這個作法甚至會比用JSON做序列化再反序列化還要更慢。程式如下:
import { cloneDeep } from "lodash";
const a = [[0]];
const b = cloneDeep(a);
a[0][0] = 1;
console.log(b);
即便是Lodash提供的cloneDeep
函數也還是不能複製物件的屬性的特性(即writable
、enumerable
等Property Descriptor內的選項)。
至於在網路上您可能會搜索到其它類似以下的深複製函數,筆者就不建議使用了,因為效能不會比Lodash提供的cloneDeep
函數好,可能還有一些潛在的問題。
// 不要用,這是爛Code!
const cloneDeep = (obj, cache = new WeakMap()) => {
switch (typeof obj) {
case "object":
if (obj === null) {
return null;
} else if (cache.has(obj)) {
return cache.get(obj);
} else {
const newObj = new obj.constructor();
cache.set(obj, newObj);
[...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)].forEach(key => {
newObj[key] = cloneDeep(obj[key], cache);
});
return newObj;
}
default:
return obj;
}
};
如果您只需要簡單支援number
、string
等基本型別,以及常用的Date
和RegExp
等不可變的(immutable)物件所組合而成的陣列或是物件結構,可以試試以下的函數,再依照您自己的需求去修改(例如把Date
和RegExp
移除,不去判斷它們;或是增加更多的不可變的物件的判別)。
const cloneDeep = (obj, cache = new WeakMap()) => {
switch (typeof obj) {
case "object":
if (obj === null || obj instanceof Date || obj instanceof RegExp) {
return obj;
} else if (cache.has(obj)) {
return cache.get(obj);
} else {
let newObj;
if (Array.isArray(obj)) {
newObj = new Array(obj.length);
cache.set(obj, newObj);
obj.forEach((value, index) => {
newObj[index] = cloneDeep(value, cache);
});
} else {
newObj = {};
cache.set(obj, newObj);
for (const key of Object.keys(obj)) {
newObj[key] = cloneDeep(obj[key], cache);
}
}
return newObj;
}
default:
return obj;
}
};
以下是將以上程式移除掉WeakMap
後的版本,執行速度會快一些,但不支援循環參考。
const cloneDeep = (obj) => {
switch (typeof obj) {
case "object":
if (obj === null || obj instanceof Date || obj instanceof RegExp) {
return obj;
} else if (Array.isArray(obj)) {
return obj.map((e) => cloneDeep(e));
} else {
const newObj = {};
for (const key of Object.keys(obj)) {
newObj[key] = cloneDeep(obj[key]);
}
return newObj;
}
default:
return obj;
}
};