一文帶你詳細瞭解JavaScript中的深拷貝

2022-10-21 22:01:40

前端(vue)入門到精通課程:進入學習
Apipost = Postman + Swagger + Mock + Jmeter 超好用的API偵錯工具:

網上有很多關於深拷貝的文章,但是品質良莠不齊,有很多都考慮得不周到,寫的方法比較簡陋,難以令人滿意。本文旨在完成一個完美的深拷貝,大家看了如果有問題,歡迎一起補充完善。

評價一個深拷貝是否完善,請檢查以下問題是否都實現了:

  • 基本型別資料是否能拷貝?

  • 鍵和值都是基本型別的普通物件是否能拷貝?

  • Symbol作為物件的key是否能拷貝?

  • DateRegExp物件型別是否能拷貝?

  • MapSet物件型別是否能拷貝?

  • Function物件型別是否能拷貝?(函數我們一般不用深拷貝)

  • 物件的原型是否能拷貝?

  • 不可列舉屬性是否能拷貝?

  • 迴圈參照是否能拷貝?

怎樣?你寫的深拷貝夠完善嗎?

深拷貝的最終實現

這裡先直接給出最終的程式碼版本,方便想快速瞭解的人檢視,當然,你想一步步瞭解可以繼續檢視文章餘下的內容:

function deepClone(target) {
    const map = new WeakMap()
    
    function isObject(target) {
        return (typeof target === 'object' && target ) || typeof target === 'function'
    }

    function clone(data) {
        if (!isObject(data)) {
            return data
        }
        if ([Date, RegExp].includes(data.constructor)) {
            return new data.constructor(data)
        }
        if (typeof data === 'function') {
            return new Function('return ' + data.toString())()
        }
        const exist = map.get(data)
        if (exist) {
            return exist
        }
        if (data instanceof Map) {
            const result = new Map()
            map.set(data, result)
            data.forEach((val, key) => {
                if (isObject(val)) {
                    result.set(key, clone(val))
                } else {
                    result.set(key, val)
                }
            })
            return result
        }
        if (data instanceof Set) {
            const result = new Set()
            map.set(data, result)
            data.forEach(val => {
                if (isObject(val)) {
                    result.add(clone(val))
                } else {
                    result.add(val)
                }
            })
            return result
        }
        const keys = Reflect.ownKeys(data)
        const allDesc = Object.getOwnPropertyDescriptors(data)
        const result = Object.create(Object.getPrototypeOf(data), allDesc)
        map.set(data, result)
        keys.forEach(key => {
            const val = data[key]
            if (isObject(val)) {
                result[key] = clone(val)
            } else {
                result[key] = val
            }
        })
        return result
    }

    return clone(target)
}
登入後複製

1. JavaScript資料型別的拷貝原理

先看看JS資料型別圖(除了Object,其他都是基礎型別):
JS資料類型
在JavaScript中,基礎型別值的複製是直接拷貝一份新的一模一樣的資料,這兩份資料相互獨立,互不影響。而參照型別值(Object型別)的複製是傳遞物件的參照(也就是物件所在的記憶體地址,即指向物件的指標),相當於多個變數指向同一個物件,那麼只要其中的一個變數對這個物件進行修改,其他的變數所指向的物件也會跟著修改(因為它們指向的是同一個物件)。如下圖:
類型的賦值

2. 深淺拷貝

深淺拷貝主要針對的是Object型別,基礎型別的值本身即是複製一模一樣的一份,不區分深淺拷貝。這裡我們先給出測試的拷貝物件,大家可以拿這個obj物件來測試一下自己寫的深拷貝函數是否完善:

// 測試的obj物件
const obj = {
    // =========== 1.基礎資料型別 ===========
    num: 0, // number
    str: '', // string
    bool: true, // boolean
    unf: undefined, // undefined
    nul: null, // null
    sym: Symbol('sym'), // symbol
    bign: BigInt(1n), // bigint

    // =========== 2.Object型別 ===========
    // 普通物件
    obj: {
        name: '我是一個物件',
        id: 1
    },
    // 陣列
    arr: [0, 1, 2],
    // 函數
    func: function () {
        console.log('我是一個函數')
    },
    // 日期
    date: new Date(0),
    // 正則
    reg: new RegExp('/我是一個正則/ig'),
    // Map
    map: new Map().set('mapKey', 1),
    // Set
    set: new Set().add('set'),
    // =========== 3.其他 ===========
    [Symbol('1')]: 1  // Symbol作為key
};

// 4.新增不可列舉屬性
Object.defineProperty(obj, 'innumerable', {
    enumerable: false,
    value: '不可列舉屬性'
});

// 5.設定原型物件
Object.setPrototypeOf(obj, {
    proto: 'proto'
})

// 6.設定loop成迴圈參照的屬性
obj.loop = obj
登入後複製

obj物件在Chrome瀏覽器中的結果:

obj

2.1 淺拷貝

淺拷貝: 建立一個新的物件,來接受你要重新複製或參照的物件值。如果物件屬性是基本的資料型別,複製的就是基本型別的值給新物件;但如果屬性是參照資料型別,複製的就是記憶體中的地址,如果其中一個物件改變了這個記憶體中的地址所指向的物件,肯定會影響到另一個物件。

首先我們看看一些淺拷貝的方法(詳細瞭解可點選對應方法的超連結):

方法使用方式注意事項
Object.assign()Object.assign(target, ...sources)
說明:用於將所有可列舉屬性的值從一個或多個源物件分配到目標物件。它將返回目標物件。
1.不會拷貝物件的繼承屬性;
2.不會拷貝物件的不可列舉的屬性;
3.可以拷貝 Symbol 型別的屬性。
展開語法let objClone = { ...obj };缺陷和Object.assign()差不多,但是如果屬性都是基本型別的值,使用擴充套件運運算元進行淺拷貝會更加方便。
Array.prototype.concat()拷貝陣列const new_array = old_array.concat(value1[, value2[, ...[, valueN]]])淺拷貝,適用於基本型別值的陣列
Array.prototype.slice()拷貝陣列arr.slice([begin[, end]])淺拷貝,適用於基本型別值的陣列

這裡只列舉了常用的幾種方式,除此之外當然還有其他更多的方式。注意,我們直接使用=賦值不是淺拷貝,因為它是直接指向同一個物件了,並沒有返回一個新物件。

手動實現一個淺拷貝:

function shallowClone(target) {
    if (typeof target === 'object' && target !== null) {
        const cloneTarget = Array.isArray(target) ? [] : {};
        for (let prop in target) {
            if (target.hasOwnProperty(prop)) {
                cloneTarget[prop] = target[prop];
            }
        }
        return cloneTarget;
    } else {
        return target;
    }
}


// 測試
const shallowCloneObj = shallowClone(obj)

shallowCloneObj === obj  // false,返回的是一個新物件
shallowCloneObj.arr === obj.arr  // true,對於物件型別只拷貝了參照
登入後複製

從上面這段程式碼可以看出,利用型別判斷(檢視typeof),針對參照型別的物件進行 for 迴圈遍歷物件屬性賦值給目標物件的屬性(for...in語句以任意順序遍歷一個物件的除Symbol以外的可列舉屬性,包含原型上的屬性。檢視for…in),基本就可以手工實現一個淺拷貝的程式碼了。

2.2 深拷貝

深拷貝:建立一個新的物件,將一個物件從記憶體中完整地拷貝出來一份給該新物件,並從堆記憶體中開闢一個全新的空間存放新物件,且新物件的修改並不會改變原物件,二者實現真正的分離。

看看現存的一些深拷貝的方法:

方法1:JSON.stringify()

JSON.stringfy() 其實就是將一個 JavaScript 物件或值轉換為 JSON 字串,最後再用 JSON.parse() 的方法將JSON 字串生成一個新的物件。(點這瞭解:JSON.stringfy()、JSON.parse())

使用如下:

function deepClone(target) {
    if (typeof target === 'object' && target !== null) {
        return JSON.parse(JSON.stringify(target));
    } else {
        return target;
    }
}

// 開頭的測試obj存在BigInt型別、迴圈參照,JSON.stringfy()執行會報錯,所以除去這兩個條件進行測試
const clonedObj = deepClone(obj)

// 測試
clonedObj === obj  // false,返回的是一個新物件
clonedObj.arr === obj.arr  // false,說明拷貝的不是參照
登入後複製

瀏覽器執行結果:

瀏覽器執行JSON.stringify()結果
從以上結果我們可知JSON.stringfy() 存在以下一些問題:

  • 執行會報錯:存在BigInt型別、迴圈參照。

  • 拷貝Date參照型別會變成字串。

  • 鍵值會消失:物件的值中為FunctionUndefinedSymbol 這幾種型別,。

  • 鍵值變成空物件:物件的值中為MapSetRegExp這幾種型別。

  • 無法拷貝:不可列舉屬性、物件的原型鏈。

  • 補充:其他更詳細的內容請檢視官方檔案:JSON.stringify()

由於以上種種限制條件,JSON.stringfy() 方式僅限於深拷貝一些普通的物件,對於更復雜的資料型別,我們需要另尋他路。

方法2:遞迴基礎版深拷貝

手動遞迴實現深拷貝,我們只需要完成以下2點即可:

  • 對於基礎型別,我們只需要簡單地賦值即可(使用=)。

  • 對於參照型別,我們需要建立新的物件,並通過遍歷鍵來賦值對應的值,這個過程中如果遇到 Object 型別還需要再次進行遍歷。

function deepClone(target) {
    if (typeof target === 'object' && target) {
        let cloneObj = {}
        for (const key in target) { // 遍歷
            const val = target[key]
            if (typeof val === 'object' && val) {
                cloneObj[key] = deepClone(val) // 是物件就再次呼叫該函數遞迴
            } else {
                cloneObj[key] = val // 基本型別的話直接複製值
            }
        }
        return cloneObj
    } else {
        return target;
    }
}

// 開頭的測試obj存在迴圈參照,除去這個條件進行測試
const clonedObj = deepClone(obj)

// 測試
clonedObj === obj  // false,返回的是一個新物件
clonedObj.arr === obj.arr  // false,說明拷貝的不是參照
登入後複製

瀏覽器執行結果:

簡版深拷貝
該基礎版本存在許多問題:

  • 不能處理迴圈參照。

  • 只考慮了Object物件,而Array物件、Date物件、RegExp物件、Map物件、Set物件都變成了Object物件,且值也不正確。

  • 丟失了屬性名為Symbol型別的屬性。

  • 丟失了不可列舉的屬性。

  • 原型上的屬性也被新增到拷貝的物件中了。

如果存在迴圈參照的話,以上程式碼會導致無限遞迴,從而使得堆疊溢位。如下例子:

const a = {}
const b = {}
a.b = b
b.a = a
deepClone(a)
登入後複製

物件 a 的鍵 b 指向物件 b,物件 b 的鍵 a 指向物件 a,檢視a物件,可以看到是無限迴圈的:
在這裡插入圖片描述
對物件a執行深拷貝,會出現死迴圈,從而耗盡記憶體,進而報錯:堆疊溢位
在這裡插入圖片描述
如何避免這種情況呢?一種簡單的方式就是把已新增的物件記錄下來,這樣下次碰到相同的物件參照時,直接指向記錄中的物件即可。要實現這個記錄功能,我們可以藉助 ES6 推出的 WeakMap 物件,該物件是一組鍵/值對的集合,其中的鍵是弱參照的。其鍵必須是物件,而值可以是任意的。(WeakMap相關見這:WeakMap)

針對以上基礎版深拷貝存在的缺陷,我們進一步去完善,實現一個完美的深拷貝

方法3:遞迴完美版深拷貝

對於基礎版深拷貝存在的問題,我們一一改進:

存在的問題改進方案
1. 不能處理迴圈參照使用 WeakMap 作為一個Hash表來進行查詢
2. 只考慮了Object物件當引數為 DateRegExpFunctionMapSet,則直接生成一個新的範例返回
3. 屬性名為Symbol的屬性
4. 丟失了不可列舉的屬性
針對能夠遍歷物件的不可列舉屬性以及 Symbol 型別,我們可以使用 Reflect.ownKeys()
Reflect.ownKeys(obj)相當於[...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)]
4. 原型上的屬性Object.getOwnPropertyDescriptors()設定屬性描述物件,以及Object.create()方式繼承原型鏈

程式碼實現:

function deepClone(target) {
    // WeakMap作為記錄物件Hash表(用於防止迴圈參照)
    const map = new WeakMap()

    // 判斷是否為object型別的輔助函數,減少重複程式碼
    function isObject(target) {
        return (typeof target === 'object' && target ) || typeof target === 'function'
    }

    function clone(data) {

        // 基礎型別直接返回值
        if (!isObject(data)) {
            return data
        }

        // 日期或者正則物件則直接構造一個新的物件返回
        if ([Date, RegExp].includes(data.constructor)) {
            return new data.constructor(data)
        }

        // 處理常式物件
        if (typeof data === 'function') {
            return new Function('return ' + data.toString())()
        }

        // 如果該物件已存在,則直接返回該物件
        const exist = map.get(data)
        if (exist) {
            return exist
        }

        // 處理Map物件
        if (data instanceof Map) {
            const result = new Map()
            map.set(data, result)
            data.forEach((val, key) => {
                // 注意:map中的值為object的話也得深拷貝
                if (isObject(val)) {
                    result.set(key, clone(val))
                } else {
                    result.set(key, val)
                }
            })
            return result
        }

        // 處理Set物件
        if (data instanceof Set) {
            const result = new Set()
            map.set(data, result)
            data.forEach(val => {
                // 注意:set中的值為object的話也得深拷貝
                if (isObject(val)) {
                    result.add(clone(val))
                } else {
                    result.add(val)
                }
            })
            return result
        }

        // 收集鍵名(考慮了以Symbol作為key以及不可列舉的屬性)
        const keys = Reflect.ownKeys(data)
        // 利用 Object 的 getOwnPropertyDescriptors 方法可以獲得物件的所有屬性以及對應的屬性描述
        const allDesc = Object.getOwnPropertyDescriptors(data)
        // 結合 Object 的 create 方法建立一個新物件,並繼承傳入原物件的原型鏈, 這裡得到的result是對data的淺拷貝
        const result = Object.create(Object.getPrototypeOf(data), allDesc)

        // 新物件加入到map中,進行記錄
        map.set(data, result)

        // Object.create()是淺拷貝,所以要判斷並遞迴執行深拷貝
        keys.forEach(key => {
            const val = data[key]
            if (isObject(val)) {
                // 屬性值為 物件型別 或 函數物件 的話也需要進行深拷貝
                result[key] = clone(val)
            } else {
                result[key] = val
            }
        })
        return result
    }

    return clone(target)
}



// 測試
const clonedObj = deepClone(obj)
clonedObj === obj  // false,返回的是一個新物件
clonedObj.arr === obj.arr  // false,說明拷貝的不是參照
clonedObj.func === obj.func  // false,說明function也複製了一份
clonedObj.proto  // proto,可以取到原型的屬性
登入後複製

詳細的說明見程式碼中的註釋,更多測試希望大家自己動手嘗試驗證一下以加深印象。

在遍歷 Object 型別資料時,我們需要把 Symbol 型別的鍵名也考慮進來,所以不能通過 Object.keys 獲取鍵名或 for...in 方式遍歷,而是通過Reflect.ownKeys()獲取所有自身的鍵名(getOwnPropertyNamesgetOwnPropertySymbols 函數將鍵名組合成陣列也行:[...Object.getOwnPropertyNames(obj), ...Object.getOwnPropertySymbols(obj)]),然後再遍歷遞迴,最終實現拷貝。

瀏覽器執行結果:
完美版深拷貝
可以發現我們的cloneObj物件和原來的obj物件一模一樣,並且修改cloneObj物件的各個屬性都不會對obj物件造成影響。其他的大家再多嘗試體會哦!

【相關推薦:、】

以上就是一文帶你詳細瞭解JavaScript中的深拷貝的詳細內容,更多請關注TW511.COM其它相關文章!