深拷貝的終極探索(99%的人都不知道)

劃重點,這是一道面試必考題,我靠這道題刷掉了多少面試者✧(≖ ◡ ≖✿)嘿嘿javascript

首先這是一道很是棒的面試題,能夠考察面試者的不少方面,好比基本功,代碼能力,邏輯能力,並且進可攻,退可守,針對不一樣級別的人能夠考察不一樣難度,好比漂亮妹子就出1☆題,要是個帥哥那就得上5☆了,(*^__^*) 嘻嘻……html

不管面試者多麼優秀,漂亮的回答出問題,我總可以瀟灑的再拋出一個問題,看着面試者露出驚異的眼神,默默一轉身,深藏功與名前端

本文我將給你們破解深拷貝的謎題,由淺入深,環環相扣,總共涉及4種深拷貝方式,每種方式都有本身的特色和個性java

深拷貝 VS 淺拷貝

再開始以前須要先給同窗科普下什麼是深拷貝,和深拷貝有關係的另個一術語是淺拷貝又是什麼意思呢?若是對這部分部份內容瞭解的同窗能夠跳過node

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

// 基本類型
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指向同一份數據,此時若是對其中一個進行修改,就會影響到另一個,有時候這可能不是咱們想要的結果,若是對這種現象不清楚的話,還可能形成沒必要要的buggithub

那麼如何切斷a和b之間的關係呢,能夠拷貝一份a的數據,根據拷貝的層級不一樣能夠分爲淺拷貝和深拷貝,淺拷貝就是隻進行一層拷貝,深拷貝就是無限層級拷貝面試

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

var a2 = shallowClone(a1); // 淺拷貝
a2.b.c === a1.b.c // true

var a3 = clone(a3); // 深拷貝
a3.b.c === a1.b.c // false

淺拷貝的實現很是簡單,並且還有多種方法,其實就是遍歷對象屬性的問題,這裏只給出一種,若是看不懂下面的方法,或對其餘方法感興趣,能夠看個人這篇文章npm

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)); // ok
clone(createData(10000)); // Maximum call stack size exceeded

clone(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 = 1;
var a = {a1: b, a2: b};

a.a1 === a.a2 // true

var 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;
            break; // 中斷本次循環
        }

        // 數據不存在
        // 保存源數據,在拷貝數據中對應的引用
        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 = 1;
var a = {a1: b, a2: b};

a.a1 === a.a2 // true

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

深度 clone cloneJSON cloneLoop cloneForce
500 351 212 338 372
1000 174 104 175 143
1500 116 67 112 82
2000 92 50 88 69

將上面的數據作成表格能夠發現,一些規律

  • 隨着深度變小,相互之間的差別在變小
  • 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秒內執行的次數

寬度 clone cloneJSON cloneLoop cloneForce
0 13400 3272 14292 989

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

  • 隨着對象的增多,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以上的延遲

總結

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

下面對各類方法進行對比,但願給你們提供一些幫助

clone cloneJSON cloneLoop cloneForce
難度 ☆☆ ☆☆☆ ☆☆☆☆
兼容性 ie6 ie8 ie6 ie6
循環引用 一層 不支持 一層 支持
棧溢出 不會 不會
保持引用
適合場景 通常數據拷貝 通常數據拷貝 層級不少 保持引用關係

本文的靈感都來自於@jsmini/clone,若是你們想使用文中的4種深拷貝方式,能夠直接使用@jsmini/clone這個庫

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

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

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

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

感謝你閱讀了本文,相信如今你可以駕馭任何深拷貝的問題了,若是有什麼疑問,歡迎和我討論

最後推薦下個人新書《React狀態管理與同構實戰》,深刻解讀前沿同構技術,感謝你們支持

京東:https://item.jd.com/12403508.html

噹噹:http://product.dangdang.com/25308679.html

最後最後招聘前端,後端,客戶端啦!地點:北京+上海+成都,感興趣的同窗,能夠把簡歷發到個人郵箱: yanhaijing@yeah.net

原文網址:http://yanhaijing.com/javascr...

相關文章
相關標籤/搜索