JavaScript 中的淺拷貝和深拷貝

一.前言

每一個前端的 JavaScript 之路不一定是由《JavaScript 高級程序設計》開啓的,但是每一位前端都一定被“按值傳遞”和“按引用傳遞”這兩個概念坑過。現在你我應該都很清楚,在 JavaScript 中的object類型是按引用傳遞的,但是在函數參數中,所有參數都是按值傳遞的。

我們今天要談的東西,就起源於object型數據的複製與再操作,簡單來說,就是我們今天的主題:對象的淺拷貝和深拷貝。

二.按引用傳遞是什麼含義?

首先,我們需要快速回想一下在 JavaScript 中基本數據類型有哪些,請看下面:

1. number
2. string
3. boolean
4. null
5. undefined
6. symbol

引用數據類型只有一種:

object

當然,由此衍生出來的變種也有很多,包括Array,function等等,也可以看成是對象的一種。

接着我們得複習下 JavaScript 的堆棧中是怎麼存儲數據的。在 JS 中內存的使用和分配與其他語言也大同小異:

堆是動態分配內存,內存大小不一,也不會自動釋放。棧是自動分配相對固定大小的內存空間,並由系統自動釋放。

對以上6種基本數據類型來說,

當我們在 JavaScript 中聲明一個簡單基礎數據類型並初始化它的值時:

const NAME = 'waw';

此時這個值將以鍵值對的形式key:'NAME', value:'waw'保存在棧中。

但是對引用類型的數據來說卻並不是如此,對它來說,雖然也是以鍵值對形式存儲於棧中,但是value部分存儲的是指向堆中的地址,如:key:'NAME', value: 指向堆的指針,而value真正的值則存儲在堆中。而在 JavaScript 中是不允許直接操作堆中的內容的,所以我們日常操作對象時實際上操作的是對象的引用。

到這兒爲止,咱們的提前知識儲備已經差不多了,從上面的內容我們可以達成一個基礎的共識,那就是基礎數據類型是沒有淺拷貝深拷貝之說的,大家都是存儲在棧中的社會主義公民,都很平等。所以爲了接下來講解的層次性,我將從數組和對象,基本數據類型和引用數據類型,兩種角度出發來給大家講講淺拷貝和深拷貝。

三.JavaScript 的淺拷貝

3.1 從基本數據類型的數組角度來說

形如var arr = [1, '1', true, null, undefined, Symbol()],這一類數組內元素的集合,我們都可以稱它們爲基本數據類型的數組。

淺拷貝最簡單的就是var a = b了。舉個例子:

var arr = [1, '1', true, null, undefined, Symbol()];
var arrCopy = arr;
arrCopy[0] = 2;
console.log(arr)   
console.log(arrCopy)

結果如下:
在這裏插入圖片描述

很明顯,這就是個最簡單的淺拷貝。當然我們也可以自己實現一個基礎的淺拷貝函數:

function shallowClone(obj){
    let cloneObj = {}; 
    for(let key in obj){
        if(obj.hasOwnProperty(key)){
            cloneObj[key] = obj[key];
        }
    }
    return cloneObj;
}

3.2 從引用數據類型的數組角度來說

什麼是引用數據類型的數組?舉個簡單例子,形如[[1, 2], {age: 1}, Number(1), String(2)]的數組內元素的集合,都可以稱爲引用數據類型。當然,我們平常在業務中最常見的就是多維數組或者是對象數組。

對它來說,淺拷貝最直接的方式也是var a = b;舉個例子:

var arr = [[1, 2], {age: 1}, Number(1), String(2)];
var arrCopy = arr;
arrCopy[0] = [3, 4];
console.log(arr)   
console.log(arrCopy)

結果如下:
在這裏插入圖片描述

3.3 從對象角度來說

從對象角度來說,它就不像數組那樣好區分基本數據類型和引用數據類型了,因爲畢竟是key=> value鍵值對形式來存儲的結構。

我們一般也可以通過var a=b來進行淺拷貝。舉個例子:

var arr = {name:'waw', age:1};
var arrCopy = arr;
arrCopy.name = 'gcc';
console.log(arr)   
console.log(arrCopy)

結果如下:
在這裏插入圖片描述

這兒有個很有意思的事,ES6有一個Object.assgin()方法,可以拷貝(合併)對象並返回新對象。我們試試用它來複制上面的對象看會輸出什麼:

var arr = {name:'waw', age:1};
var arrCopy = Object.assign({}, arr);
arrCopy.name = 'gcc';
console.log(arr)   
console.log(arrCopy)

結果如下:
在這裏插入圖片描述

emmm…從結果來看,像是一個深拷貝方法,不着急,我們試試稍微複雜的結構:

var arr = {name:'waw', age:1, love:{ ball: 'football', game: 'tecent'}};
var arrCopy = Object.assign({}, arr);
arrCopy.love.game= 'alibaba';
console.log(arr);   
console.log(arrCopy);

結果如下:
在這裏插入圖片描述

你還可以試試其他層次大於一級的對象結構,我們會發現Object.assign()這個方法在大於一級的對象結構下進行的都是淺拷貝。所以,我們一般是建議在業務裏如果想使用深拷貝來處理對象,避免使用Object.assign()函數。

四.JavaScript 的深拷貝

要談深拷貝,首先我們要有一個共識,就是何爲深拷貝?簡單來說,就是對某個對象會不斷遞歸去遍歷複雜對象中的每一個層級,最後輸出的這樣一個過程,可以稱之爲深拷貝。有了這個前提,我們再來看看深拷貝在數組和對象中的具體實現。

4.1 從基本數據類型的數組角度來說

對於數組的深拷貝來說,其實你可以從這個角度出發:

在 JavaScript 中所有操作數組的方法裏,如果此操作是不修改原數組而是返回新數組,那麼可以判定這個方法可以做到深拷貝。

接下來我用幾個例子來支撐上面這個理論:

var arr = ['a', 'b', 'c'];
var arrCopySlice = arr.slice(0);
var arrCopyConcat = [].concat(arr);
var arrCopyMap = arr.map(el => el);
var arrCopyFilter = arr.filter(el => el);
var arrCopy = [...arr];
arrCopySlice[0] = 'test';
arrCopyConcat[0] = 'test';
arrCopyMap[0] = 'test';
arrCopyFilter[0] = 'test';
arrCopy[0] = 'test';
console.log('arr', arr)
console.log('arrCopySlice', arrCopySlice)
console.log('arrCopyConcat', arrCopyConcat)
console.log('arrCopyMap', arrCopyMap)
console.log('arrCopyFilter', arrCopyFilter)
console.log('arrCopy', arrCopy)

可以看到,我使用了arr.slice(), arr.concat(), arr.map(), arr.filter(), [...arr]這五個方法,以上5個方法都是滿足在不修改原數組的前提下返回新數組這一原則。輸出結果如下:
在這裏插入圖片描述

從輸出可以看出,改變了arrCopy並不會影響原數組arr。當然,我們還可以換句話來理解:

所謂數組深拷貝,也可以理解爲無論此數組內的數據有多少層級,它都可以一層層遍歷去獲取。

而以上5個例子,我們要注意都是基本數據類型,這意味着大家都是一層的結構,更深的層次,以上5個方法,都做不到深拷貝了,要切記這一點。

4.2 從引用數據類型的數組角度來說

這兒也有個很有意思的事,我們對數組使用Object.assgin()方法時,也是可以拷貝數組的,但是結果與拷貝對象有所不同:

var arr = [1, '1', {a:11}];
var arrCopy = Object.assign([],arr);
arrCopy[2].a = 22;
console.log(arr)   
console.log(arrCopy)

結果如下:
在這裏插入圖片描述
結果與對對象進行 Object.assgin() 操作時不同,爲淺拷貝,所以對於數組來說,一級結構也不能使用 Object.assgin() 來進行深拷貝。

對於更深層的數組或是對象(二級結構或以上)來說,我們一般會採取兩種思路:

  1. 序列化和序列化。(即 JSON.parse()JSON.stringify())
  2. 深遞歸

所以這兩種思路就放在對象的深拷貝里一起講了。

4.3 從對象角度來說

主要說說兩種思路。

第一種,序列化和反序列化

const obj = {a:1, b:'str', c:{name:'waw', age:20}};
let objCopy = JSON.parse(JSON.stringify(obj));
objCopy.c.age = 18;
console.log(obj)   
console.log(objCopy)

輸出結果如下:
在這裏插入圖片描述
就平常的業務情況來說,肯定是夠用的,當然它也不是銀彈,它只能處理對象和數組內容,除此之外的 regdateerr等類型對象都無法處理。

第二種. for in 深遞歸

function isObject(obj){
    return (typeof obj === 'object' || typeof obj === 'function') && obj !== null
}

function deepClone(obj){
     let isArray = Array.isArray(obj); 
     var cloneObj = isArray ? [] : {};
     for(let key in obj){
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
     }
     return cloneObj;
}

let obj = {a:1, b:2, c:{name:'waw', age:10}};
let copyObj = deepClone(obj);
copyObj.c.age = 20;
console.log('obj', obj)
console.log('copyObj', copyObj)

輸出結果如下:
在這裏插入圖片描述

五.深拷貝的特殊情況

5.1 DateRegExp 對象的深拷貝失效

在 MDN 的結構化克隆算法中有這麼一段話:
在這裏插入圖片描述

對應到我們現在的問題來說,意味着error, Date, RegExp 以及 Function 對象不能被上面的所深克隆。

舉個例子:

let obj = {date:new Date(), reg:/reg/g, func:function(){}};
let copyObj = deepClone(obj);
console.log('obj', obj)
console.log('copyObj', copyObj)

我們可以看到結果:
在這裏插入圖片描述
copyObj裏的屬性全是空對象{},深拷貝失敗。

怎麼解決呢?可以通過構建它們對應的構造函數來處理這個問題:

function isObject(obj){
    return (typeof obj === 'object' || typeof obj === 'function') && obj !== null
}

function deepClone(obj){
     let cloneObj;
     let Constructor = obj.constructor;
     switch(Constructor){
        case RegExp:
            cloneObj = new Constructor(obj);
            break;
        case Date:
            cloneObj = new Constructor(Number(obj));
            break;
        default:
            cloneObj = new Constructor();
     }
     for(let key in obj){
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key]) : obj[key]
     }
     return cloneObj;
}
let obj = {date:new Date(), reg:/reg/g};
let copyObj = deepClone(obj);
console.log('obj', obj)
console.log('copyObj', copyObj)
console.log(obj.date === copyObj.date)
console.log(obj.reg === copyObj.reg)

輸出如下:
在這裏插入圖片描述

5.2 環對象

什麼是環對象?形如下面這種結構就叫環對象:

const obj = {};
obj.private = obj;

我們用上面的那個 deepClone() 函數對此對象進行復制,則會拋出棧溢出的異常:Maximum call stack size exceeded

怎麼處理呢?其實原理也簡單,就是用一個哈希表來存儲已經拷貝過的內容,然後在拷貝前對哈希表裏的內容進行判斷,如果對象已經存在,則直接返回此對象。

function isObject(obj){
    return (typeof obj === 'object' || typeof obj === 'function') && obj !== null
}


function deepClone(obj, hash = new WeakMap()){
     let cloneObj;
     let Constructor = obj.constructor;
     if(hash.has(obj)){
        return hash.get(obj);
     }
     
     switch(Constructor){
        case RegExp:
            cloneObj = new Constructor(obj);
            break;
        case Date:
            cloneObj = new Constructor(Number(obj));
            break;
        default:
            cloneObj = new Constructor();
            hash.set(obj, cloneObj);
     }
     
     for(let key in obj){
        cloneObj[key] = isObject(obj[key]) ? deepClone(obj[key], hash) : obj[key]
     }
     return cloneObj;
}
let obj = {date:new Date(), reg:/reg/g};
obj.private = obj;
let copyObj = deepClone(obj);
console.log('obj', obj)
console.log('copyObj', copyObj)

輸出結果如下:
在這裏插入圖片描述
我們可以看到,private 中是一個 Circular 對象,即無限自循環對象。拷貝是成功的。

你也可以把上面的代碼放到瀏覽器中執行,就可以看到 Circular 對象的具體內容:
在這裏插入圖片描述

5.3 原型鏈上的屬性

我們知道,函數有protorype屬性,被稱爲原型鏈。對象也有原型鏈,其屬性名爲__proto__。如果你想拷貝某個對象__proto__上的屬性,你可以使用for...in。看個例子:

const obj = {
	name:'waw',
	age:11
};
const obj2 = Object.create(obj);
for(let key in obj2){
	console.log(key)
}

輸出結果如下:
在這裏插入圖片描述

我們可以看到,obj2 本是一個空對象,並沒有屬性,但是 for...in 卻打印出了 nameage 屬性,從上圖也可以看出,這兩個屬性實際上是原型鏈 __proto__ 上的屬性。

到這裏深拷貝的內容差不多就結束了,其實還有很多可以繼續挖掘的地方,比如說:Symbol 類型的深拷貝, Symbol 類型作爲 key 時的深拷貝,不可枚舉屬性的深拷貝,function 類型的深拷貝等等,由於篇幅原因就不在此篇文章中細說了,忙完手裏的事我會再接着寫一篇後續來補充更深入的內容。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章