1. 程式人生 > >這一次,徹底理解JavaScript深拷貝

這一次,徹底理解JavaScript深拷貝

## 導語 這一次,通過本文徹底理解JavaScript深拷貝! 閱讀本文前可以先思考三個問題: - JS世界裡,資料是如何儲存的? - 深拷貝和淺拷貝的區別是什麼? - 如何寫出一個真正合格的深拷貝? 本文會一步步解答這三個問題 ## 資料是如何儲存的 先看一個問題,下面這段程式碼的輸出結果是什麼: ```javascript function foo(){ let a = {name:"dellyoung"} let b = a a.name = "dell" console.log(a) console.log(b) } foo() ``` ### JS的記憶體空間 要解答這個問題就要先了解,JS中資料是如何儲存的。 要理解JS中資料是如何儲存的,就要先明白其記憶體空間的種類。下圖就是JS的記憶體空間模型。 ![](https://user-gold-cdn.xitu.io/2020/2/24/17077e9152e2b5fc?w=1142&h=1183&f=png&s=75892) 從模型中我們可以看出JS記憶體空間分為:程式碼空間、棧空間、堆空間。 **程式碼空間**:程式碼空間主要是儲存可執行程式碼的。 **棧空間**:棧(`call stack`)指的就是呼叫棧,用來儲存執行上下文的。(每個執行上下文包括了:變數環境、詞法環境) **堆空間**:堆(`Heap`)空間,一般用來儲存物件的。 ### JS的資料型別 現在我們已經瞭解JS記憶體空間了。接下來我們瞭解一下JS中的資料型別 : ![](https://user-gold-cdn.xitu.io/2020/2/25/17078001c8ffdeed?w=1142&h=648&f=png&s=318030) JS中一共有8中資料型別:Number、BigInt、String、Boolean、Symble、Null、Undefined、Object。 前7種稱為**原始型別**,最後一種Object稱為**引用型別**,之所以把它們區分成兩種型別,是因為**它們在記憶體中存放的位置不同**。 原始型別存放在棧空間中,具體點到執行上下文來說就是:用var定義的變數會存放在變數環境中,而用let、const定義的變數會存放在詞法環境中。並且對原始型別來說存放的是值,而引用型別存放的是指標,指標指向堆記憶體中存放的真正內容。 好啦,現在我們就明白JS中資料是如何儲存的了:**原始型別存放在棧空間中,引用型別存放在堆空間中**。 ## 深拷貝和淺拷貝的區別 我們先來明確一下深拷貝和淺拷貝的定義: ### 淺拷貝 > 建立一個新物件,這個物件有著原始物件屬性值的一份精確拷貝。如果屬性是基本型別,拷貝的就是基本型別的值,如果屬性是引用型別,拷貝的就是記憶體地址 ,所以修改新拷貝的物件會影響原物件。 ![](https://user-gold-cdn.xitu.io/2020/2/25/17079ef9d5d5dd16?w=1333&h=612&f=jpeg&s=74939) ### 深拷貝 > 將一個物件從記憶體中完整的拷貝一份出來,從堆記憶體中開闢一個新的區域存放新物件,且修改新物件不會影響原物件 ![](https://user-gold-cdn.xitu.io/2020/2/25/17079eed9b1b2dc8?w=1351&h=731&f=jpeg&s=89550) 接下來我們就開始逐步實現一個深拷貝 ## 自帶版 一般情況下如果不使用loadsh的深拷貝函式,我們可能會這樣寫一個深拷貝函式 ```javascript JSON.parse(JSON.stringify()); ``` 但是這個方法侷限性比較大: - 會忽略 undefined - 會忽略 symbol - 不能序列化函式 - 不能解決迴圈引用的物件 顯然這絕對不是我們想要的一個合格的深拷貝函式 ## 基本版 手動實現的話我們很容易寫出如下函式 ```javascript const clone = (target) => { let cloneTarget = {}; Object.keys(target).forEach((item) => { cloneTarget[item] = target[item] }); return cloneTarget } ``` 先看下這個函式做了什麼:建立一個新物件,遍歷原物件,並且將需要拷貝的物件依次新增到新物件上,返回新物件。 既然是深拷貝的話,對於引用了型別我們不知道物件屬性的深度,我們可以通過遞迴來解決這個問題,接下來我們修改一下上面的程式碼: - 判斷是否是引用型別,如果是原始型別的話直接返回就可以了。 - 如果是原始型別,那麼我們需要建立一個物件,遍歷原物件,將需要拷貝的物件**執行深拷貝後**再依次新增到新物件上。 - 另外如果物件有更深層次的物件,我們就可以通過遞迴來解決。 這樣我們就實現了一個最基本的深拷貝函式: ```javascript // 是否是引用型別 const isObject = (target) => { return typeof target === 'object'; }; const clone = (target) => { // 處理原始型別直接返回(Number BigInt String Boolean Symbol Undefined Null) if (!isObject(target)) { return target; } let cloneTarget = {}; Object.keys(target).forEach((item) => { cloneTarget[item] = clone(target[item]) }); return cloneTarget } ``` 顯然這個深拷貝函式還有很多缺陷,比如:沒有考慮包含陣列的情況 ## 考慮陣列 上面程式碼中,我們只考慮了是object的情況,並沒有考慮存在陣列的情況。改成相容陣列也非常簡單: - 判斷傳入的物件是陣列還是物件,我們分別對它們進行處理 - 判斷型別的方法有很多比如 type of、instanceof,但是這兩種方法缺陷都比較多,這裡我使用的是Object.prototype.toString.call()的方法,它可以精準的判斷各種型別 - 當判斷出是陣列時,那麼我們需要建立一個新陣列,遍歷原陣列,將需要陣列中的每個值**執行深拷貝後**再依次新增到新的陣列上,返回新陣列。 程式碼如下: ```javascript const typeObject = '[object Object]'; const typeArray = '[object Array]'; // 是否是引用型別 const isObject = (target) => { return typeof target === 'object'; }; // 獲取標準型別 const getType = (target) => { return Object.prototype.toString.call(target); }; const clone = (target) => { // 處理原始型別直接返回(Number BigInt String Boolean Symbol Undefined Null) if (!isObject(target)) { return target; } const type = getType(target); let cloneTarget; switch (type) { case typeArray: // 陣列 cloneTarget = []; target.forEach((item, index) => { cloneTarget[index] = clone(item) }); return cloneTarget; case typeObject: // 物件 cloneTarget = {}; Object.keys(target).forEach((item) => { cloneTarget[item] = clone(target[item]) }); return cloneTarget; default: return target; } return cloneTarget } ``` OK,這樣我們的深拷貝函式就相容了最常用的陣列和物件的情況。 ## 迴圈引用 但是如果出現下面這種情況 ``` const target = { field1: 1, field2: { child: 'dellyoung' }, field3: [2, 4, 8] }; target.target = target; ``` 我們來拷貝這個target物件的話,就會發現會出現報錯:迴圈引用導致了棧溢位。 解決迴圈引用問題,我們需要額外有一個空間,來專門儲存已經被拷貝過的物件。當需要拷貝物件時,我們先從這個空間裡找是否已經拷貝過,如果拷貝過了就直接返回這個物件,沒有拷貝過就進行接下來的拷貝。需要注意的是隻有可遍歷的引用型別才會出現迴圈引用的情況。 很顯然這種情況下我們使用Map,以key-value來儲存就非常的合適: - 用has方法檢查Map中有無克隆過的物件 - 有的話就獲取Map存入的值後直接返回 - 沒有的話以當前物件為key,以拷貝得到的值為value儲存到Map中 - 繼續進行克隆 ```javascript const typeObject = '[object Object]'; const typeArray = '[object Array]'; // 是否是引用型別 const isObject = (target) => { return typeof target === 'object'; }; // 獲取標準型別 const getType = (target) => { return Object.prototype.toString.call(target); }; const clone = (target, map = new Map()) => { // 處理原始型別直接返回(Number BigInt String Boolean Symbol Undefined Null) if (!isObject(target)) { return target; } const type = getType(target); // 用於返回 let cloneTarget; // 處理迴圈引用 if (map.get(target)) { // 已經放入過map的直接返回 return map.get(target) } switch (type) { case typeArray: // 陣列 cloneTarget = []; map.set(target, cloneTarget); target.forEach((item, index) => { cloneTarget[index] = clone(item, map) }); return cloneTarget; case typeObject: // 物件 cloneTarget = {}; map.set(target, cloneTarget); Object.keys(target).forEach((item) => { cloneTarget[item] = clone(target[item], map) }); return cloneTarget; default: return target; } return cloneTarget } ``` ## 效能優化 **迴圈效能優化:** 其實我們寫程式碼的時候已經考慮到了效能優化了,比如:迴圈沒有使用 for in 迴圈而是使用的forEach迴圈,使用forEach或while迴圈會比for in迴圈快上不少的 **WeakMap效能優化:** 我們可以使用WeakMap來替代Map,提高效能。 ```javascript const clone = (target, map = new WeakMap()) => { // ... }; ``` 為什麼要這樣做呢?,先來看看WeakMap的作用: > WeakMap 物件是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是物件,而值可以是任意的。 那什麼是弱引用呢? > 在計算機程式設計中,弱引用與強引用相對,是指不能確保其引用的物件不會被垃圾回收器回收的引用。 一個物件若只被弱引用所引用,則被認為是不可訪問(或弱可訪問)的,並因此可能在任何時刻被回收。 我們預設建立一個物件:const obj = {},就預設建立了一個強引用的物件,我們只有手動將obj = null,它才會被垃圾回收機制進行回收,如果是弱引用物件,垃圾回收機制會自動幫我們回收。 我們來舉個例子: ```javascript let obj = { name : 'dellyoung'} const target = new Map(); target.set(obj,'dell'); obj = null; ``` 雖然我們手動將obj賦值為null,進行釋放,但是target依然對obj存在強引用關係,所以這部分記憶體依然無法被釋放。 基於此我們再來看WeakMap: ```javascript let obj = { name : 'dellyoung'} const target = new WeakMap(); target.set(obj,'dell'); obj = null; ``` 如果是WeakMap的話,target和obj存在的就是弱引用關係,當下一次垃圾回收機制執行的時候,這塊記憶體就會被釋放掉了。 如果我們要拷貝的物件非常龐大時,使用Map會對記憶體造成非常大的額外消耗,而且我們需要手動delete Map的key才能釋放這塊記憶體,而WeakMap會幫我們解決這個問題。 ## 更多的資料型別 到現在其實我們已經解決了Number BigInt String Boolean Symbol Undefined Null Object Array,這9種情況了,但是引用型別中我們其實只考慮了Object和Array兩種資料型別,但是實際上所有的引用型別遠遠不止這兩個。 ### 判斷引用型別 判斷是否是引用型別還需要考慮null和function兩種型別。 ```javascript // 是否是引用型別 const isObject = (target) => { if (target === null) { return false; } else { const type = typeof target; return type === 'object' || type === 'function'; } }; ``` ### 獲取資料型別 獲取型別,我們可以使用toString來獲取準確的引用型別: > 每一個引用型別都有toString方法,預設情況下,toString()方法被每個Object物件繼承。如果此方法在自定義物件中未被覆蓋,toString() 返回 "[object type]",其中type是物件的型別。 但是由於大部分引用型別比如Array、Date、RegExp等都重寫了toString方法,所以我們可以直接呼叫Object原型上未被覆蓋的toString()方法,使用call來改變this指向來達到我們想要的效果 ```javascript // 獲取標準型別 const getType = (target) => { return Object.prototype.toString.call(target); }; ``` ![](https://user-gold-cdn.xitu.io/2020/2/25/1707a383c77d3725?w=1046&h=942&f=jpeg&s=237349) 型別非常多,本文先考慮大部分常用的型別,其他型別就等小夥伴來探索啦 ```javascript // 可遍歷型別 Map Set Object Array const typeMap = '[object Map]'; const typeSet = '[object Set]'; const typeObject = '[object Object]'; const typeArray = '[object Array]'; // 非原始型別的 不可遍歷型別 Date RegExp Function const typeDate = '[object Date]'; const typeRegExp = '[object RegExp]'; const typeFunction = '[object Function]'; ``` ### 可繼續遍歷型別 上面我們已經考慮的Object、Array都屬於可以繼續遍歷的型別,因為它們記憶體都還可以儲存其他資料型別的資料,另外還有Map,Set等都是可以繼續遍歷的型別,這裡我們只考慮這四種常用的,其他型別等你來探索咯。 下面,我們改寫clone函式,使其對可繼續遍歷的資料型別進行處理: ```javascript // 可遍歷型別 Map Set Object Array const typeMap = '[object Map]'; const typeSet = '[object Set]'; const typeObject = '[object Object]'; const typeArray = '[object Array]'; // 是否是引用型別 const isObject = (target) => { if (target === null) { return false; } else { const type = typeof target; return type === 'object' || type === 'function'; } }; // 獲取標準型別 const getType = (target) => { return Object.prototype.toString.call(target); }; /* * 1、處理原始型別 Number String Boolean Symbol Null Undefined * 2、處理迴圈引用情況 WeakMap * 3、處理可遍歷型別 Set Map Array Object * */ const clone = (target, map = new WeakMap()) => { // 處理原始型別直接返回(Number BigInt String Boolean Symbol Undefined Null) if (!isObject(target)) { return target; } // 用於返回 let cloneTarget; // 處理迴圈引用 if (map.get(target)) { // 已經放入過map的直接返回 return map.get(target) } // 處理可遍歷型別 switch (type) { case typeSet: // Set cloneTarget = new Set(); map.set(target, cloneTarget); target.forEach((item) => { cloneTarget.add(clone(item, map)) }); return cloneTarget; case typeMap: // Map cloneTarget = new Map(); map.set(target, cloneTarget); target.forEach((value, key) => { cloneTarget.set(key, clone(value, map)) }); return cloneTarget; case typeArray: // 陣列 cloneTarget = []; map.set(target, cloneTarget); target.forEach((item, index) => { cloneTarget[index] = clone(item, map) }); return cloneTarget; case typeObject: // 物件 cloneTarget = {}; map.set(target, cloneTarget); Object.keys(target).forEach((item) => { cloneTarget[item] = clone(target[item], map) }); return cloneTarget; default: return target; } }; ``` 這樣我們就完成了對Set和Map的相容 ### 考慮物件鍵名為Symbol型別 對於物件鍵名為`Symbol`型別時,用`Object.keys(target)`是獲取不到的,這時候就需要用到`Object.getOwnPropertySymbols(target)`方法。 ```javascript case typeObject: // 物件 cloneTarget = {}; map.set(target, cloneTarget); [...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => { cloneTarget[item] = clone(target[item], map) }); return cloneTarget; ``` 這樣就實現了對於物件鍵名為`Symbol`型別的相容。 ### 不可繼續遍歷型別 不可遍歷的型別有Number BigInt String Boolean Symbol Undefined Null Date RegExp Function 等等,但是前7中已經被isObject攔截了,於是我們先對後面Date RegExp Function進行處理,其實後面不止有這幾種,其他型別等你來探索咯。 其中對函式的處理要簡單說下,我認為克隆函式是沒有必要的其實,兩個物件使用一個在記憶體中處於同一個地址的函式也是沒有任何問題的,如下是lodash對函式的處理: ```javascript const isFunc = typeof value == 'function' if (isFunc || !cloneableTags[tag]) { return object ? value : {} } ``` 顯然如果發現是函式的話就會直接返回了,沒有做特殊的處理,這裡我們暫時也這樣處理,以後有時間我會把拷貝函式的部分給補上。 ```javascript // 可遍歷型別 Map Set Object Array const typeMap = '[object Map]'; const typeSet = '[object Set]'; const typeObject = '[object Object]'; const typeArray = '[object Array]'; // 非原始型別的 不可遍歷型別 Date RegExp Function const typeDate = '[object Date]'; const typeRegExp = '[object RegExp]'; const typeFunction = '[object Function]'; // 非原始型別的 不可遍歷型別的 集合(原始型別已經被過濾了不用再考慮了) const simpleType = [typeDate, typeRegExp, typeFunction]; // 是否是引用型別 const isObject = (target) => { if (target === null) { return false; } else { const type = typeof target; return type === 'object' || type === 'function'; } }; // 獲取標準型別 const getType = (target) => { return Object.prototype.toString.call(target); }; /* * 1、處理原始型別 Number String Boolean Symbol Null Undefined * 2、處理不可遍歷型別 Date RegExp Function * 3、處理迴圈引用情況 WeakMap * 4、處理可遍歷型別 Set Map Array Object * */ const clone = (target, map = new WeakMap()) => { // 處理原始型別直接返回(Number BigInt String Boolean Symbol Undefined Null) if (!isObject(target)) { return target; } // 處理不可遍歷型別 const type = getType(target); if (simpleType.includes(type)) { switch (type) { case typeDate: // 日期 return new Date(target); case typeRegExp: // 正則 const reg = /\w*$/; const result = new RegExp(target.source, reg.exec(target)[0]); result.lastIndex = target.lastIndex; // lastIndex 表示每次匹配時的開始位置 return result; case typeFunction: // 函式 return target; default: return target; } } // 用於返回 let cloneTarget; // 處理迴圈引用 if (map.get(target)) { // 已經放入過map的直接返回 return map.get(target) } // 處理可遍歷型別 switch (type) { case typeSet: // Set cloneTarget = new Set(); map.set(target, cloneTarget); target.forEach((item) => { cloneTarget.add(clone(item, map)) }); return cloneTarget; case typeMap: // Map cloneTarget = new Map(); map.set(target, cloneTarget); target.forEach((value, key) => { cloneTarget.set(key, clone(value, map)) }); return cloneTarget; case typeArray: // 陣列 cloneTarget = []; map.set(target, cloneTarget); target.forEach((item, index) => { cloneTarget[index] = clone(item, map) }); return cloneTarget; case typeObject: // 物件 cloneTarget = {}; map.set(target, cloneTarget); [...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => { cloneTarget[item] = clone(target[item], map) }); return cloneTarget; default: return target; } }; ``` 至此這個深拷貝函式已經能處理大部分的型別了:Number String Boolean Symbol Null Undefined Date RegExp Function Set Map Array Object,並且也能優秀的處理迴圈引用情況了 ## 參考 - 瀏覽器工作原理與實踐 - ConardLi深拷貝文章 - MDN-WeakMap - Loadsh ## 總結 現在我們應該能理清楚寫一個合格深拷貝的思路了: - 處理原始型別 如: Number String Boolean Symbol Null Undefined - 處理不可遍歷型別 如: Date RegExp Function - 處理迴圈引用情況 使用: WeakMap - 處理可遍歷型別 如: Set Map Array Object ## 看完兩件事 - 歡迎加我微信(iamyyymmm),拉你進技術群,長期交流學習 - 關注公眾號「呆鵝實驗室」,和呆鵝一起學前端,提高技術認知 ![](https://tva1.sinaimg.cn/large/007S8ZIlgy1gjdcbqfm0rj30wa0gu74y.jpg)