一.前言
每一個前端的 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()
來進行深拷貝。
對於更深層的數組或是對象(二級結構或以上)來說,我們一般會採取兩種思路:
- 序列化和序列化。(即
JSON.parse()
和JSON.stringify()
) - 深遞歸
所以這兩種思路就放在對象的深拷貝里一起講了。
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)
輸出結果如下:
就平常的業務情況來說,肯定是夠用的,當然它也不是銀彈,它只能處理對象和數組內容,除此之外的 reg
,date
,err
等類型對象都無法處理。
第二種. 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 Date
和 RegExp
對象的深拷貝失效
在 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
卻打印出了 name
和 age
屬性,從上圖也可以看出,這兩個屬性實際上是原型鏈 __proto__
上的屬性。
到這裏深拷貝的內容差不多就結束了,其實還有很多可以繼續挖掘的地方,比如說:Symbol
類型的深拷貝, Symbol
類型作爲 key
時的深拷貝,不可枚舉屬性的深拷貝,function
類型的深拷貝等等,由於篇幅原因就不在此篇文章中細說了,忙完手裏的事我會再接着寫一篇後續來補充更深入的內容。