聊聊物件深拷貝和淺拷貝

yeyan1996發表於2018-12-29

寫在前面

各類技術論壇關於深拷貝的部落格有很多,有些寫的也比我好,那為什麼我還要堅持寫這篇部落格呢,之前看到的一篇部落格中有句話寫的非常好

學習就好比是座大山,人們沿著不同的路登山,分享著自己看到的風景。你不一定能看到別人看到的風景,體會到別人的心情。只有自己去登山,才能看到不一樣的風景,體會才更加深刻。

寫部落格的初衷也是作為自己學到的知識點的總結,同時也希望能給點開這篇文章的人一些幫助,在前端開發的路上能夠少一點坎坷多一點希望

基本型別的值和引用型別的值

JavaScript的變數中包含兩種型別的值

  1. 基本型別值 基本型別值指的是儲存在棧中的一些簡單的資料段
let str = 'a';
let num = 1;
複製程式碼

在JavaScript中基本資料型別有String,Number,Undefined,Null,Boolean,在ES6中,又定義了一種新的基本資料型別Symbol,所以一共有6種

基本型別是按值訪問的,從一個變數複製基本型別的值到另一個變數後這2個變數的值是完全獨立的,即使一個變數改變了也不會影響到第二個變數

let str1 = 'a';
let str2 = str1;
str2 = 'b';
console.log(str2); //'b'
console.log(str1); //'a'
複製程式碼
  1. 引用型別值 引用型別值是引用型別的例項,它是儲存在堆記憶體中的一個物件,引用型別是一種資料結構,最常用的是Object,Array,Function型別,另外還有Date,RegExp,Error等,ES6同樣也提供了Set,Map2種新的資料結構

JavaScript是如何複製引用型別的

JavaScript對於基本型別和引用型別的賦值是不一樣的

let obj1 = {a:1};
let obj2 = obj1;
obj2.a = 2;
console.log(obj1); //{a:2}
console.log(obj2); //{a:2}
複製程式碼

在這裡只修改了obj1中的a屬性,卻同時改變了ob1和obj2中的a屬性

當變數複製引用型別值的時候,同樣和基本型別值一樣會將變數的值複製到新變數上,不同的是對於變數的值,它是一個指標,指向儲存在堆記憶體中的物件(JS規定放在堆記憶體中的物件無法直接訪問,必須要訪問這個物件在堆記憶體中的地址,然後再按照這個地址去獲得這個物件中的值,所以引用型別的值是按引用訪問)

變數的值也就是這個指標是儲存在棧上的,當變數obj1複製變數的值給變數obj2時,obj1,obj2只是一個儲存在棧中的指標,指向同一個儲存在堆記憶體中的物件,所以當通過變數obj1操作堆記憶體的物件時,obj2也會一起改變

儲存在於棧中的變數和堆記憶體中物件的關係

再舉個例子,小明(obj1變數)知道他家的地址(物件{a:1}),然後小明告訴了小剛(obj2變數)他家的地址(複製變數),小剛這個時候就知道了小明家的地址,然後小剛去小明家把小明家的門給拆了(修改物件),小明回家一看就會發現門沒了,這時小明和小剛去這個地址的時候都會看到一個沒有門的家-.-(物件的修改反映到變數)

淺拷貝

對於淺拷貝的定義可以理解為

建立一個新物件,這個物件有著原始物件屬性值的一份精確拷貝。如果屬性是基本型別,拷貝的就是基本型別的值,如果屬性是引用型別,拷貝的就是記憶體地址 ,所以如果其中一個物件改變了這個地址,就會影響到另一個物件。

以下是一些JavaScript提供的淺拷貝方法

Object.assign

ES6中拷貝物件的方法,接受的第一個引數是拷貝的目標,剩下的引數是拷貝的源物件(可以是多個)

語法:Object.assign(target, ...sources)

let target = {};
let source = { a: { b: 2 } };
Object.assign(target, source);
console.log(target); // { a: { b: 2 } };
複製程式碼

首先我們先通過 Object.assign 將 source 拷貝到 target 物件中,然後我們嘗試將 source 物件中的 b 屬性修改由 2 修改為 10

let target = {};
let source = { a: { b: 2 } };
Object.assign(target, source);
console.log(target); // { a: { b: 10 } };
source.a.b = 10;
console.log(source); // { a: { b: 10 } };
console.log(target); // { a: { b: 10 } };
複製程式碼

通過控制檯可以發現,列印結果中,三個 target 裡的 b 屬性都變為 10 了,證明 Object.assign 是一個淺拷貝

Object.assign 只是在根屬性(物件的第一層級)建立了一個新的物件,但是對於屬性的值是物件的話只會拷貝一份相同的記憶體地址

Object.assign還有一些注意的點是:

  1. 不會拷貝物件繼承的屬性
  2. 不可列舉的屬性
  3. 屬性的資料屬性/訪問器屬性
  4. 可以拷貝Symbol型別

可以這樣理解,Object.assign 會從左往右遍歷源物件(sources)的所有屬性,然後用 = 賦值到目標物件(target)

let obj1 = {
    a:{
        b:1
    },
    sym:Symbol(1)
};
Object.defineProperty(obj1,'innumerable',{
    value:'不可列舉屬性',
    enumerable:false
});
let obj2 = {};
Object.assign(obj2,obj1)
obj1.a.b = 2;
console.log('obj1',obj1); 
console.log('obj2',obj2); 
複製程式碼

聊聊物件深拷貝和淺拷貝
可以看到Symbol型別可以正確拷貝,但是不可列舉的屬性被忽略了並且改變了obj1.a.b的值,obj2.a.b的值也會跟著改變,說明依舊存在訪問的是堆記憶體中同一個物件的問題

題外話: 在Object.assgin中target,source引數如果是基本資料型別會被包裝成一個基本包裝型別,更多介紹請參考MDN

擴充套件運算子

利用擴充套件運算子可以在構造字面量物件時,進行克隆或者屬性拷貝

語法:let cloneObj = { ...obj };

let obj = {a:1,b:{c:1}}
let obj2 = {...obj};
obj.a=2;
console.log(obj); //{a:2,b:{c:1}}
console.log(obj2); //{a:1,b:{c:1}}

obj.b.c = 2;
console.log(obj); //{a:2,b:{c:2}}
console.log(obj2); //{a:1,b:{c:2}}
複製程式碼

擴充套件運算子Object.assign()有同樣的缺陷,對於值是物件的屬性無法完全拷貝成2個不同物件,但是如果屬性都是基本型別的值的話,使用擴充套件運算子更加方便

Array.prototype.slice

slice() 方法返回一個新的陣列物件,這一物件是一個由 begin和 end(不包括end)決定的原陣列的淺拷貝。原始陣列不會被改變。

語法: arr.slice(begin, end);

在ES6以前,沒有剩餘運算子,Array.from的時候可以用 Array.prototype.slice將arguments類陣列轉為真正的陣列,它返回一個淺拷貝後的的新陣列

Array.prototype.slice.call({0: "aaa", length: 1}) //["aaa"]

let arr = [1,2,3,4]
console.log(arr.slice() === arr); //false
複製程式碼

Array.prototype.concat

對於陣列的concat方法其實也是淺拷貝,所以連線一個含有引用型別的陣列需要注意修改原陣列中的元素的屬性會反映到連線後的陣列

聊聊物件深拷貝和淺拷貝

深拷貝

淺拷貝只在根屬性上在堆記憶體中建立了一個新的的物件,複製了基本型別的值,但是複雜資料型別也就是物件則是拷貝相同的地址,而深拷貝則是對於複雜資料型別在堆記憶體中開闢了一塊記憶體地址用於存放複製的物件並且把原有的物件複製過來,這2個物件是相互獨立的,也就是2個不同的地址

將一個物件從記憶體中完整的拷貝一份出來,從堆記憶體中開闢一個新的區域存放新物件,且修改新物件不會影響原物件

一個簡單的深拷貝

let obj1 = {
    a: {
        b: 1
    },
    c: 1
};
let obj2 = {};

obj2.a = {}
obj2.c = obj1.c
obj2.a.b = obj1.a.b;
console.log(obj1); //{a:{b:1},c:1};
console.log(obj2); //{a:{b:1},c:1};
obj1.a.b = 2;
console.log(obj1); //{a:{b:2},c:1};
console.log(obj2); //{a:{b:1},c:1};
複製程式碼

在上面的程式碼中,我們新建了一個obj2物件,同時根據obj1物件的a屬性是一個引用型別,我們給obj2.a的值也新建一個新物件(即在記憶體中新開闢了一塊記憶體地址),然後把obj1.a.b屬性的值數字1複製給obj2.a.b,因為數字1是基本型別的值,所以改變obj1.a.b的值後,obj2.a不會收到影響,因為他們的引用是完全2個獨立的物件,這就完成了一個簡單的深拷貝

JSON.stringify

JSON.stringify()是目前前端開發過程中最常用的深拷貝方式,原理是把一個物件序列化成為一個JSON字串,將物件的內容轉換成字串的形式再儲存在磁碟上,再用JSON.parse()反序列化將JSON字串變成一個新的物件

let obj1 = {
    a:1,
    b:[1,2,3]
}
let str = JSON.stringify(obj1)
let obj2 = JSON.parse(str)
console.log(obj2); //{a:1,b:[1,2,3]}
obj1.a = 2
obj1.b.push(4);
console.log(obj1); //{a:2,b:[1,2,3,4]}
console.log(obj2); //{a:1,b:[1,2,3]}
複製程式碼

通過JSON.stringify實現深拷貝有幾點要注意

  1. 拷貝的物件的值中如果有函式,undefined,symbol則經過JSON.stringify()序列化後的JSON字串中這個鍵值對會消失
  2. 無法拷貝不可列舉的屬性,無法拷貝物件的原型鏈
  3. 拷貝Date引用型別會變成字串
  4. 拷貝RegExp引用型別會變成空物件
  5. 物件中含有NaN、Infinity和-Infinity,則序列化的結果會變成null
  6. 無法拷貝物件的迴圈應用(即obj[key] = obj)
function Obj() {
    this.func = function () {
        alert(1) 
    };
    this.obj = {a:1};
    this.arr = [1,2,3];
    this.und = undefined;
    this.reg = /123/;
    this.date = new Date(0);
    this.NaN = NaN
    this.infinity = Infinity
    this.sym = Symbol(1)
}
let obj1 = new Obj();
Object.defineProperty(obj1,'innumerable',{
    enumerable:false,
    value:'innumerable'
})
console.log('obj1',obj1);
let str = JSON.stringify(obj1);
let obj2 = JSON.parse(str);
console.log('obj2',obj2);
複製程式碼

列印出來的結果如下

聊聊物件深拷貝和淺拷貝

可以看到除了Object物件和陣列其他基本都和原來的不一樣,obj1的constructor是Obj建構函式,而obj2的constructor指向了Object,對於迴圈引用則是直接報錯了

雖說通過JSON.stringify()方法深拷貝物件也有很多無法實現的功能,但是對於日常的開發需求(物件和陣列),使用這種方法是最簡單和快捷的

使用第三方庫實現物件的深拷貝

1.lodash

2.jQuery

以上2個第三方的庫都很好的封裝的深拷貝的方法,有興趣的同學可以去深入研究一下

自己來實現一個深拷貝函式

遞迴

這裡簡單封裝了一個deepClone的函式,for in遍歷傳入引數的值,如果值是引用型別則再次呼叫deepClone函式,並且傳入第一次呼叫deepClone引數的值作為第二次呼叫deepClone的引數,如果不是引用型別就直接複製

let obj1 = {
    a:{
        b:1
    }
};
function deepClone(obj) {
    let cloneObj = {}; //在堆記憶體中新建一個物件
    for(let key in obj){ //遍歷引數的鍵
       if(typeof obj[key] ==='object'){ 
          cloneObj[key] = deepClone(obj[key]) //值是物件就再次呼叫函式
       }else{
           cloneObj[key] = obj[key] //基本型別直接複製值
       }
    }
    return cloneObj 
}
let obj2 = deepClone(obj1);
obj1.a.b = 2;
console.log(obj2); //{a:{b:1}}
複製程式碼

但是還有很多問題

  • 首先這個deepClone函式並不能複製不可列舉的屬性以及Symbol型別
  • 這裡只是針對Object引用型別的值做的迴圈迭代,而對於Array,Date,RegExp,Error,Function引用型別無法正確拷貝
  • 物件成環,即迴圈引用 (例如:obj1.a = obj)

本人總結的深拷貝的方法

看過很多關於深拷貝的部落格,本人總結出了一個能夠深拷貝ECMAScript的原生引用型別的方法

const isComplexDataType = obj => (typeof obj === 'object' || typeof obj === 'function') && (obj !== null)

const deepClone = function (obj, hash = new WeakMap()) {

    if (obj.constructor === Date) return new Date(obj);   //日期物件就返回一個新的日期物件
    if (obj.constructor === RegExp) return new RegExp(obj);  //正則物件就返回一個新的正則物件

    //如果成環了,引數obj = obj.loop = 最初的obj 會在WeakMap中找到第一次放入的obj提前返回第一次放入WeakMap的cloneObj
    if (hash.has(obj)) return hash.get(obj)

    let allDesc = Object.getOwnPropertyDescriptors(obj);     //遍歷傳入引數所有鍵的特性
    let cloneObj = Object.create(Object.getPrototypeOf(obj), allDesc); //繼承原型鏈

    hash.set(obj, cloneObj)

    for (let key of Reflect.ownKeys(obj)) {   //Reflect.ownKeys(obj)可以拷貝不可列舉屬性和符號型別
        // 如果值是引用型別(非函式)則遞迴呼叫deepClone
        cloneObj[key] =
            (isComplexDataType(obj[key]) && typeof obj[key] !== 'function') ?
                deepClone(obj[key], hash) : obj[key];
    }
    return cloneObj;
};

let obj = {
    num: 0,
    str: '',
    boolean: true,
    unf: undefined,
    nul: null,
    obj: {
        name: '我是一個物件',
        id: 1
    },
    arr: [0, 1, 2],
    func: function () {
        console.log('我是一個函式')
    },
    date: new Date(0),
    reg: new RegExp('/我是一個正則/ig'),
    [Symbol('1')]: 1,
};

Object.defineProperty(obj, 'innumerable', {
    enumerable: false,
    value: '不可列舉屬性'
});

obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj))

obj.loop = obj

let cloneObj = deepClone(obj);

console.log('obj', obj);
console.log('cloneObj', cloneObj);

for (let key of Object.keys(cloneObj)) {
    if (typeof cloneObj[key] === 'object' || typeof cloneObj[key] === 'function') {
        console.log(`${key}相同嗎? `, cloneObj[key] === obj[key])
    }
}
複製程式碼

這個函式有幾個要點

  1. 利用Reflect.ownKeys方法,能夠遍歷物件的不可列舉屬性和Symbol型別
  2. 當引數為Date,RegExp型別則直接生成一個新的例項
  3. 使用Object.getOwnPropertyDescriptors獲得物件的所有屬性對應的特性,結合Object.create建立一個新物件繼承傳入原物件的原型鏈
  4. 利用WeakMap型別作為雜湊表,WeakMap因為是弱引用的可以有效的防止記憶體洩露,作為檢測迴圈引用很有幫助,如果存在迴圈引用直接返回WeakMap儲存的值

這裡我用全等判斷列印了2個物件的屬性是否相等,通過列印的結果可以看到,雖然值是一樣的,但是在記憶體中是兩個完全獨立的物件

聊聊物件深拷貝和淺拷貝

上述的深拷貝函式中Null和Function型別引用的還是同一個物件,因為deepClone函式對於物件的值是函式或者null時直接返回,這裡沒有深拷貝函式,如果需要深拷貝一個函式,可以考慮使用Function建構函式或者eval?這裡還有待研究

總結

  1. 封裝的deepClone方法雖然能實現對ECMAScript原生引用型別的拷貝,但是對於物件來說範圍太廣了,仍有很多無法準確拷貝的(比如DOM節點),但是在日常開發中一般並不需要拷貝很多特殊的引用型別,深拷貝物件使用JSON.stringify依然是最方便的方法之一(當然也需要了解JSON.stringify的缺點)

  2. 實現一個完整的深拷貝是非常複雜的,需要考慮到很多邊界情況,這裡我也只是對部分的原生的建構函式進行了深拷貝,對於特殊的引用型別有拷貝需求的話,建議還是藉助第三方完整的庫

  3. 對於深入研究深拷貝的原理有助於理解JavaScript引用型別的特點,以及遇到相關特殊的問題也能迎刃而解,對於提高JavaScript的基礎還是很有幫助的~~~

感謝觀看

參考資料

深入JS深拷貝物件

JavaScript高階程式設計第三版

相關文章