深拷貝的終極探索(90%的人都不了解)

2021-03-02 顏海鏡

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

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

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

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

深拷貝 VS 淺拷貝

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

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

// 基本類型var a = 1;var b = a;a = 2;console.log(a, b); // 2, 1 ,a b指向不同的數據// 引用類型指向同一份數據var a = {c: 1};var b = a;a.c = 2;console.log(a.c, b.c); // 2, 2 全是2,a b指向同一份數據

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

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

var a1 = {b: {c: {}};var a2 = shallowClone(a1); // 淺拷貝a2.b.c === a1.b.c // truevar a3 = clone(a1); // 深拷貝a3.b.c === a1.b.c // false

淺拷貝的實現非常簡單,而且還有多種方法,其實就是遍歷對象屬性的問題,這裡只給出一種,如果看不懂下面的方法,或對其他方法感興趣,可以看我的這篇文章

function shallowClone(source) {    var target = {};    for(var i in source) {        if (source.hasOwnProperty(i)) {            target[i] = source[i];        }    }    return target;}

最簡單的深拷貝

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

var a1 = {b: {c: {d: 1}};

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

function clone(source) {    var target = {};    for(var i in source) {        if (source.hasOwnProperty(i)) {            if (typeof source[i] === 'object') {                target[i] = clone(source[i]); // 注意這裡            } else {                target[i] = source[i];            }        }    }    return target;}

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

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

沒有對參數做檢驗

判斷是否對象的邏輯不夠嚴謹

沒有考慮數組的兼容

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

function isObject(x) {    return Object.prototype.toString.call(x) === '[object Object]';}

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

function clone(source) {    if (!isObject(source)) return source;    // xxx}

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

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

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

function createData(deep, breadth) {    var data = {};    var temp = data;    for (var i = 0; i < deep; i++) {        temp = temp['data'] = {};        for (var j = 0; j < breadth; j++) {            temp[j] = j;        }    }    return data;}createData(1, 3); // 1層深度,每層有3個數據 {data: {0: 0, 1: 1, 2: 2}}createData(3, 0); // 3層深度,每層有0個數據 {data: {data: {data: {}}}}

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

clone(createData(1000)); // okclone(createData(10000)); // Maximum call stack size exceededclone(createData(10, 100000)); // ok 廣度不會溢出

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

var a = {};a.a = a;clone(a) // Maximum call stack size exceeded 直接死循環了有沒有,/(ㄒoㄒ)/~~

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

一行代碼的深拷貝

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

function cloneJSON(source) {    return JSON.parse(JSON.stringify(source));}

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

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

cloneJSON(createData(10000)); // Maximum call stack size exceeded

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

var a = {};a.a = a;cloneJSON(a) // Uncaught TypeError: Converting circular structure to JSON

破解遞歸爆棧

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

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

var a = {    a1: 1,    a2: {        b1: 1,        b2: {            c1: 1        }    }}

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

   a  /   \ a1   a2         |    / \         1   b1 b2         |   |             1  c1         |         1

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

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

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

function cloneLoop(x) {    const root = {};    // 棧    const loopList = [        {            parent: root,            key: undefined,            data: x,        }    ];    while(loopList.length) {        // 深度優先        const node = loopList.pop();        const parent = node.parent;        const key = node.key;        const data = node.data;        // 初始化賦值目標,key為undefined則拷貝到父元素,否則拷貝到子元素        let res = parent;        if (typeof key !== 'undefined') {            res = parent[key] = {};        }        for(let k in data) {            if (data.hasOwnProperty(k)) {                if (typeof data[k] === 'object') {                    // 下一次循環                    loopList.push({                        parent: res,                        key: k,                        data: data[k],                    });                } else {                    res[k] = data[k];                }            }        }    }    return root;}

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

破解循環引用

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

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

var b = {};var a = {a1: b, a2: b};a.a1 === a.a2 // truevar c = clone(a);c.a1 === c.a2 // false

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

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

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

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

// 保持引用關係function cloneForce(x) {    // =============    const uniqueList = []; // 用來去重    // =============    let root = {};    // 循環數組    const loopList = [        {            parent: root,            key: undefined,            data: x,        }    ];    while(loopList.length) {        // 深度優先        const node = loopList.pop();        const parent = node.parent;        const key = node.key;        const data = node.data;        // 初始化賦值目標,key為undefined則拷貝到父元素,否則拷貝到子元素        let res = parent;        if (typeof key !== 'undefined') {            res = parent[key] = {};        }        // =============        // 數據已經存在        let uniqueData = find(uniqueList, data);        if (uniqueData) {            parent[key] = uniqueData.target;            continue; // 中斷本次循環        }        // 數據不存在        // 保存源數據,在拷貝數據中對應的引用        uniqueList.push({            source: data,            target: res,        });        // =============        for(let k in data) {            if (data.hasOwnProperty(k)) {                if (typeof data[k] === 'object') {                    // 下一次循環                    loopList.push({                        parent: res,                        key: k,                        data: data[k],                    });                } else {                    res[k] = data[k];                }            }        }    }    return root;}function find(arr, item) {    for(let i = 0; i < arr.length; i++) {        if (arr[i].source === item) {            return arr[i];        }    }    return null;}

下面來驗證一下效果,amazing

var b = {};var a = {a1: b, a2: b};a.a1 === a.a2 // truevar c = cloneForce(a);c.a1 === c.a2 // true

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

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

var a = {};a.a = a;cloneForce(a)

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

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

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

性能對比

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

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

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

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

function runTime(fn, time) {    var stime = Date.now();    var count = 0;    while(Date.now() - stime < time) {        fn();        count++;    }    return count;}runTime(function () { clone(createData(500, 1)) }, 2000);

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

深度clonecloneJSONcloneLoopcloneForce500351212338372100017410417514315001166711282200092508869

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

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

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秒內執行的次數

寬度clonecloneJSONcloneLoopcloneForce013400327214292989

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

隨著對象的增多,cloneForce的性能低下凸顯

cloneJSON的性能也大打折扣,這是因為循環檢測佔用了很多時間

cloneLoop的性能高於clone,可以看出遞歸新建函數的時間和循環對象比起來可以忽略不計

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

var data1 = createData(2000, 0);var data2 = createData(4000, 0);var data3 = createData(6000, 0);var data4 = createData(8000, 0);var data5 = createData(10000, 0);cloneForce(data1)cloneForce(data2)cloneForce(data3)cloneForce(data4)cloneForce(data5)

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

總結

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

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


clonecloneJSONcloneLoopcloneForce難度☆☆☆☆☆☆☆☆☆☆兼容性ie6ie8ie6ie6循環引用一層不支持一層支持棧溢出會會不會不會保持引用否否否是適合場景一般數據拷貝一般數據拷貝層級很多保持引用關係

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

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

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

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

jsmini的誕生離不開jslib-base,感謝jslib-base為jsmini提供了底層技術

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

最後最後推薦下我的新書《React 狀態管理與同構實戰》這本書凝結了我在學習、實踐 React 框架過程中的積累和心得

有興趣的讀者可以長按識別下方二維碼購買,再次感謝各位的支持與鼓勵!懇請各位批評指正!

PS:想要限量籤名版(附贈精美禮物)的同學,公眾號後臺回復: React

最後最後最後美團外賣招聘前端,後端,客戶端啦!地點:北京+上海+成都,感興趣的同學,可以把簡歷發到我的郵箱: yanhaijing@yeah.net

相關焦點

  • 前端面試-深拷貝和淺拷貝
    面試題目:如何實現對一個數組或對象的淺拷貝和深拷貝?WTF,複製還分兩種,第一次遇到這種問題的時候很是無語呢,先來看看一般的答案的理解。淺拷貝是只拷貝一層,深層次的對象級別就只拷貝引用。 深拷貝是拷貝多層,每一級別的數據都拷貝出來。
  • 面試題:如何實現一個深拷貝
    今天這篇文章我們來看看一道必會面試題,即如何實現一個深拷貝。第一步:簡單實現其實深拷貝可以拆分成 2 步,淺拷貝 + 遞歸,淺拷貝時判斷屬性值是否是對象,如果是對象就進行遞歸操作,兩個一結合就實現了深拷貝。根據上篇文章內容,我們可以寫出簡單淺拷貝代碼如下。
  • 低門檻徹底理解JavaScript中的深拷貝和淺拷貝
    在說深拷貝與淺拷貝前,我們先看兩個簡單的案例:按照常規思維,obj1應該和num1一樣,不會因為另外一個值的改變而改變,而這裡的obj1 卻隨著obj2的改變而改變了。同樣是變量,為什麼表現不一樣呢?案例2中的賦值就是典型的淺拷貝,並且深拷貝與淺拷貝的概念只存在於引用類型。深拷貝與淺拷貝既然已經知道了深拷貝與淺拷貝的來由,那麼該如何實現深拷貝?
  • 一文讀懂 javascript 深拷貝與淺拷貝
    總而言之,淺拷貝只複製指向某個對象的指針,而不複製對象本身,新舊對象還是共享同一塊內存。但深拷貝會另外創造一個一模一樣的對象,新對象跟原對象不共享內存,修改新對象不會改到原對象。賦值和深/淺拷貝的區別這三者的區別如下,不過比較的前提都是針對引用類型:當我們把一個對象賦值給一個新的變量時,賦的其實是該對象的在棧中的地址,而不是堆中的數據。
  • JavaScript的深拷貝實現
    在實際開發當中,我們經常會遇到要對對象進行深拷貝的情況。而且深拷貝這個問題在面試過程中也經常會遇到,下面就對本人在學習過程中的收穫,做以簡單的總結。什麼是淺拷貝,什麼是深拷貝?什麼是淺拷貝關於淺拷貝的概念,我在網上看到一種說法,直接上代碼。
  • 深拷貝和淺拷貝之list、dataframe
    python list:b = a是淺拷貝,b = list(a)和b = copy.cpoy(a)是深拷貝。淺拷貝,a和b指向的是一個地址。當b改變後,a也會改變。深拷貝,a和b指向的是兩個地址,當b改變後,a不受影響。
  • 面試題-python 淺拷貝和深拷貝(copy模塊)
    這裡有個知識點:在python中,都是將「對象的引用(內存地址)」賦值給變量的。其次,在python中有6個標準數據類型,他們分為可變和不可變兩類。可變和不可變對象在python中有6個標準數據類型,他們分為可變和不可變兩類。
  • Python深拷貝和淺拷貝詳解
    對於淺拷貝(shallow copy)和深度拷貝(deep copy),本節並不打算一上來拋出它們的概念,而是先從它們的操作方法說起,通過代碼來理解兩者的不同
  • JavaScript系列--如何優雅簡單的實現深拷貝和淺拷貝
    優雅簡單的實現深拷貝和淺拷貝淺析JavaScript解析賦值、淺拷貝和深拷貝的區別:裡面介紹了解析賦值,淺拷貝,深拷貝的原理和實現。我們把這種複製引用的拷貝方法稱之為淺拷貝,與之對應的就是深拷貝,深拷貝就是指完全的拷貝一個對象,即使嵌套了對象,兩者也相互分離,修改一個對象的屬性,也不會影響另一個。所以我們可以看出使用 concat , slice, 擴展預算符spread 是一種淺拷貝。
  • 5 張圖徹底理解 Python 中的淺拷貝與深拷貝
    若平時你在開發中過度使用 deepcopy,以至於忘記了淺拷貝(shallow copy)和深拷貝(deep copy)的區別,那很可能要栽大跟頭了。本文轉載一篇小吉大佬的文章,幫助大家更好的理解深拷貝和淺拷貝的區別。
  • 深淺拷貝知多少?
    導語 日常工作開發中,賦值、拷貝是每天都在做的事情,可是有一些拷貝的改變會同時改變原有元素的內容,本次分享主要從拷貝前和拷貝後的數據對比來進行交流探討
  • C++之拷貝構造函數的淺copy和深copy
    :(1)淺拷貝拷貝後對象的物理狀態相同(2)深拷貝拷貝後對象的邏輯狀態相同/a.outt1.i = 2, t1.j = 3, t1.p = 0xb0a010t2.i = 2, t2.j = 3, t2.p = 0xb0a030註解:從列印的p地址空間來看,就知釋放的兩個對象的堆空間不同,不再是指向同一堆空間了;同時我們發現淺拷貝只是簡單數值上的進行賦值而已;深拷貝不只是簡單的值賦值
  • 阿里Java開發規約為什麼不建議使用Apache BeanUtils拷貝對象?
    前言做 JAVA 開發的同學都知道,在 JAVA 世界中萬事萬物皆為對象。是我們在實際開發中,經常會遇到將一個對象實例拷貝轉換為另一個對象實例的情況:對兩個對象的屬性進行淺(深)度複製。在具體介紹 BeanUtils 工具以前,先介紹一個有關拷貝的基礎知識。其實所有的 BeanUtils本質上就是對象拷貝工具,通常對象拷貝分為:深拷貝和淺拷貝,接下來做詳細解釋。
  • 深洋蔥DeepOnion:靠90%預挖與90%水軍撐起的「偽熱土」
    深洋蔥DeepOnion自面世以來就一直被不斷熱炒,不過此「炒」非彼「炒」。毫無誇張地說,這款靠論壇模式發展而來的數字貨幣,目前在網絡上幾乎90%的看漲及推薦都是團隊利用水軍帳號刷屏而來!以致如今在網絡上隨便搜索關於「深洋蔥DeepOnion」的詞條,至少前3頁都屬於虛假帳號發布的不真實信息。例如知乎:已有知乎答主發現了其中貓膩,開貼嘲諷,但該問題立刻吸引了超過50餘個深洋蔥DeepOnion水軍刷屏。為何如此肯定都是水軍?
  • 什麼是Java深淺拷貝?
    拷貝與Java內存結構息息相關,搞懂Java深淺拷貝是很必要的!在對象的拷貝中,很多初學者可能搞不清到底是拷貝了引用還是拷貝了對象。在拷貝中這裡就分為引用拷貝、淺拷貝、深拷貝進行講述。引用拷貝引用拷貝會生成一個新的對象引用地址,但是兩個最終指向依然是同一個對象。如何更好的理解引用拷貝呢?
  • 關於粉底液誤區 90%的人都沒用對
    原標題:關於粉底液誤區 90%的人都沒用對 關於粉底液誤區 90%的人都沒用對 粉底到底怎麼塗,才能看上去像個彩妝高手?首先,你要知道:粉底不是塗得越多就好!在上粉底之前,基礎護理很重要!不做好護膚步驟,就直接上妝的人,我還能說什麼! 關於粉底液誤區 90%的人都沒用對 誤區一:不需基礎護理 不知道是誰說的,清潔臉部後可以直接用粉底塗抹全臉,跳過複雜的基礎護理步驟。
  • 圖解 Python 中深淺拷貝(copy)
    但是python對深copy做了一個優化,將可變的數據類型在內存中重新創建一份,而不可變的數據類型則沿用之前的,所以內存中是下面這樣的:小結:深copy:會在內存中開闢新空間,將原列表以及列表裡面的可變數據類型重新創建一份,不可變數據類型則沿用之前的。為什麼Python默認的拷貝方式是淺拷貝?
  • 一篇文章讀懂Python賦值與拷貝
    題圖:Photo by Massimiliano Donghi on Unsplash變量與賦值在 Python 中,一切皆為對象,對象通過「變量名」引用,「變量名」更確切的叫法是「名字」,好比我們每個人都有自己的名字一樣
  • 人類對海洋了解多少?世界上最深的海洋裡有什麼?一切都需要探索
    探索未知世界的步伐人類從未停止。上至宇宙,下至海洋,但凡人類能探索得到的都是夢開啟的地方。人類企圖徵服世界,從宇宙大氣層到高空,從太陽到雲層,一步一步蔓延開來。漸漸的也開始涉及深海。不論是深海裡的礦物質、資源,還是海洋動物,都成了人類徵服世界的開始。那麼大海究竟有多深?
  • 看看你知道的「淺拷貝」是對的嗎
    ,在應聘者的回答中,筆者發現有好一部分人對淺拷貝都是錯誤的,故有了此篇內容。她: 「複製對象有深拷貝和淺拷貝...」大佬:」說一下這兩者之間的區別「她: 」我給你寫一段淺拷貝的代碼「var a = { x: 1 };var b = a;大佬:」回去等通知吧 ~.~「2.