前言
对于前端同学来说,对象的深拷贝和浅拷贝可以说是面试中最火热的题目之一了,今天我们一起来把它盘明白。
1、深拷贝和浅拷贝介绍
深拷贝和浅拷贝都是对对象进行拷贝,其主要区别是,在对象拷贝 时,对引用数据类型的处理方式。
1.1 浅拷贝(Shallow Copy)
浅拷贝是指只复制对象本身和对象中的基本数据类型,对于引用类型的属性,只复制内存地址引用,不复制引用的对象本身。
浅拷贝的特点:
- 只拷贝第一层。
- 对于基本数据类型,拷贝值。
- 对于引用数据类型,拷贝引用,即新对象和原对象共享引用类型的属性。
1.2 深拷贝(Deep Copy)
深拷贝是指创建一个对象,这个对象的内容和原始对象完全相同,但它们是存储在不同的内存地址上的,这意味着,我们修改新对象,原始对象不受影响。
深拷贝的特点:
- 拷贝所有层级,对多层的属性进行循环递归拷贝。
- 对于基本数据类型,拷贝值。(和浅拷贝相同)
- 对于引用数据类型,创建一个新的引用对象,并循环拷贝到新对象中,也就是新对象和原对象分别引用独立的引用数据,不共享引用。
1.3 示例说明
举个例子:
js
const obj = {
a: 1,
b: [1, 2, 3],
c: {
d: 4,
e: 5,
}
}
const newObj1 = shallowClone(obj);
console.log('newObj1:', newObj1);
const newObj2 = deepClone(obj);
console.log('newObj2:', newObj2);
/**
打印结果:
newObj1: { a: 1, b: [ 1, 2, 3 ], c: { d: 4, e: 5 } }
newObj2: { a: 1, b: [ 1, 2, 3 ], c: { d: 4, e: 5 } }
*/
我这里并没有给出
shallowClone和deepClone的实现代码,后文会分别详细介绍。
从打印结果来看,newObj1 和 newObj2 都是相同的,而不同的是,对于浅拷贝来说:
obj.b === newObj1.b,结果为true。obj.c === newObj1.c,结果为true。
而对于深拷贝来说,它们前后都是不相等的。
obj.b === newObj.b,结果为false。obj.c === newObj.c,结果为false。
2、浅拷贝的实现方式
2.1 展开运算符
可以用 ECMAScript 2015 新增特性,也就是ES6提供的语法三个点(...)展开运算符,它可以展开可迭代对象(数组、字符串、Map、Set 等),从而来实现浅拷贝,具体用法如下:
js
const newObj = { ...obj };
2.2 Object.assign
Object.assign是 Object 类自带的一个静态方法 ,可以将一个或多个对象中的可枚举(此属性的enumerable为true)自有属性(对象自身的,从原型上继承的不算)合并到目标对象中。
js
const newObj = Object.assign({}, obj1, obj2, obj3, ...objN);
2.3 for...in + Object.prototype.hasOwnProperty
直接用for..in循环,配合 Object.prototype.hasOwnProperty 判断是否是自身的属性来进行拷贝。
js
function shallowClone(obj) {
const newObj = {};
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
newObj[key] = obj[key];
}
}
return newObj;
}
const obj = {
a: 1,
b: [1, 2, 3],
c: {
d: 4,
e: 5,
}
};
const newObj = shallowClone(obj);
console.log(newObj)
或者用Object.keys先拿到自身的属性的数组,然后forEach循环拷贝也可,方式有很多种,大家可以自行扩展。
2.4 扩展:展开运算符和 Object.assign 在实现浅拷贝上有什么区别?
展开运算符和 Object.assign 虽然可以都实现浅拷贝,但仍有细微的区别,比如在遇到 getter 和 setter 时,两者的表现不一样。来举个例子:
首先是展开运算符:
js
const obj1 = {
get a() {
console.log('getter')
return 1;
},
set a(val) {
console.log('setter')
}
}
const obj2 = {
a: 2,
}
const newObj1 = { ...obj1, ...obj2 };
console.log(newObj1);
console.log(newObj1.a)
console.log('------------------------');
const newObj2 = { ...obj2, ...obj1 };
console.log(newObj2);
console.log(newObj2.a)
/**
* 打印结果:
getter
{ a: 2 }
2
------------------------
getter
{ a: 1 }
1
*/
从打印结果可以看出,展开运算符在拷贝时有如下特点:
- 合并时不会执行
setter; - 合并后取值时会按照合并的先后顺序,后合并的值优先级更高。
然后是Object.assign:
js
const obj1 = {
get a() {
console.log('getter')
return 1;
},
set a(val) {
console.log('setter')
}
}
const obj2 = {
a: 2,
}
const newObj = Object.assign(obj1, obj2);
console.log(newObj);
console.log(newObj.a)
console.log('------------------------');
const newObj1 = Object.assign(obj2, obj1);
console.log(newObj1);
console.log(newObj1.a);
/**
* 打印结果:
setter
{ a: [Getter/Setter] }
getter
1
------------------------
getter
{ a: 1 }
1
*/
从打印结果可以看出,``Object.assign`在拷贝时有如下特点:
同名属性和同名getter、setter合并时,会执行setter,而同名getter、setter和同名属性合并时却不会执行setter。- 无论同名属性和同名
getter和setter的合并先后顺序如何,最终访问只会访问到getter里面的值,只是从控制台里看的效果不一样而已。
3、深拷贝的实现方式
3.1 JSON.parse(JSON.stringify(obj))
JSON.stringify:将一个对象序列化 成一个JSON字符串,包括嵌套的对象属性。JSON.parse:将一个JSON反序列化成为一个 JS 对象。
由于在内存中 JSON 字符串的地址都是独立的,和原始对象不是同一个地址,所以我们就能通过 JSON.parse 解析出一个新对象了。
下面看一下用这种方式实现深拷贝的优缺点:
优点:
- 简单易用 :语法
JSON.parse(JSON.stringify(obj)),用起来非常简单。 - 跨平台 :在不同平台和环境都能用,甚至是其它语言也有提供对应的实现,比如
Java。 - 兼容性好:各大浏览器都支持。
缺点:
- 无法处理特殊对象类型 ,比如函数、正则表达式、日期对象等
- 拷贝的时候会丢失函数和 undefined。
- 时间对象 Date 会变成字符串形式。
- RegExp、Error 对象会变成空对象。
- NaN、Infinity、-Infinity会变成 null。
- 等等...。
- 无法处理循环引用 ,比如在一个对象中,a引用了b,b引用了c,而c又引用了a,出现这种情况调用
JSON.parse(JSON.stringify(obj))会报错。
3.2 借助第三方库
实现深拷贝,一般我们会借助第三方库实现,比如 lodash,lodash提供了一个 cloneDeep 的方法实现深拷贝。
js
const _ = require('lodash');
const obj = { a: [{ b: 2 }] };
const res = _.cloneDeep(obj);
console.log(res);
// 输出:{ a: [ { b: 2 } ] }
3.3 手撸一个深拷贝方法
深拷贝其实实现起来要写完整,还是挺复杂的,要处理函数、数组、正则,甚至是symbol、buffer等,但对于面试来说,我们写个简单版本就行啦。
手动实现深拷贝有两个关键点:
- 对象是以
key和value键值对的方式存储的,所以要拷贝它们必须要用循环。 - 既然要
深拷贝,相较于只拷贝最外层的浅拷贝,就需要用递归或循环拷贝N层。
废话不多说,直接上完整代码。
js
const isObj = (target) => typeof target === 'object' && target !== null
function deepClone(obj, hash = new WeakMap()) {
if (!isObj(obj)) return obj
if (hash.has(obj)) return has.get(obj)
const target = new obj.constructor()
hash.set(obj, target)
Object.keys(obj).forEach((key) => {
target[key] = deepClone(obj[key], hash)
})
return target
}
const obj = { a: [{ b: 2 }] }
const res = deepClone(obj)
console.log(res)
// 输出:{ a: [ { b: 2 } ] }
我们用一个WeakMap来处理循环引用,然后通过拿到对象引用的constructor来复制对象,这样我们就省去了判断不同对象类型这一步,会简单很多,然后forEach循环递归复制就好啦。
虽然可能有人会说用 constructor 比较粗糙,但这是比较简洁的写法,我们面试的时候大可不必这么较真,只要知道它的核心思路和原理就行啦!
4、如何选择浅拷贝和深拷贝?
浅拷贝能共享数据,节约内存,性能高。
深拷贝实现数据隔离,数据更安全 。但要注意循环引用问题、特殊对象的处理以及对象层级过深带来的性能问题。
在平常开发中,数据拷贝几乎设计不到性能问题,所以如果不介意引用数据共享,选浅拷贝,需要引用数据相互独立,选深拷贝。
小结
-
先介绍了深拷贝深拷贝和浅拷贝的区别。它们的主要区别在于拷贝时对引用数据类型的处理,浅拷贝是共享引用,而深拷贝是复制出一个新对象,和原对象相互独立,没任何关系。
-
然后介绍了浅拷贝和深拷贝的主要实现方式 。浅拷贝的实现方式主要有
展开运算符、Object.assign、for..in + Object.prototype.hasOwnProperty等,还扩展介绍了展开运算符和Object.assign的区别,主要是体现在复制时对属性访问器getter和setter的处理方式不同,而深拷贝的实现方式主要有JSON.parse(JSON.stringify(obj))、借助第三方库,比如 lodash、手撸一个深拷贝方法(注意对循环引用的处理)。 -
最后介绍了在实际开发中如何选择浅拷贝和深拷贝。主要选择方式是,不介意引用数据共享,选浅拷贝,需要引用数据相互独立,选深拷贝。