深入深入再深入 js 深拷貝物件

雲峰yf發表於2018-04-18

前言

物件是 JS 中基本型別之一,而且和原型鏈、陣列等知識息息相關。不管是面試中,還是實際開發中我們都會碰見深拷貝物件的問題。

顧名思義,深拷貝就是完完整整的將一個物件從記憶體中拷貝一份出來。所以無論用什麼辦法,必然繞不開開闢一塊新的記憶體空間。

通常有下面兩種方法實現深拷貝:

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

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

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)
    複製程式碼

    結果:

    lodash深拷貝結果.png

    我們發現,arr、obj、date、reg深拷貝成功了,但 func 和 err 記憶體引用仍然不變。

    為什麼不變呢?這個問題留給讀者自己去探尋,嘿嘿~不過可以提示下,這跟 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 實現都會棧溢位:

    環物件深拷貝報錯

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

    深入深入再深入 js 深拷貝物件

    但 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 實現的深拷貝我們會發現:

    深入深入再深入 js 深拷貝物件

    拷貝失敗了,為什麼?

    因為 Symbol 是一種特殊的資料型別,它最大的特點便是獨一無二,所以它的深拷貝就是淺拷貝。

    但如果這時我們使用 Reflect 實現的版本:

    深入深入再深入 js 深拷貝物件

    成功了,因為 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 方法)都不會追蹤原型鏈上的屬性:

    深入深入再深入 js 深拷貝物件

  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
    }
    複製程式碼

    結果:

    深入深入再深入 js 深拷貝物件

結語

  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 方法。

    最後感謝一下知乎上關於這個問題的啟發,無論做什麼,儘量不要把簡單的事情複雜化,深拷貝能不用就不用,它面對的問題往往可以用更優雅的方式解決,比如使用一個函式來得到物件,當然面試的時候裝個逼是可以的。

相關文章