深刻 js 深拷貝對象

前言

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

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

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

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

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

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 } 

迭代遞歸法

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

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 } 

結果:github


 
迭代遞歸法結果.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 方法bash

咱們把測試用例用到的深拷貝函數換成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
來源:簡書
簡書著做權歸做者全部,任何形式的轉載都請聯繫做者得到受權並註明出處。
 
相關文章
相關標籤/搜索