導語
這一次,通過本文徹底理解JavaScript深拷貝!
閱讀本文前可以先思考三個問題:
- JS世界里,數據是如何存儲的?
- 深拷貝和淺拷貝的區別是什么?
- 如何寫出一個真正合格的深拷貝?
本文會一步步解答這三個問題
數據是如何存儲的
先看一個問題,下面這段代碼的輸出結果是什么:
function foo(){
let a = {name:"dellyoung"}
let b = a
a.name = "dell"
console.log(a)
console.log(b)
}
foo()
JS的內存空間
要解答這個問題就要先了解,JS中數據是如何存儲的。
要理解JS中數據是如何存儲的,就要先明白其內存空間的種類。下圖就是JS的內存空間模型。
從模型中我們可以看出JS內存空間分為:代碼空間、棧空間、堆空間。
代碼空間:代碼空間主要是存儲可執行代碼的。
棧空間:棧(call stack
)指的就是調用棧,用來存儲執行上下文的。(每個執行上下文包括了:變量環境、詞法環境)
堆空間:堆(Heap
)空間,一般用來存儲對象的。
JS的數據類型
現在我們已經了解JS內存空間了。接下來我們了解一下JS中的數據類型 :
JS中一共有8中數據類型:Number、BigInt、String、Boolean、Symble、Null、Undefined、Object。
前7種稱為原始類型,最后一種Object稱為引用類型,之所以把它們區分成兩種類型,是因為它們在內存中存放的位置不同。
原始類型存放在棧空間中,具體點到執行上下文來說就是:用var定義的變量會存放在變量環境中,而用let、const定義的變量會存放在詞法環境中。並且對原始類型來說存放的是值,而引用類型存放的是指針,指針指向堆內存中存放的真正內容。
好啦,現在我們就明白JS中數據是如何存儲的了:原始類型存放在棧空間中,引用類型存放在堆空間中。
深拷貝和淺拷貝的區別
我們先來明確一下深拷貝和淺拷貝的定義:
淺拷貝
創建一個新對象,這個對象有着原始對象屬性值的一份精確拷貝。如果屬性是基本類型,拷貝的就是基本類型的值,如果屬性是引用類型,拷貝的就是內存地址 ,所以修改新拷貝的對象會影響原對象。
深拷貝
將一個對象從內存中完整的拷貝一份出來,從堆內存中開辟一個新的區域存放新對象,且修改新對象不會影響原對象
接下來我們就開始逐步實現一個深拷貝
自帶版
一般情況下如果不使用loadsh的深拷貝函數,我們可能會這樣寫一個深拷貝函數
JSON.parse(JSON.stringify());
但是這個方法局限性比較大:
- 會忽略 undefined
- 會忽略 symbol
- 不能序列化函數
- 不能解決循環引用的對象
顯然這絕對不是我們想要的一個合格的深拷貝函數
基本版
手動實現的話我們很容易寫出如下函數
const clone = (target) => {
let cloneTarget = {};
Object.keys(target).forEach((item) => {
cloneTarget[item] = target[item]
});
return cloneTarget
}
先看下這個函數做了什么:創建一個新對象,遍歷原對象,並且將需要拷貝的對象依次添加到新對象上,返回新對象。
既然是深拷貝的話,對於引用了類型我們不知道對象屬性的深度,我們可以通過遞歸來解決這個問題,接下來我們修改一下上面的代碼:
- 判斷是否是引用類型,如果是原始類型的話直接返回就可以了。
- 如果是原始類型,那么我們需要創建一個對象,遍歷原對象,將需要拷貝的對象執行深拷貝后再依次添加到新對象上。
- 另外如果對象有更深層次的對象,我們就可以通過遞歸來解決。
這樣我們就實現了一個最基本的深拷貝函數:
// 是否是引用類型
const isObject = (target) => {
return typeof target === 'object';
};
const clone = (target) => {
// 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
let cloneTarget = {};
Object.keys(target).forEach((item) => {
cloneTarget[item] = clone(target[item])
});
return cloneTarget
}
顯然這個深拷貝函數還有很多缺陷,比如:沒有考慮包含數組的情況
考慮數組
上面代碼中,我們只考慮了是object的情況,並沒有考慮存在數組的情況。改成兼容數組也非常簡單:
- 判斷傳入的對象是數組還是對象,我們分別對它們進行處理
- 判斷類型的方法有很多比如 type of、instanceof,但是這兩種方法缺陷都比較多,這里我使用的是Object.prototype.toString.call()的方法,它可以精准的判斷各種類型
- 當判斷出是數組時,那么我們需要創建一個新數組,遍歷原數組,將需要數組中的每個值執行深拷貝后再依次添加到新的數組上,返回新數組。
代碼如下:
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是否是引用類型
const isObject = (target) => {
return typeof target === 'object';
};
// 獲取標准類型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
const clone = (target) => {
// 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
const type = getType(target);
let cloneTarget;
switch (type) {
case typeArray:
// 數組
cloneTarget = [];
target.forEach((item, index) => {
cloneTarget[index] = clone(item)
});
return cloneTarget;
case typeObject:
// 對象
cloneTarget = {};
Object.keys(target).forEach((item) => {
cloneTarget[item] = clone(target[item])
});
return cloneTarget;
default:
return target;
}
return cloneTarget
}
OK,這樣我們的深拷貝函數就兼容了最常用的數組和對象的情況。
循環引用
但是如果出現下面這種情況
const target = {
field1: 1,
field2: {
child: 'dellyoung'
},
field3: [2, 4, 8]
};
target.target = target;
我們來拷貝這個target對象的話,就會發現會出現報錯:循環引用導致了棧溢出。
解決循環引用問題,我們需要額外有一個空間,來專門存儲已經被拷貝過的對象。當需要拷貝對象時,我們先從這個空間里找是否已經拷貝過,如果拷貝過了就直接返回這個對象,沒有拷貝過就進行接下來的拷貝。需要注意的是只有可遍歷的引用類型才會出現循環引用的情況。
很顯然這種情況下我們使用Map,以key-value來存儲就非常的合適:
- 用has方法檢查Map中有無克隆過的對象
- 有的話就獲取Map存入的值后直接返回
- 沒有的話以當前對象為key,以拷貝得到的值為value存儲到Map中
- 繼續進行克隆
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是否是引用類型
const isObject = (target) => {
return typeof target === 'object';
};
// 獲取標准類型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
const clone = (target, map = new Map()) => {
// 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
const type = getType(target);
// 用於返回
let cloneTarget;
// 處理循環引用
if (map.get(target)) {
// 已經放入過map的直接返回
return map.get(target)
}
switch (type) {
case typeArray:
// 數組
cloneTarget = [];
map.set(target, cloneTarget);
target.forEach((item, index) => {
cloneTarget[index] = clone(item, map)
});
return cloneTarget;
case typeObject:
// 對象
cloneTarget = {};
map.set(target, cloneTarget);
Object.keys(target).forEach((item) => {
cloneTarget[item] = clone(target[item], map)
});
return cloneTarget;
default:
return target;
}
return cloneTarget
}
性能優化
循環性能優化:
其實我們寫代碼的時候已經考慮到了性能優化了,比如:循環沒有使用 for in 循環而是使用的forEach循環,使用forEach或while循環會比for in循環快上不少的
WeakMap性能優化:
我們可以使用WeakMap來替代Map,提高性能。
const clone = (target, map = new WeakMap()) => {
// ...
};
為什么要這樣做呢?,先來看看WeakMap的作用:
WeakMap 對象是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是對象,而值可以是任意的。
那什么是弱引用呢?
在計算機程序設計中,弱引用與強引用相對,是指不能確保其引用的對象不會被垃圾回收器回收的引用。 一個對象若只被弱引用所引用,則被認為是不可訪問(或弱可訪問)的,並因此可能在任何時刻被回收。
我們默認創建一個對象:const obj = {},就默認創建了一個強引用的對象,我們只有手動將obj = null,它才會被垃圾回收機制進行回收,如果是弱引用對象,垃圾回收機制會自動幫我們回收。
我們來舉個例子:
let obj = { name : 'dellyoung'}
const target = new Map();
target.set(obj,'dell');
obj = null;
雖然我們手動將obj賦值為null,進行釋放,但是target依然對obj存在強引用關系,所以這部分內存依然無法被釋放。
基於此我們再來看WeakMap:
let obj = { name : 'dellyoung'}
const target = new WeakMap();
target.set(obj,'dell');
obj = null;
如果是WeakMap的話,target和obj存在的就是弱引用關系,當下一次垃圾回收機制執行的時候,這塊內存就會被釋放掉了。
如果我們要拷貝的對象非常龐大時,使用Map會對內存造成非常大的額外消耗,而且我們需要手動delete Map的key才能釋放這塊內存,而WeakMap會幫我們解決這個問題。
更多的數據類型
到現在其實我們已經解決了Number BigInt String Boolean Symbol Undefined Null Object Array,這9種情況了,但是引用類型中我們其實只考慮了Object和Array兩種數據類型,但是實際上所有的引用類型遠遠不止這兩個。
判斷引用類型
判斷是否是引用類型還需要考慮null和function兩種類型。
// 是否是引用類型
const isObject = (target) => {
if (target === null) {
return false;
} else {
const type = typeof target;
return type === 'object' || type === 'function';
}
};
獲取數據類型
獲取類型,我們可以使用toString來獲取准確的引用類型:
每一個引用類型都有toString方法,默認情況下,toString()方法被每個Object對象繼承。如果此方法在自定義對象中未被覆蓋,toString() 返回 "[object type]",其中type是對象的類型。
但是由於大部分引用類型比如Array、Date、RegExp等都重寫了toString方法,所以我們可以直接調用Object原型上未被覆蓋的toString()方法,使用call來改變this指向來達到我們想要的效果
// 獲取標准類型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
類型非常多,本文先考慮大部分常用的類型,其他類型就等小伙伴來探索啦
// 可遍歷類型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 非原始類型的 不可遍歷類型 Date RegExp Function
const typeDate = '[object Date]';
const typeRegExp = '[object RegExp]';
const typeFunction = '[object Function]';
可繼續遍歷類型
上面我們已經考慮的Object、Array都屬於可以繼續遍歷的類型,因為它們內存都還可以存儲其他數據類型的數據,另外還有Map,Set等都是可以繼續遍歷的類型,這里我們只考慮這四種常用的,其他類型等你來探索咯。
下面,我們改寫clone函數,使其對可繼續遍歷的數據類型進行處理:
// 可遍歷類型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 是否是引用類型
const isObject = (target) => {
if (target === null) {
return false;
} else {
const type = typeof target;
return type === 'object' || type === 'function';
}
};
// 獲取標准類型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
/*
* 1、處理原始類型 Number String Boolean Symbol Null Undefined
* 2、處理循環引用情況 WeakMap
* 3、處理可遍歷類型 Set Map Array Object
* */
const clone = (target, map = new WeakMap()) => {
// 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
// 用於返回
let cloneTarget;
// 處理循環引用
if (map.get(target)) {
// 已經放入過map的直接返回
return map.get(target)
}
// 處理可遍歷類型
switch (type) {
case typeSet:
// Set
cloneTarget = new Set();
map.set(target, cloneTarget);
target.forEach((item) => {
cloneTarget.add(clone(item, map))
});
return cloneTarget;
case typeMap:
// Map
cloneTarget = new Map();
map.set(target, cloneTarget);
target.forEach((value, key) => {
cloneTarget.set(key, clone(value, map))
});
return cloneTarget;
case typeArray:
// 數組
cloneTarget = [];
map.set(target, cloneTarget);
target.forEach((item, index) => {
cloneTarget[index] = clone(item, map)
});
return cloneTarget;
case typeObject:
// 對象
cloneTarget = {};
map.set(target, cloneTarget);
Object.keys(target).forEach((item) => {
cloneTarget[item] = clone(target[item], map)
});
return cloneTarget;
default:
return target;
}
};
這樣我們就完成了對Set和Map的兼容
考慮對象鍵名為Symbol類型
對於對象鍵名為Symbol
類型時,用Object.keys(target)
是獲取不到的,這時候就需要用到Object.getOwnPropertySymbols(target)
方法。
case typeObject:
// 對象
cloneTarget = {};
map.set(target, cloneTarget);
[...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => {
cloneTarget[item] = clone(target[item], map)
});
return cloneTarget;
這樣就實現了對於對象鍵名為Symbol
類型的兼容。
不可繼續遍歷類型
不可遍歷的類型有Number BigInt String Boolean Symbol Undefined Null Date RegExp Function 等等,但是前7中已經被isObject攔截了,於是我們先對后面Date RegExp Function進行處理,其實后面不止有這幾種,其他類型等你來探索咯。
其中對函數的處理要簡單說下,我認為克隆函數是沒有必要的其實,兩個對象使用一個在內存中處於同一個地址的函數也是沒有任何問題的,如下是lodash對函數的處理:
const isFunc = typeof value == 'function'
if (isFunc || !cloneableTags[tag]) {
return object ? value : {}
}
顯然如果發現是函數的話就會直接返回了,沒有做特殊的處理,這里我們暫時也這樣處理,以后有時間我會把拷貝函數的部分給補上。
// 可遍歷類型 Map Set Object Array
const typeMap = '[object Map]';
const typeSet = '[object Set]';
const typeObject = '[object Object]';
const typeArray = '[object Array]';
// 非原始類型的 不可遍歷類型 Date RegExp Function
const typeDate = '[object Date]';
const typeRegExp = '[object RegExp]';
const typeFunction = '[object Function]';
// 非原始類型的 不可遍歷類型的 集合(原始類型已經被過濾了不用再考慮了)
const simpleType = [typeDate, typeRegExp, typeFunction];
// 是否是引用類型
const isObject = (target) => {
if (target === null) {
return false;
} else {
const type = typeof target;
return type === 'object' || type === 'function';
}
};
// 獲取標准類型
const getType = (target) => {
return Object.prototype.toString.call(target);
};
/*
* 1、處理原始類型 Number String Boolean Symbol Null Undefined
* 2、處理不可遍歷類型 Date RegExp Function
* 3、處理循環引用情況 WeakMap
* 4、處理可遍歷類型 Set Map Array Object
* */
const clone = (target, map = new WeakMap()) => {
// 處理原始類型直接返回(Number BigInt String Boolean Symbol Undefined Null)
if (!isObject(target)) {
return target;
}
// 處理不可遍歷類型
const type = getType(target);
if (simpleType.includes(type)) {
switch (type) {
case typeDate:
// 日期
return new Date(target);
case typeRegExp:
// 正則
const reg = /\w*$/;
const result = new RegExp(target.source, reg.exec(target)[0]);
result.lastIndex = target.lastIndex; // lastIndex 表示每次匹配時的開始位置
return result;
case typeFunction:
// 函數
return target;
default:
return target;
}
}
// 用於返回
let cloneTarget;
// 處理循環引用
if (map.get(target)) {
// 已經放入過map的直接返回
return map.get(target)
}
// 處理可遍歷類型
switch (type) {
case typeSet:
// Set
cloneTarget = new Set();
map.set(target, cloneTarget);
target.forEach((item) => {
cloneTarget.add(clone(item, map))
});
return cloneTarget;
case typeMap:
// Map
cloneTarget = new Map();
map.set(target, cloneTarget);
target.forEach((value, key) => {
cloneTarget.set(key, clone(value, map))
});
return cloneTarget;
case typeArray:
// 數組
cloneTarget = [];
map.set(target, cloneTarget);
target.forEach((item, index) => {
cloneTarget[index] = clone(item, map)
});
return cloneTarget;
case typeObject:
// 對象
cloneTarget = {};
map.set(target, cloneTarget);
[...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach((item) => {
cloneTarget[item] = clone(target[item], map)
});
return cloneTarget;
default:
return target;
}
};
至此這個深拷貝函數已經能處理大部分的類型了:Number String Boolean Symbol Null Undefined Date RegExp Function Set Map Array Object,並且也能優秀的處理循環引用情況了
參考
總結
現在我們應該能理清楚寫一個合格深拷貝的思路了:
- 處理原始類型 如: Number String Boolean Symbol Null Undefined
- 處理不可遍歷類型 如: Date RegExp Function
- 處理循環引用情況 使用: WeakMap
- 處理可遍歷類型 如: Set Map Array Object
看完兩件事
- 歡迎加我微信(iamyyymmm),拉你進技術群,長期交流學習
- 關注公眾號「呆鵝實驗室」,和呆鵝一起學前端,提高技術認知
🌈點個贊支持我吧👉