引子
在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;
}
如果你还对深拷贝有兴趣或者想研究,可以阅读lodash深拷贝相关代码,相信你会对深拷贝有进一步的理解