深入 js 深拷貝對象


前言

對象是 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 } 

迭代遞歸法

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

for...in

// 迭代遞歸法:深拷貝對象與數組 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 

序列化反序列化法

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

// 序列化反序列化法 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 作為哈希表,因為它的鍵是弱引用的,而我們這個場景里鍵恰好是對象,需要弱引用。

  1. 鍵值不是字符串而是 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 } 
  1. 拷貝原型上的屬性

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

我們創建一個 childTest 變量,讓 result 為它的深拷貝結果,其他不變:

let childTest = Object.create(test) let result = deepClone(childTest) 

這時,我們最初提供的四種實現只有 for...in 的實現能正確拷貝,為什么呢?原因還是在結構化克隆算法里:原形鏈上的屬性也不會被追蹤以及復制。

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


 
 
  1. 需要拷貝不可枚舉的屬性
    第四種情況,就是我們需要拷貝類似屬性描述符,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 方法。

最后感謝一下知乎上關於這個問題的提問的啟發,無論做什么,盡量不要把簡單的事情復雜化,深拷貝能不用就不用,它面對的問題往往可以用更優雅的方式解決,當然面試的時候裝個逼是可以的。

 


作者:雲峰yf
鏈接:https://www.jianshu.com/p/b08bc61714c7
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯系作者獲得授權並注明出處。
 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM