在寫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有的型別,像是NaNInfinityundefinedFunction等就無法複製成功(NaNInfinity會變成null,其它JSON不支援的型別則會被忽略)。JSON.stringify也不支援有循環參考的資料。

如果JavaScript程式只會運行在Node.js環境上,倒是可以改寫成以下這樣來支援更多的型別(functionsymbol不能被複製,會拋出錯誤),效能不會太差,可以支援有循環參考的資料。

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函數也還是不能複製物件的屬性的特性(即writableenumerable等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;
    }
};

如果您只需要簡單支援numberstring等基本型別,以及常用的DateRegExp等不可變的(immutable)物件所組合而成的陣列或是物件結構,可以試試以下的函數,再依照您自己的需求去修改(例如把DateRegExp移除,不去判斷它們;或是增加更多的不可變的物件的判別)。

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