深拷貝的終極探索(刷掉很多人的必考面試題)

劃重點,這是一道面試必考題,我就問過很多面試者這個問題,✧(≖ ◡ ≖✿)嘿嘿

首先這是一道非常棒的面試題,可以考察面試者的很多方面,比如基本功,代碼能力,邏輯能力,而且進可攻,退可守,針對不同級別的人可以考察不同難度,比如漂亮妹子就出1☆題,(*^__^*) 嘻嘻……

一般在面試者回答出問題後,我總能夠瀟灑的再拋出一些問題,看著面試者露出驚異的眼神,默默一轉身,深藏功與名

本文我將給大家破解深拷貝的謎題,由淺入深,環環相扣,總共涉及4種深拷貝方式,每種方式都有自己的特點和個性

深拷貝 VS 淺拷貝

再開始之前需要先給同學科普下什麼是深拷貝,和深拷貝有關係的另個一術語是淺拷貝又是什麼意思呢?如果對這部分部分內容瞭解的同學可以跳過

其實深拷貝和淺拷貝都是針對的引用類型,JS中的變量類型分為值類型(基本類型)和引用類型;對值類型進行復制操作會對值進行一份拷貝,而對引用類型賦值,則會進行地址的拷貝,最終兩個變量指向同一份數據

深拷貝的終極探索(刷掉很多人的必考面試題)

對於引用類型,會導致a b指向同一份數據,此時如果對其中一個進行修改,就會影響到另外一個,有時候這可能不是我們想要的結果,如果對這種現象不清楚的話,還可能造成不必要的bug

那麼如何切斷a和b之間的關係呢,可以拷貝一份a的數據,根據拷貝的層級不同可以分為淺拷貝和深拷貝,淺拷貝就是隻進行一層拷貝,深拷貝就是無限層級拷貝

深拷貝的終極探索(刷掉很多人的必考面試題)

淺拷貝的實現非常簡單,而且還有多種方法,其實就是遍歷對象屬性的問題,這裡只給出一種,如果看不懂下面的方法,或對其他方法感興趣,可以看我的這篇文章(https://yanhaijing.com/javascript/2015/05/09/diff-between-keys-getOwnPropertyNames-forin/)

深拷貝的終極探索(刷掉很多人的必考面試題)

最簡單的深拷貝

深拷貝的問題其實可以分解成兩個問題,淺拷貝+遞歸,什麼意思呢?假設我們有如下數據

深拷貝的終極探索(刷掉很多人的必考面試題)

只需稍加改動上面淺拷貝的代碼即可,注意區別

深拷貝的終極探索(刷掉很多人的必考面試題)

大部分人都能寫出上面的代碼,但當我問上面的代碼有什麼問題嗎?就很少有人答得上來了,聰明的你能找到問題嗎?

其實上面的代碼問題太多了,先來舉幾個例子吧

  • 沒有對參數做檢驗
  • 判斷是否對象的邏輯不夠嚴謹
  • 沒有考慮數組的兼容

(⊙o⊙),下面我們來看看各個問題的解決辦法,首先我們需要抽象一個判斷對象的方法,其實比較常用的判斷對象的方法如下,其實下面的方法也有問題,但如果能夠回答上來那就非常不錯了,如果完美的解決辦法感興趣,不妨看看這裡吧

深拷貝的終極探索(刷掉很多人的必考面試題)

函數需要校驗參數,如果不是對象的話直接返回

深拷貝的終極探索(刷掉很多人的必考面試題)

關於第三個問題,嗯,就留給大家自己思考吧,本文為了減輕大家的負擔,就不考慮數組的情況了,其實ES6之後還要考慮set, map, weakset, weakmap,/(ㄒoㄒ)/~~

其實吧這三個都是小問題,其實遞歸方法最大的問題在於爆棧,當數據的層次很深是就會棧溢出

下面的代碼可以生成指定深度和每層廣度的代碼,這段代碼我們後面還會再次用到

深拷貝的終極探索(刷掉很多人的必考面試題)

當 clone 層級很深的話就會棧溢出,但數據的廣度不會造成溢出

深拷貝的終極探索(刷掉很多人的必考面試題)

其實大部分情況下不會出現這麼深層級的數據,但這種方式還有一個致命的問題,就是循環引用,舉個例子

深拷貝的終極探索(刷掉很多人的必考面試題)

關於循環引用的問題解決思路有兩種,一直是循環檢測,一種是暴力破解,關於循環檢測大家可以自己思考下;關於暴力破解我們會在下面的內容中詳細講解

一行代碼的深拷貝

有些同學可能見過用系統自帶的 JSON 來做深拷貝的例子,下面來看下代碼實現

深拷貝的終極探索(刷掉很多人的必考面試題)

其實我第一次簡單這個方法的時候,由衷的表示佩服,其實利用工具,達到目的,是非常聰明的做法

下面來測試下cloneJSON有沒有溢出的問題,看起來cloneJSON內部也是使用遞歸的方式

深拷貝的終極探索(刷掉很多人的必考面試題)

既然是用了遞歸,那循環引用呢?並沒有因為死循環而導致棧溢出啊,原來是 JSON.stringify內部做了循環引用的檢測,正是我們上面提到破解循環引用的第一種方法:循環檢測

深拷貝的終極探索(刷掉很多人的必考面試題)

破解遞歸爆棧

其實破解遞歸爆棧的方法有兩條路,第一種是消除尾遞歸,但在這個例子中貌似行不通,第二種方法就是乾脆不用遞歸,改用循環,當我提出用循環來實現時,基本上90%的前端都是寫不出來的代碼的,這其實讓我很震驚

舉個例子,假設有如下的數據結構

深拷貝的終極探索(刷掉很多人的必考面試題)

這不就是一個樹嗎,其實只要把數據橫過來看就非常明顯了

深拷貝的終極探索(刷掉很多人的必考面試題)

用循環遍歷一棵樹,需要藉助一個棧,當棧為空時就遍歷完了,棧裡面存儲下一個需要拷貝的節點

首先我們往棧裡放入種子數據,key 用來存儲放哪一個父元素的那一個子元素拷貝對象

然後遍歷當前節點下的子元素,如果是對象就放到棧裡,否則直接拷貝

深拷貝的終極探索(刷掉很多人的必考面試題)

改用循環後,再也不會出現爆棧的問題了,但是對於循環引用依然無力應對

破解循環引用

有沒有一種辦法可以破解循環應用呢?彆著急,我們先來看另一個問題,上面的三種方法都存在的一個問題就是引用丟失,這在某些情況下也許是不能接受的

舉個例子,假如一個對象a,a下面的兩個鍵值都引用同一個對象b,經過深拷貝後,a的兩個鍵值會丟失引用關係,從而變成兩個不同的對象,o(╯□╰)o

深拷貝的終極探索(刷掉很多人的必考面試題)

如果我們發現個新對象就把這個對象和他的拷貝存下來,每次拷貝對象前,都先看一下這個對象是不是已經拷貝過了,如果拷貝過了,就不需要拷貝了,直接用原來的,這樣我們就能夠保留引用關係了,✧(≖ ◡ ≖✿)嘿嘿

但是代碼怎麼寫呢,o(╯□╰)o,別急往下看,其實和循環的代碼大體一樣,不一樣的地方我用// ==========標註出來了

引入一個數組 uniqueList 用來存儲已經拷貝的數組,每次循環遍歷時,先判斷對象是否在uniqueList 中了,如果在的話就不執行拷貝邏輯了

find 是抽象的一個函數,其實就是遍歷 uniqueList

深拷貝的終極探索(刷掉很多人的必考面試題)

下面來驗證一下效果,amazing

深拷貝的終極探索(刷掉很多人的必考面試題)

接下來再說一下如何破解循環引用,等一下,上面的代碼好像可以破解循環引用啊,趕緊驗證一下

驚不驚喜,(*^__^*) 嘻嘻……

深拷貝的終極探索(刷掉很多人的必考面試題)

看起來完美的 cloneForce 是不是就沒問題呢?cloneForce 有兩個問題

第一個問題,所謂成也蕭何,敗也蕭何,如果保持引用不是你想要的,那就不能用 cloneForce 了;

第二個問題,cloneForce 在對象數量很多時會出現很大的問題,如果數據量很大不適合使用cloneForce

性能對比

上邊的內容還是有點難度,下面我們來點更有難度的,對比一下不同方法的性能

我們先來做實驗,看數據,影響性能的原因有兩個,一個是深度,一個是每層的廣度,我們採用固定一個變量,只讓一個變量變化的方式來測試性能

測試的方法是在指定的時間內,深拷貝執行的次數,次數越多,證明性能越好

下面的

runTime 是測試代碼的核心片段,下面的例子中,我們可以測試在2秒內運行clone(createData(500, 1) 的次數

深拷貝的終極探索(刷掉很多人的必考面試題)

下面來做第一個測試,將廣度固定在100,深度由小到大變化,記錄1秒內執行的次數

深拷貝的終極探索(刷掉很多人的必考面試題)

將上面的數據做成表格可以發現,一些規律

  • 隨著深度變小,相互之間的差異在變小
  • clone和cloneLoop的差別並不大
  • cloneLoop > cloneForce > cloneJSON
深拷貝的終極探索(刷掉很多人的必考面試題)

我們先來分析下各個方法的時間複雜度問題,各個方法要做的相同事情,這裡就不計算,比如循環對象,判斷是否為對象

  • clone時間 = 創建遞歸函數 + 每個對象處理時間
  • cloneJSON時間 = 循環檢測 + 每個對象處理時間 * 2 (遞歸轉字符串 + 遞歸解析)
  • cloneLoop時間 = 每個對象處理時間
  • cloneForce時間 = 判斷對象是否緩存中 + 每個對象處理時間

cloneJSON的速度只有clone的50%,很容易理解,因為其會多進行一次遞歸時間

cloneForce由於要判斷對象是否在緩存中,而導致速度變慢,我們來計算下判斷邏輯的時間複雜度,假設對象的個數是n,則其時間複雜度為O(n2),對象的個數越多,cloneForce的速度會越慢

1 + 2 + 3 ... + n = n^2/2 - 1

關於clone和cloneLoop這裡有一點問題,看起來實驗結果和推理結果不一致,其中必有蹊蹺

接下來做第二個測試,將深度固定在10000,廣度固定為0,記錄2秒內執行的次數

深拷貝的終極探索(刷掉很多人的必考面試題)

排除寬度的干擾,來看看深度對各個方法的影響

  • 隨著對象的增多,cloneForce的性能低下凸顯
  • cloneJSON的性能也大打折扣,這是因為循環檢測佔用了很多時間
  • cloneLoop的性能高於clone,可以看出遞歸新建函數的時間和循環對象比起來可以忽略不計

下面我們來測試一下cloneForce的性能極限,這次我們測試運行指定次數需要的時間

深拷貝的終極探索(刷掉很多人的必考面試題)

通過測試發現,其時間成指數級增長,當對象個數大於萬級別,就會有300ms以上的延遲

深拷貝的終極探索(刷掉很多人的必考面試題)

總結

尺有所短寸有所長,無關乎好壞優劣,其實每種方法都有自己的優缺點,和適用場景,人盡其才,物盡其用,方是真理

下面對各種方法進行對比,希望給大家提供一些幫助

深拷貝的終極探索(刷掉很多人的必考面試題)

本文的靈感都來自於 @jsmini/clone(https://github.com/jsmini/clone),如果大家想使用文中的4種深拷貝方式,可以直接使用 @jsmini/clone這個庫

// npm install --save @jsmini/clone
import { clone, cloneJSON, cloneLoop, cloneForce } from '@jsmini/clone';

本文為了簡單和易讀,示例代碼中忽略了一些邊界情況,如果想學習生產中的代碼,請閱讀@jsmini/clone 的源碼

@jsmini/clone 孵化於 jsmini(https://github.com/jsmini),jsmini 致力於為大家提供一組小而美,無依賴的高質量庫

jsmini 的誕生離不開 jslib-base(https://github.com/yanhaijing/jslib-base),感謝 jslib-base 為 jsmini 提供了底層技術

最後感謝你閱讀了本文,相信現在你能夠駕馭任何深拷貝的問題了,如果有什麼疑問,歡迎和我討論

原文網址:http://yanhaijing.com/javascript/2018/10/10/clone-deep/


分享到:


相關文章: