深刻深刻再深刻 js 深拷貝對象

前言

對象是 JS 中基本類型之一,並且和原型鏈、數組等知識息息相關。不論是面試中,仍是實際開發中咱們都會遇見深拷貝對象的問題。前端

顧名思義,深拷貝就是完完整整的將一個對象從內存中拷貝一份出來。因此不管用什麼辦法,必然繞不開開闢一塊新的內存空間。node

一般有下面兩種方法實現深拷貝:git

  1. 迭代遞歸法
  2. 序列化反序列化法

咱們會基於一個測試用例對經常使用的實現方法進行測試並對比優劣:github

let test = {
    num: 0,
    str: '',
    boolean: true,
    unf: undefined,
    nul: null,
    obj: {
        name: '我是一個對象',
        id: 1
    },
    arr: [0, 1, 2],
    func: function() {
        console.log('我是一個函數')
    },
    date: new Date(0),
    reg: new RegExp('/我是一個正則/ig'),
    err: new Error('我是一個錯誤')
}

let result = deepClone(test)

console.log(result)
for (let key in result) {
    if (isObject(result[key]))
        console.log(`${key}相同嗎? `, result[key] === test[key])
}

// 判斷是否爲對象
function isObject(o) {
    return (typeof o === 'object' || typeof o === 'function') && o !== null
}
複製代碼

1. 迭代遞歸法

這是最常規的方法,思想很簡單:就是對對象進行迭代操做,對它的每一個值進行遞歸深拷貝。面試

// 迭代遞歸法:深拷貝對象與數組
function deepClone(obj) {
    if (!isObject(obj)) {
        throw new Error('obj 不是一個對象!')
    }

    let isArray = Array.isArray(obj)
    let cloneObj = isArray ? [] : {}
    for (let key in obj) {
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
    }

    return cloneObj
}
複製代碼
結果:
複製代碼

迭代遞歸法結果.png

咱們發現,arr 和 obj 都深拷貝成功了,它們的內存引用已經不一樣了,但 func、date、reg 和 err 並無複製成功,由於它們有特殊的構造函數。
複製代碼
  • Reflect 法

// 代理法
function deepClone(obj) {
    if (!isObject(obj)) {
        throw new Error('obj 不是一個對象!')
    }

    let isArray = Array.isArray(obj)
    let cloneObj = isArray ? [...obj] : { ...obj }
    Reflect.ownKeys(cloneObj).forEach(key => {
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
    })

    return cloneObj
}
複製代碼
結果:
複製代碼

代理法結果

咱們發現,結果和使用 for...in 同樣。那麼它有什麼優勢呢?讀者能夠先猜一猜,答案咱們會在下文揭曉。
複製代碼
  • lodash 中的深拷貝

    著名的 lodash 中的 cloneDeep 方法一樣是使用這種方法實現的,只不過它支持的對象種類更多,具體的實現過程讀者能夠參考 lodash 的 baseClone 方法算法

    咱們把測試用例用到的深拷貝函數換成 lodash 的:數組

    let result = _.cloneDeep(test)
    複製代碼

    結果: bash

    lodash深拷貝結果.png

    咱們發現,arr、obj、date、reg深拷貝成功了,但 func 和 err 內存引用仍然不變。ide

    爲何不變呢?這個問題留給讀者本身去探尋,嘿嘿~不過能夠提示下,這跟 lodash 中的 cloneableTags 有關。函數

    因爲前端中的對象種類太多了,因此 lodash 也給用戶準備了自定義深拷貝的方法 cloneDeepWith,好比自定義深拷貝 DOM 對象:

    function customizer(value) {
      if (_.isElement(value)) {
        return value.cloneNode(true);
      }
    }
    
    var el = _.cloneDeepWith(document.body, customizer);
     
    console.log(el === document.body);
    // => false
    console.log(el.nodeName);
    // => 'BODY'
    console.log(el.childNodes.length);
    // => 20
    複製代碼

2.序列化反序列化法

這個方法很是有趣,它先把代碼序列化成數據,再反序列化回對象:

// 序列化反序列化法
function deepClone(obj) {
    return JSON.parse(JSON.stringify(obj))
}
複製代碼

結果:

序列化反序列化法結果.png
咱們發現,它也只能深拷貝對象和數組,對於其餘種類的對象,會失真。這種方法比較適合日常開發中使用,由於一般不須要考慮對象和數組以外的類型。

深刻深刻再深刻

  1. 對象成環怎麼辦? 咱們給 test 加一個 loopObj 鍵,值指向自身:

    test.loopObj = test
    複製代碼

    這時咱們使用第一種方法中的 for..in 實現和 Reflect 實現都會棧溢出:

    環對象深拷貝報錯

    而使用第二種方法也會報錯:

    但 lodash 卻能夠獲得正確結果:

    lodash 深拷貝環對象.png

    爲何呢?咱們去 lodash 源碼看看:

    lodash 應對環對象辦法.png

    由於 lodash 使用的是棧把對象存儲起來了,若是有環對象,就會從棧裏檢測到,從而直接返回結果,回頭是岸。這種算法思想來源於 HTML5 規範定義的結構化克隆算法,它同時也解釋了爲何 lodash 不對 Error 和 Function 類型進行拷貝。

    固然,設置一個哈希表存儲已拷貝過的對象一樣能夠達到一樣的目的:

    function deepClone(obj, hash = new WeakMap()) {
        if (!isObject(obj)) {
            return obj
        }
        // 查表
        if (hash.has(obj)) return hash.get(obj)
    
        let isArray = Array.isArray(obj)
        let cloneObj = isArray ? [] : {}
        // 哈希表設值
        hash.set(obj, cloneObj)
    
        let result = Object.keys(obj).map(key => {
            return {
                [key]: deepClone(obj[key], hash)
            }
        })
        return Object.assign(cloneObj, ...result)
    }
    複製代碼

    這裏咱們使用 WeakMap 做爲哈希表,由於它的鍵是弱引用的,而咱們這個場景裏鍵剛好是對象,須要弱引用。

  2. 鍵不是字符串而是 Symbol

    咱們修改一下測試用例:

    var test = {}
    let sym = Symbol('我是一個Symbol')
    test[sym] = 'symbol'
    
    let result = deepClone(test)
    console.log(result)
    console.log(result[sym] === test[sym])
    複製代碼

    運行 for...in 實現的深拷貝咱們會發現:

    拷貝失敗了,爲何?

    由於 Symbol 是一種特殊的數據類型,它最大的特色即是獨一無二,因此它的深拷貝就是淺拷貝。

    但若是這時咱們使用 Reflect 實現的版本:

    成功了,由於 for...in 沒法得到 Symbol 類型的鍵,而 Reflect 是能夠獲取的。

    固然,咱們改造一下 for...in 實現也能夠:

    function deepClone(obj) {
        if (!isObject(obj)) {
            throw new Error('obj 不是一個對象!')
        }
    
        let isArray = Array.isArray(obj)
        let cloneObj = isArray ? [] : {}
        let symKeys = Object.getOwnPropertySymbols(obj)
        // console.log(symKey)
        if (symKeys.length > 0) {
            symKeys.forEach(symKey => {
                cloneObj[symKey] =  isObject(obj[symKey]) ? deepClone(obj[symKey]) : obj[symKey]
            })
        }
        for (let key in obj) {
            cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
        }
    
        return cloneObj
    }
    複製代碼
  3. 拷貝原型上的屬性

    衆所周知,JS 對象是基於原型鏈設計的,因此當一個對象的屬性查找不到時會沿着它的原型鏈向上查找,也就是一個非構造函數對象的 __proto__ 屬性。

    咱們建立一個 childTest 變量,讓 result 爲它的深拷貝結果,其餘不變:

    let childTest = Object.create(test)
    let result = deepClone(childTest)
    複製代碼

    這時,咱們最初提供的四種實現只有 for...in 的實現能正確拷貝,爲何呢?緣由仍是在結構化克隆算法裏:原形鏈上的屬性也不會被追蹤以及複製。

    落在具體實現上就是:for...in 會追蹤原型鏈上的屬性,而其它三種方法(Object.keys、Reflect.ownKeys 和 JSON 方法)都不會追蹤原型鏈上的屬性:

  4. 須要拷貝不可枚舉的屬性

    第四種狀況,就是咱們須要拷貝相似屬性描述符,setters 以及 getters 這樣不可枚舉的屬性,通常來講,這就須要一個額外的不可枚舉的屬性集合來存儲它們。相似在第二種狀況使用 for...in 拷貝 Symbol 類型鍵時: 咱們給 test 變量裏的 obj 和 arr 屬性定義一下屬性描述符:

    Object.defineProperties(test, {
        'obj': {
            writable: false,
            enumerable: false,
            configurable: false
        },
        'arr': {
            get() {
                console.log('調用了get')
                return [1,2,3]
            },
            set(val) {
                console.log('調用了set')
            }
        }
    })
    複製代碼

    而後實現咱們的拷貝不可枚舉屬性的版本:

    function deepClone(obj, hash = new WeakMap()) {
        if (!isObject(obj)) {
            return obj
        }
        // 查表,防止循環拷貝
        if (hash.has(obj)) return hash.get(obj)
    
        let isArray = Array.isArray(obj)
        // 初始化拷貝對象
        let cloneObj = isArray ? [] : {}
        // 哈希表設值
        hash.set(obj, cloneObj)
        // 獲取源對象全部屬性描述符
        let allDesc = Object.getOwnPropertyDescriptors(obj)
        // 獲取源對象全部的 Symbol 類型鍵
        let symKeys = Object.getOwnPropertySymbols(obj)
        // 拷貝 Symbol 類型鍵對應的屬性
        if (symKeys.length > 0) {
            symKeys.forEach(symKey => {
                cloneObj[symKey] = isObject(obj[symKey]) ? deepClone(obj[symKey], hash) : obj[symKey]
            })
        }
    
        // 拷貝不可枚舉屬性,由於 allDesc 的 value 是淺拷貝,因此要放在前面
        cloneObj = Object.create(
            Object.getPrototypeOf(cloneObj),
            allDesc
        )
        // 拷貝可枚舉屬性(包括原型鏈上的)
        for (let key in obj) {
            cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key], hash) : obj[key];
        }
    
        return cloneObj
    }
    複製代碼

    結果:

結語

  1. 平常深拷貝,建議序列化反序列化方法。
  2. 面試時碰見面試官搞事情,寫一個能拷貝自身可枚舉、自身不可枚舉、自身 Symbol 類型鍵、原型上可枚舉、原型上不可枚舉、原型上的 Symol 類型鍵,循環引用也能夠拷的深拷貝函數:
// 將以前寫的 deepClone 函數封裝一下
function cloneDeep(obj) {
    let family = {}
    let parent = Object.getPrototypeOf(obj)

    while (parent != null) {
        family = completeAssign(deepClone(family), parent)
        parent = Object.getPrototypeOf(parent)
    }

    // 下面這個函數會拷貝全部自有屬性的屬性描述符,來自於 MDN
    // https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
    function completeAssign(target, ...sources) {
        sources.forEach(source => {
            let descriptors = Object.keys(source).reduce((descriptors, key) => {
                descriptors[key] = Object.getOwnPropertyDescriptor(source, key)
                return descriptors
            }, {})

            // Object.assign 默認也會拷貝可枚舉的Symbols
            Object.getOwnPropertySymbols(source).forEach(sym => {
                let descriptor = Object.getOwnPropertyDescriptor(source, sym)
                if (descriptor.enumerable) {
                    descriptors[sym] = descriptor
                }
            })
            Object.defineProperties(target, descriptors)
        })
        return target
    }

    return completeAssign(deepClone(obj), family)
}

複製代碼
  1. 有特殊需求的深拷貝,建議使用 lodash 的 copyDeep 或 copyDeepWith 方法。

    最後感謝一下知乎上關於這個問題的啓發,不管作什麼,儘可能不要把簡單的事情複雜化,深拷貝能不用就不用,它面對的問題每每能夠用更優雅的方式解決,好比使用一個函數來獲得對象,固然面試的時候裝個逼是能夠的。

相關文章
相關標籤/搜索