引子
在js中,如何複製一個對象? 大家的第一反應是直接使用賦值語句賦值嘛,比如
let a = {a: 1};
let b = a;
看看打印結果
console.log(a) // {a: 1}
console.log(b) // {a: 1}
打印出來都是{a: 1},很不錯,但是這樣真的是拷貝出來一份了嗎? 我們再試下一下的操作:
a.b = 1;
console.log(a) // {a: 1, b: 1}
console.log(b) // {a: 1, b: 1}
這樣只修改了a對象,但是b對象也緊跟着改變了,這是什麼原因造成的呢?
js的類型
原來,在js中,存在兩個類型的概念,分別是基本類型與引用類型,而基本類型和引用類型的最主要的區別便是在計算機的儲存位置不同;
基本類型:Number
、String
、Boolen
、null
、undefined
、Symbol
、Bigint
引用類型:Object
、Array
、RegExp
、Date
、Function
基本類型儲存在棧(stack)中,它具有以下特性:
- 基本類型的比較是它們的值的比較
- 在複製基本類型值的時候,會開闢出一個新的內存空間,將值複製到新的內存空間
而引用類型儲存在堆(heap)中,它具有一下特性
- 引用類型的比較是他們地址的比較(也就是指針指向的內容是否一致)
- 引用類型值是保存在堆內存中的對象,變量保存的只是指向該內存的地址,在複製引用類型值的時候,其實只複製了指向該內存的地址
由此可以得出,基本類型的複製可以直接使用賦值語句,而引用類型想這樣直接複製則會得到一個新的指針指向該地址,並不會複製出一個新的對象出來,那如果我們需要複製一個新的對象,應該怎麼辦呢?
js對象的拷貝
js的拷貝,分爲淺拷貝與深拷貝
淺拷貝:
創建一個新對象,這個對象有着原始對象屬性值的一份精確拷貝。如果屬性是基本類型,拷貝的就是基本類型的值,如果屬性是引用類型,拷貝的就是內存地址 ,所以如果其中一個對象改變了這個地址,就會影響到另一個對象。
淺拷貝使用場景:
1. Object.assign()
Object.assign()方法用於將所有可枚舉屬性的值從一個或多個源對象複製到目標對象。它將返回目標對象。注意,Object.assign()不是深拷貝,如果非要說的話,他只是拷貝對象的第一層基本類型,引用類型拷貝的還是個指針,我們看下下面的例子:
let obj1 = {
a: 1,
b: {c: 2}
}
let obj2 = Object.assign({}, obj1);
console.log(obj2);
// {
// a: 1,
// b: {c: 2}
// }
obj1.a = 3;
obj1.b.c = 4;
console.log(obj1);
// {
// a: 3,
// b: { c: 4}
// }
console.log(b);
// {
// a: 1,
// b: {c: 4}
// }
// }
上面代碼改變對象 obj1 之後,對象 obj2 的基本屬性保持不變。但是當改變對象 obj1 中的對象 b 時,對象 obj2 相應的位置也發生了變化。
2. 展開運算符…
let obj1 = {
a: 1,
b: {c: 2}
}
let obj2 = {...obj1}
console.log(obj2); // {a: 1, b: {c: 2}}
obj1.a = 3;
obj1.b.c = 4;
console.log(obj1); // {a: 3, b: {c: 4}}
console.log(obj2); // {a: 1, b: {c: 4}}
由上面代碼所見,展開運算符…實際效果和Object.assign一樣。
3. Array.prototype.slice()、Array.prototype.concat()
let arr1 = [1,2,[3,4]];
let arr2 = arr1.slice(1);
console.log(arr2); // [2,[3,4]]
arr1[1] = 5;
arr1[2][0] = 6;
console.log(arr1); // [1,5,[6,4]]
console.log(arr2); // [2,[6,4]]
let arr1 = [1,[2,3]];
let arr2 = [4,5,6];
let arr3 = arr1.concat(arr2);
console.log(arr3); // [1,[2,3],4,5,6]
arr1[0] = 7;
arr1[1][0] = 8;
console.log(arr1); // [7,[8,3]]
console.log(arr3); // [1,[8,3],4,5,6]
由上可知Array的slice
、 concat
方法也不是深拷貝,因此在處理複雜數組的時候需要注意這裏。
深拷貝:
深拷貝會拷貝所有的屬性,並拷貝屬性指向的動態分配的內存。當對象和它所引用的對象一起拷貝時即發生深拷貝。深拷貝相比於淺拷貝速度較慢並且花銷較大。拷貝前後兩個對象互不影響。
如上,既然引用對象存儲的是指針,基本類型儲存的是值,那麼,我們可以把引用類型變成基本類型,在把這個基本類型轉換成引用類型重新賦值,這樣就達到了深拷貝引用類型的效果
let a = {a: 1};
let b = JSON.stringify(a);
let c = JSON.parse(b);
console.log(a); // {a: 1}
console.log(b); // {a: 1}
a.b = 2;
console.log(a); // {a: 1, b: 2}
console.log(b) // {a: 1}
這樣,我們就得到了一個新的對象。一切看起來都很完美,但是,當對象比較複雜時,又發現了新的問題
let a = {
a: "1",
b: undefined,
c: Symbol("dd"),
fn: function() {
return true;
},
};
console.log(JSON.stringify(a)); // {a: 1}
emmm,明明a對象有3個值,但是爲什麼JSON.stringify後只出現了1個?
原來,JSON.stringify具有以下特性:
undefined、symbol 和函數這三種情況,會直接忽略
let obj = {
name: 'muyiy',
a: undefined,
b: Symbol('muyiy'),
c: function() {}
}
console.log(obj); // { name: "muyiy", a: undefined, b: Symbol(muyiy), c: ƒ () }
let b = JSON.parse(JSON.stringify(obj));
console.log(b); // {name: "muyiy"}
循環引用情況下,會報錯
let obj = {
a: 1,
b: {
c: 2,
d: 3
}
}
obj.a = obj.b;
obj.b.c = obj.a;
let b = JSON.parse(JSON.stringify(obj)); // Uncaught TypeError: Converting circular structure to JSON
new Date 情況下,轉換結果不正確
new Date();
// Mon Dec 24 2018 10:59:14 GMT+0800 (China Standard Time)
JSON.stringify(new Date());
// ""2018-12-24T02:59:25.776Z""
JSON.parse(JSON.stringify(new Date()));
// "2018-12-24T02:59:41.523Z"
不能處理正則
let obj = {
name: "muyiy",
a: /'123'/
}
console.log(obj);
// {name: "muyiy", a: /'123'/}
let b = JSON.parse(JSON.stringify(obj));
console.log(b);
// {name: "muyiy", a: {}}
那麼,我們該怎麼避免這種情況呢?
其實,實現一個對象的深拷貝,可以把他分爲兩部分,即淺拷貝+遞歸,可以判斷當前屬性是否是對象,如果是對象的話就進行遞歸操作。
function deepClone1(obj) {
var target = {};
for(var key in obj) {
if( typeof obj[key] === 'object') {
target[key] = deepClone1(obj[key]);
} else {
target[key] = obj[key];
}
}
return target;
}
let obj1 = {a: 1, b: {c: 2}};
let obj2 = deepClone1(obj1);
console.log(obj2); // {a: 1, b: {c: 2}}
obj1.a = 3;
obj1.b.c = 4;
console.log(obj1); // {a: 3, b: {c: 4}}
console.log(obj2); // {a: 1, b: {c: 2}}
以上,就是一個深拷貝的簡單實現,但是,面對複雜的,多類型的對象,以上的方法還是有諸多缺陷。
1.沒有考慮null的情況
在js的設計中,object的前三位標誌是000,而null在32位表示中也全是0,因此,typeof null
也會打印出object
function deepClone2(obj) {
if (obj === null) return null; // 新增代碼,判斷obj是否爲null
var target = {};
for(var key in obj) {
if( typeof obj[key] === 'object') {
target[key] = deepClone2(obj[key]);
} else {
target[key] = obj[key];
}
}
return target;
}
2.沒有考慮數組的兼容
在js中,typeof 數組 得到的也是一個object,需要針對數組在做處理
function deepClone3(obj) {
if (obj === null) return null;
var target = Array.isArray(obj) ? []: {}; // 新增代碼,判斷是否是數組
for(var key in obj) {
if( typeof obj[key] === 'object') {
target[key] = deepClone3(obj[key]);
} else {
target[key] = obj[key];
}
}
return target;
}
複製代碼
3.沒有考慮對象中循環引用的情況
其實解決循環引用的思路,就是在賦值之前判斷當前值是否已經存在,避免循環引用,這裏我們可以使用es6的WeakMap來生成一個hash表
function deepClone4(obj, hash = new WeakMap()) {
if (obj === null) return null;
if (hash.has(obj)) return hash.get(obj); // 新增代碼,查哈希表
var target = Array.isArray(obj) ? []: {};
hash.set(obj, target); // 新增代碼,哈希表設值
for(var key in obj) {
if( typeof obj[key] === 'object') {
target[key] = deepClone4(obj[key], hash); // 傳入hash表
} else {
target[key] = obj[key];
}
}
return target;
}
var a = {b: 1};
a.c = a;
console.log(a); // {b:1, c: {b: 1, c:{......}}}
var b = deepClone4(a);
console.log(b); // {b:1, c: {b: 1, c:{......}}}
如果在es5中,同樣用數組也可以實現。
4.沒有考慮Symbol
判斷當前對象是否有Symbol,需要使用到方法Object.getOwnPropertySymbols()或者Reflect.ownKeys(),下面,我們使用Object.getOwnPropertySymbols()來實現一下Symbol的拷貝
function deepClone5(obj, hash = new WeakMap()) {
if (obj === null) return null;
if (hash.has(obj)) return hash.get(obj);
var target = Array.isArray(obj) ? []: {};
hash.set(obj, target);
// ============= 新增代碼
let symKeys = Object.getOwnPropertySymbols(obj); // 查找
if (symKeys.length) { // 查找成功
symKeys.forEach(symKey => {
if (typeof obj[symKey] === 'object') {
target[symKey] = deepClone5(obj[symKey], hash);
} else {
target[symKey] = obj[symKey];
}
});
}
// =============
for(var key in obj) {
if( typeof obj[key] === 'object') {
target[key] = deepClone5(obj[key], hash);
} else {
target[key] = obj[key];
}
}
return target;
}
5.es6中Map和Set的拷貝
由於typeof Map/Set對象 也爲 object,因此,在此處我們需要使用Object.prototype.toString.call()方法,下面,我們需要對deepClone函數進行一下改造
function deepClone6(obj, hash = new WeakMap()) {
// 判斷是否爲null
if (obj === null) return null;
// 設置hash表,判斷是否是循環引用
if (hash.has(obj)) return hash.get(obj);
// 判斷Symbol
let symKeys = Object.getOwnPropertySymbols(obj);
if (symKeys.length) {
symKeys.forEach(symKey =>{
if (typeof obj[symKey] === 'object') {
target[symKey] = deepClone6(obj[symKey], hash);
} else {
target[symKey] = obj[symKey];
}
});
}
// 判斷是否是對象,如果不是對象,則直接返回,如果是對象,則繼續執行
if (typeof obj === 'object') {
let target = null;
let result;
hash.set(obj, target);
let objType = Object.prototype.toString.call(obj);
switch (objType) {
case '[object Object]':
target = {};
break;
case '[object Array]':
target = [];
break;
case '[object Map]':
// 處理Map對象
result = new Map();
obj.forEach((value, key) =>{
result.set(key, deepClone6(value, hash))
})
return result
break;
case '[object Set]':
// 處理Set對象
obj.forEach((value) =>{
result.add(deepClone6(value, hash))
})
return result
break;
default:
break;
}
} else {
// 不是對象的情況
return obj;
}
for (var key in obj) {
if (typeof obj[key] === 'object') {
target[key] = deepClone6(obj[key], hash);
} else {
target[key] = obj[key];
}
}
return target;
}
6.Date對象,正則,以及函數
Date對象的複製可以直接返回一個新的new Date()對象,避免 setTime、setYear 等造成的引用改變,而正則,以及函數雖然是引用對象,也儲存在堆裏,但是一般情況下都不會給他們掛附加屬性,所以這裏一般情況下直接賦值就行
function deepClone7(obj, hash = new WeakMap()) {
// 判斷是否爲null
if (obj === null) return null;
// 設置hash表,判斷是否是循環引用
if (hash.has(obj)) return hash.get(obj);
// 判斷Symbol
let symKeys = Object.getOwnPropertySymbols(obj);
if (symKeys.length) {
symKeys.forEach(symKey =>{
if (typeof obj[symKey] === 'object') {
target[symKey] = deepClone7(obj[symKey], hash);
} else {
target[symKey] = obj[symKey];
}
});
}
// 判斷是否是對象,如果不是對象,則直接返回,如果是對象,則繼續執行
if (typeof obj === 'object' || typeof obj === 'function') {
let target = null;
let result;
hash.set(obj, target);
let objType = Object.prototype.toString.call(obj);
switch (objType) {
case '[object Object]':
target = {};
break;
case '[object Array]':
target = [];
break;
case '[object Map]':
// 處理Map對象
result = new Map();
obj.forEach((value, key) =>{
result.set(key, deepClone7(value, hash))
})
return result
break;
case '[object Set]':
// 處理Set對象
obj.forEach((value) =>{
result.add(deepClone7(value, hash))
})
return result
break;
case '[object Date]':
// 處理Date對象
return new Date(obj)
break;
default:
// 直接返回正則、函數
return obj;
break;
}
} else {
// 不是對象的情況
return obj;
}
for (var key in obj) {
if (typeof obj[key] === 'object') {
target[key] = deepClone7(obj[key], hash);
} else {
target[key] = obj[key];
}
}
return target;
}
7.避免遞歸爆棧
由於上面的深拷貝都是使用的遞歸,我們都知道一般遞歸都會大量消耗內存,存在爆棧的可能。針對這個弊端,我們通常有兩種解決思路,一種是尾遞歸,一種是把遞歸轉化成深度遍歷或者廣度遍歷。最後,簡單提供一下思路:
function cloneDeep8(x) {
const root = {};
// 棧
const loopList = [
{
parent: root,
key: undefined,
data: x,
}
];
while(loopList.length) {
// 廣度優先
const node = loopList.pop();
const parent = node.parent;
const key = node.key;
const data = node.data;
// 初始化賦值目標,key爲undefined則拷貝到父元素,否則拷貝到子元素
let res = parent;
if (typeof key !== 'undefined') {
res = parent[key] = {};
}
for(let k in data) {
if (data.hasOwnProperty(k)) {
if (typeof data[k] === 'object') {
// 下一次循環
loopList.push({
parent: res,
key: k,
data: data[k],
});
} else {
res[k] = data[k];
}
}
}
}
return root;
}