淺談深拷貝和淺拷貝

深拷貝和淺拷貝

說起深拷貝和淺拷貝,首先我們來看兩個栗子

// 栗子1
var a = 1,b=a;
console.log(a);
console.log(b)
b = 2;
console.log(a);
console.log(b)
// 栗子2
var obj1 = {x: 1, y: 2}, obj2 = obj1;
console.log(obj1) //{x: 1, y: 2}
console.log(obj2) //{x: 1, y: 2}
obj2.x = 2; //修改obj2.x
console.log(obj1) //{x: 2, y: 2}
console.log(obj2) //{x: 2, y: 2}

按照慣性思維,栗子1中obj1應該跟a一樣,不會因另外一個值的改變而改變的啊,而這裏卻是obj1跟着obj2的改變而改變了?同樣都是變量,怎麼就表現不一樣了呢?難道存在等級上的優劣?此處需要沉思一小會。要解決這個問題,就要引入一個JS中基本類型和引用類型的概念了。

基本類型和引用類型

ECMAScript變量包含兩種不同數據類型的值:基本類型值和引用類型值。基本類型值指的是那些保存在棧內存中的簡單數據段,即這種值完全保存在內存中的一個位置。而引用類型值是指那些保存堆內存中的對象,意思是變量中保存的實際上只是一個指針,這個指針指向內存中的另一個位置,該位置保存對象。

兩類數據的保存方式

數據保存格式

從上圖可以看到,棧內存主要用於存儲各種基本類型的變量,包括Boolean、Number、String、Undefined、Null等以及對象變量的指針。而堆內存主要負責對象Object這種變量類型的存儲。目前基本類型有:
Boolean、Null、Undefined、Number、String、Symbol,引用類型有:Object、Array、Function。Symbol就是ES6纔出來的,之後也可能會有新的類型出來。

讓我們再回到前面的案例,栗子1中的值爲基本類型,栗子2中的值爲引用類型,栗子2中的賦值就是典型的淺拷貝。我們需要明確一點,深拷貝與淺拷貝的概念只存在於引用類型。

既然已經知道了深拷貝與淺拷貝的來由,那麼該如何實現深拷貝?我們分別來看看Array和Object自有方法是否支持:

var arr1 = [1, 2];
var arr2 = arr1.slice();
console.log(arr1); //[1, 2]
console.log(arr2); //[1, 2]

arr2[0] = 3; //修改arr2
console.log(arr1); //[1, 2]
console.log(arr2); //[3, 2]

此時,arr2的修改並沒有影響到arr1,看來深拷貝的實現並沒有那麼難嘛。我們把arr1改成二維數組再來看看結果

var arr1 = [1, 2, [3, 4]];
var arr2 = arr1.slice();
console.log(arr1); //[1, 2, [3, 4]]
console.log(arr2); //[1, 2, [3, 4]]

arr2[2][1] = 5; 
console.log(arr1); //[1, 2, [3, 5]]
console.log(arr2); //[1, 2, [3, 5]]

咦,arr2又改變了arr1,看來slice()只能實現一維數組的深拷貝,並不能實現真正的深拷貝。與之有同等特性的還有:concat、Array.from() 。

研究完Array,我們來看看Object

var obj1 = {x: 1, y: 2};
var obj2 = Object.assign({}, obj1);
console.log(obj1) //{x: 1, y: 2}
console.log(obj2) //{x: 1, y: 2}

obj2.x = 2; //修改obj2.x
console.log(obj1) //{x: 1, y: 2}
console.log(obj2) //{x: 2, y: 2}
var obj1 = {
    x: 1, 
    y: {
        m: 1
    }
};
var obj2 = Object.assign({}, obj1);
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 1, y: {m: 1}}
obj2.y.m = 2; //修改obj2.y.m
console.log(obj1) //{x: 1, y: {m: 2}}
console.log(obj2) //{x: 2, y: {m: 2}}

經實踐證明,Object.assign()跟Array一樣也只能實現一維對象的深拷貝。造成只能實現一維對象深拷貝的原因是第一層的屬性確實實現了深拷貝,擁有了獨立的內存,但更深的屬性卻仍然公用了地址,所以纔會造成上面的問題。

那怎麼真正的實現引用類型的深拷貝呢?接下來要有請正主入場

1.JSON.parse(JSON.stringify(obj))

var obj1 = {
    x: 1, 
    y: {
        m: 1
    }
};
var obj2 = JSON.parse(JSON.stringify(obj1));
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 1, y: {m: 1}}

obj2.y.m = 2; //修改obj2.y.m
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 2, y: {m: 2}}

JSON.parse(JSON.stringify(obj)) 簡單粗暴,簡簡單單讓你功力倍增,不過MDN文檔的描述有句話寫的很清楚:

undefined、任意的函數以及 symbol 值,在序列化過程中會被忽略(出現在非數組對象的屬性值中時)或者被轉換成 null(出現在數組中時)。詳情可以戳這裏MDN文檔
var obj1 = {
    x: 1,
    y: undefined,
    z: function add(z1, z2) {
        return z1 + z2
    },
    a: Symbol("foo")
};
var obj2 = JSON.parse(JSON.stringify(obj1));
console.log(obj1) //{x: 1, y: undefined, z: ƒ, a: Symbol(foo)}
console.log(JSON.stringify(obj1)); //{"x":1}
console.log(obj2) //{x: 1}

經實踐證明,在將obj1進行JSON.stringify()序列化的過程中,y、z、a都被忽略了,也就驗證了MDN文檔的描述。既然這樣,那JSON.parse(JSON.stringify(obj))的使用也是有侷限性的,不能深拷貝含有undefined、function、symbol值的對象,不過JSON.parse(JSON.stringify(obj))簡單粗暴,已經滿足90%的使用場景了。
經過驗證,我們發現JS 提供的自有方法並不能徹底解決Array、Object的深拷貝問題。只能祭出大殺器:遞歸

2.遞歸

function deepCopy(obj) {
    // 創建一個新對象
    let result = {}
    let keys = Object.keys(obj),
        key = null,
        temp = null;

    for (let i = 0; i < keys.length; i++) {
        key = keys[i];    
        temp = obj[key];
        // 如果字段的值也是一個對象則遞歸操作
        if (temp && typeof temp === 'object') {
            result[key] = deepCopy(temp);
        } else {
        // 否則直接賦值給新對象
            result[key] = temp;
        }
    }
    return result;
}

var obj1 = {
    x: {
        m: 1
    },
    y: undefined,
    z: function add(z1, z2) {
        return z1 + z2
    },
    a: Symbol("foo")
};

var obj2 = deepCopy(obj1);
obj2.x.m = 2;

console.log(obj1); //{x: {m: 1}, y: undefined, z: ƒ, a: Symbol(foo)}
console.log(obj2); //{x: {m: 2}, y: undefined, z: ƒ, a: Symbol(foo)}

可以看到,遞歸完美的解決了前面遺留的所有問題。但是,還有一個非常特殊極端的場景:循環引用拷貝

var obj1 = {
    x: 1, 
    y: 2
};
obj1.z = obj1;

var obj2 = deepCopy(obj1);

此時如果調用剛纔的deepCopy函數的話,會陷入一個循環的遞歸過程,從而導致爆棧。解決這個問題也非常簡單,只需要判斷一個對象的字段是否引用了這個對象或這個對象的任意父級即可

function deepCopy(obj, parent = null) {
    // 創建一個新對象
    let result = {};
    let keys = Object.keys(obj),
        key = null,
        temp= null,
        _parent = parent;
    // 該字段有父級則需要追溯該字段的父級
    while (_parent) {
        // 如果該字段引用了它的父級則爲循環引用
        if (_parent.originalParent === obj) {
            // 循環引用直接返回同級的新對象
            return _parent.currentParent;
        }
        _parent = _parent.parent;
    }
    for (let i = 0; i < keys.length; i++) {
        key = keys[i];
        temp= obj[key];
        // 如果字段的值也是一個對象
        if (temp && typeof temp=== 'object') {
            // 遞歸執行深拷貝 將同級的待拷貝對象與新對象傳遞給 parent 方便追溯循環引用
            result[key] = DeepCopy(temp, {
                originalParent: obj,
                currentParent: result,
                parent: parent
            });

        } else {
            result[key] = temp;
        }
    }
    return result;
}

var obj1 = {
    x: 1, 
    y: 2
};
obj1.z = obj1;

var obj2 = deepCopy(obj1);
console.log(obj1); 
console.log(obj2); 

總結

  • 簡單的一維層次的拷貝可以利用數組自身方法和對象的Object.assign實現,在二維層次上方法失效,無法實現深拷貝
  • 簡單粗暴的常見的拷貝可以通過JSON.parse(JSON.stringify(obj))實現,但對於屬性的某些特殊類型的值失效。
  • 終極方法,用遞歸實現引用類型的深拷貝
  • 當然還有其他方法,比如使用第三方庫內封裝的方法
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章