02 | 代码基本功测试(下):如何实现一个深浅拷贝?
上一讲我们介绍了 JS 的两种数据类型,分别是基础数据类型和引用数据类型,你可以回忆一下我提到的重点内容。那么这一讲要聊的浅拷贝和深拷贝,其实就是围绕着这两种数据类型展开的。
我把深浅拷贝单独作为一讲来专门讲解,是因为在 JavaScript 的编程中经常需要对数据进行复制,什么时候用深拷贝、什么时候用浅拷贝,是开发过程中需要思考的;同时深浅拷贝也是前端面试中比较高频的题目。
但是我在面试候选人的过程中,发现有很多同学都没有搞懂深拷贝和浅拷贝的区别和定义。最近我也在一些关于 JavaScript 的技术文章中发现,里面很多关于深浅拷贝的代码写得比较简陋,从面试官的角度来讲,简陋的答案是不太能让人满意的。
因此,深入学习这部分知识有助于提高你手写 JS 的能力,以及对一些边界特殊情况的深入思考能力,这一讲我会结合最基础但是又容易写不好的的题目来帮助你提升。
在开始之前,我先抛出来两个问题,你可以思考一下。
拷贝一个很多嵌套的对象怎么实现?
在面试官眼中,写成什么样的深拷贝代码才能算合格?
带着这两个问题,我们先来看下浅拷贝的相关内容。
浅拷贝的原理和实现
对于浅拷贝的定义我们可以初步理解为:
自己创建一个新的对象,来接受你要重新复制或引用的对象值。如果对象属性是基本的数据类型,复制的就是基本类型的值给新对象;但如果属性是引用数据类型,复制的就是内存中的地址,如果其中一个对象改变了这个内存中的地址,肯定会影响到另一个对象。
下面我总结了一些 JavaScript 提供的浅拷贝方法,一起来看看哪些方法能实现上述定义所描述的过程。
方法一:object.assign
object.assign 是 ES6 中 object 的一个方法,该方法可以用于 JS 对象的合并等多个用途,其中一个用途就是可以进行浅拷贝。该方法的第一个参数是拷贝的目标对象,后面的参数是拷贝的来源对象(也可以是多个来源)。
object.assign 的语法为:Object.assign(target, …sources)
object.assign 的示例代码如下:
复制代码
let target = {};
let source = { a: { b: 1 } };
Object.assign(target, source);
console.log(target); // { a: { b: 1 } };
从上面的代码中可以看到,通过 object.assign 我们的确简单实现了一个浅拷贝,“target”就是我们新拷贝的对象,下面再看一个和上面不太一样的例子。
复制代码
let target = {};
let source = { a: { b: 2 } };
Object.assign(target, source);
console.log(target); // { a: { b: 10 } };
source.a.b = 10;
console.log(source); // { a: { b: 10 } };
console.log(target); // { a: { b: 10 } };
从上面代码中我们可以看到,首先通过 Object.assign 将 source 拷贝到 target 对象中,然后我们尝试将 source 对象中的 b 属性由 2 修改为 10。通过控制台可以发现,打印结果中,三个 target 里的 b 属性都变为 10 了,证明 Object.assign 暂时实现了我们想要的拷贝效果。
但是使用 object.assign 方法有几点需要注意:
它不会拷贝对象的继承属性;
它不会拷贝对象的不可枚举的属性;
可以拷贝 Symbol 类型的属性。
可以简单理解为:Object.assign 循环遍历原对象的属性,通过复制的方式将其赋值给目标对象的相应属性,来看一下这段代码,以验证它可以拷贝 Symbol 类型的对象。
复制代码
let obj1 = { a:{ b:1 }, sym:Symbol(1)};
Object.defineProperty(obj1, 'innumerable' ,{
value:'不可枚举属性',
enumerable:false
});
let obj2 = {};
Object.assign(obj2,obj1)
obj1.a.b = 2;
console.log('obj1',obj1);
console.log('obj2',obj2);
我们来看一下控制台打印的结果,如下图所示。
从上面的样例代码中可以看到,利用 object.assign 也可以拷贝 Symbol 类型的对象,但是如果到了对象的第二层属性 obj1.a.b 这里的时候,前者值的改变也会影响后者的第二层属性的值,说明其中依旧存在着访问共同堆内存的问题,也就是说这种方法还不能进一步复制,而只是完成了浅拷贝的功能。
方法二:扩展运算符方式
我们也可以利用 JS 的扩展运算符,在构造对象的同时完成浅拷贝的功能。
扩展运算符的语法为:let cloneObj = { …obj };
代码如下所示。
复制代码
/* 对象的拷贝 */
let obj = {a:1,b:{c:1}}
let obj2 = {...obj}
obj.a = 2
console.log(obj) //{a:2,b:{c:1}} console.log(obj2); //{a:1,b:{c:1}}
obj.b.c = 2
console.log(obj) //{a:2,b:{c:2}} console.log(obj2); //{a:1,b:{c:2}}
/* 数组的拷贝 */
let arr = [1, 2, 3];
let newArr = [...arr]; //跟arr.slice()是一样的效果
扩展运算符 和 object.assign 有同样的缺陷,也就是实现的浅拷贝的功能差不多,但是如果属性都是基本类型的值,使用扩展运算符进行浅拷贝会更加方便。
方法三:concat 拷贝数组
数组的 concat 方法其实也是浅拷贝,所以连接一个含有引用类型的数组时,需要注意修改原数组中的元素的属性,因为它会影响拷贝之后连接的数组。不过 concat 只能用于数组的浅拷贝,使用场景比较局限。代码如下所示。
复制代码
let arr = [1, 2, 3];
let newArr = arr.concat();
newArr[1] = 100;
console.log(arr); // [ 1, 2, 3 ]
console.log(newArr); // [ 1, 100, 3 ]
方法四:slice 拷贝数组
slice 方法也比较有局限性,因为它仅仅针对数组类型。slice 方法会返回一个新的数组对象,这一对象由该方法的前两个参数来决定原数组截取的开始和结束时间,是不会影响和改变原始数组的。
slice 的语法为:arr.slice(begin, end);
我们来看一下 slice 怎么使用,代码如下所示。
复制代码
let arr = [1, 2, {val: 4}];
let newArr = arr.slice();
newArr[2].val = 1000;
console.log(arr); //[ 1, 2, { val: 1000 } ]
从上面的代码中可以看出,这就是浅拷贝的限制所在了——它只能拷贝一层对象。如果存在对象的嵌套,那么浅拷贝将无能为力。因此深拷贝就是为了解决这个问题而生的,它能解决多层对象嵌套问题,彻底实现拷贝。这一讲的后面我会介绍深拷贝相关的内容。
手工实现一个浅拷贝
根据以上对浅拷贝的理解,如果让你自己实现一个浅拷贝,大致的思路分为两点:
对基础类型做一个最基本的一个拷贝;
对引用类型开辟一个新的存储,并且拷贝一层对象属性。
那么,围绕着这两个思路,请你跟着我的操作,自己来实现一个浅拷贝吧,代码如下所示。
复制代码
const shallowClone = (target) => {
if (typeof target === 'object' && target !== null) {
const cloneTarget = Array.isArray(target) ? []: {};
for (let prop in target) {
if (target.hasOwnProperty(prop)) {
cloneTarget[prop] = target[prop];
}
}
return cloneTarget;
} else {
return target;
}
}
从上面这段代码可以看出,利用类型判断,针对引用类型的对象进行 for 循环遍历对象属性赋值给目标对象的属性,基本就可以手工实现一个浅拷贝的代码了。
那么了解了实现浅拷贝代码的思路,接下来我们再看看深拷贝是怎么实现的。
深拷贝的原理和实现
浅拷贝只是创建了一个新的对象,复制了原有对象的基本类型的值,而引用数据类型只拷贝了一层属性,再深层的还是无法进行拷贝。深拷贝则不同,对于复杂引用数据类型,其在堆内存中完全开辟了一块内存地址,并将原有的对象完全复制过来存放。
这两个对象是相互独立、不受影响的,彻底实现了内存上的分离。总的来说,深拷贝的原理可以总结如下:
将一个对象从内存中完整地拷贝出来一份给目标对象,并从堆内存中开辟一个全新的空间存放新对象,且新对象的修改并不会改变原对象,二者实现真正的分离。
现在原理你知道了,那么怎么去实现深拷贝呢?我也总结了几种方法分享给你。
方法一:乞丐版(JSON.stringify)
JSON.stringify() 是目前开发过程中最简单的深拷贝方法,其实就是把一个对象序列化成为 JSON 的字符串,并将对象里面的内容转换成字符串,最后再用 JSON.parse() 的方法将JSON 字符串生成一个新的对象。示例代码如下所示。
复制代码
let obj1 = { a:1, b:[1,2,3] }
let str = JSON.stringify(obj1);
let obj2 = JSON.parse(str);
console.log(obj2); //{a:1,b:[1,2,3]}
obj1.a = 2;
obj1.b.push(4);
console.log(obj1); //{a:2,b:[1,2,3,4]}
console.log(obj2); //{a:1,b:[1,2,3]}
从上面的代码可以看到,通过 JSON.stringify 可以初步实现一个对象的深拷贝,通过改变 obj1 的 b 属性,其实可以看出 obj2 这个对象也不受影响。
但是使用 JSON.stringify 实现深拷贝还是有一些地方值得注意,我总结下来主要有这几点:
拷贝的对象的值中如果有函数、undefined、symbol 这几种类型,经过 JSON.stringify 序列化之后的字符串中这个键值对会消失;
拷贝 Date 引用类型会变成字符串;
无法拷贝不可枚举的属性;
无法拷贝对象的原型链;
拷贝 RegExp 引用类型会变成空对象;
对象中含有 NaN、Infinity 以及 -Infinity,JSON 序列化的结果会变成 null;
无法拷贝对象的循环应用,即对象成环 (obj[key] = obj)。
针对这些存在的问题,你可以尝试着用下面的这段代码亲自执行一遍,来看看如此复杂的对象,如果用 JSON.stringify 实现深拷贝会出现什么情况。
复制代码
function Obj() {
this.func = function () { alert(1) };
this.obj = {a:1};
this.arr = [1,2,3];
this.und = undefined;
this.reg = /123/;
this.date = new Date(0);
this.NaN = NaN;
this.infinity = Infinity;
this.sym = Symbol(1);
}
let obj1 = new Obj();
Object.defineProperty(obj1,'innumerable',{
enumerable:false,
value:'innumerable'
});
console.log('obj1',obj1);
let str = JSON.stringify(obj1);
let obj2 = JSON.parse(str);
console.log('obj2',obj2);
通过上面这段代码可以看到执行结果如下图所示。
使用 JSON.stringify 方法实现深拷贝对象,虽然到目前为止还有很多无法实现的功能,但是这种方法足以满足日常的开发需求,并且是最简单和快捷的。而对于其他的也要实现深拷贝的,比较麻烦的属性对应的数据类型,JSON.stringify 暂时还是无法满足的,那么就需要下面的几种方法了。
方法二:基础版(手写递归实现)
下面是一个实现 deepClone 函数封装的例子,通过 for in 遍历传入参数的属性值,如果值是引用类型则再次递归调用该函数,如果是基础数据类型就直接复制,代码如下所示。
复制代码
let obj1 = {
a:{
b:1
}
}
function deepClone(obj) {
let cloneObj = {}
for(let key in obj) { //遍历
if(typeof obj[key] ==='object') {
cloneObj[key] = deepClone(obj[key]) //是对象就再次调用该函数递归
} else {
cloneObj[key] = obj[key] //基本类型的话直接复制值
}
}
return cloneObj
}
let obj2 = deepClone(obj1);
obj1.a.b = 2;
console.log(obj2); // {a:{b:1}}
虽然利用递归能实现一个深拷贝,但是同上面的 JSON.stringify 一样,还是有一些问题没有完全解决,例如:
这个深拷贝函数并不能复制不可枚举的属性以及 Symbol 类型;
这种方法只是针对普通的引用类型的值做递归复制,而对于 Array、Date、RegExp、Error、Function 这样的引用类型并不能正确地拷贝;
对象的属性里面成环,即循环引用没有解决。
这种基础版本的写法也比较简单,可以应对大部分的应用情况。但是你在面试的过程中,如果只能写出这样的一个有缺陷的深拷贝方法,有可能不会通过。
所以为了“拯救”这些缺陷,下面我带你一起看看改进的版本,以便于你可以在面试种呈现出更好的深拷贝方法,赢得面试官的青睐。
方法三:改进版(改进后递归实现)
针对上面几个待解决问题,我先通过四点相关的理论告诉你分别应该怎么做。
针对能够遍历对象的不可枚举属性以及 Symbol 类型,我们可以使用 Reflect.ownKeys 方法;
当参数为 Date、RegExp 类型,则直接生成一个新的实例返回;
利用 Object 的 getOwnPropertyDescriptors 方法可以获得对象的所有属性,以及对应的特性,顺便结合 Object 的 create 方法创建一个新对象,并继承传入原对象的原型链;
利用 WeakMap 类型作为 Hash 表,因为 WeakMap 是弱引用类型,可以有效防止内存泄漏(你可以关注一下 Map 和 weakMap 的关键区别,这里要用 weakMap),作为检测循环引用很有帮助,如果存在循环,则引用直接返回 WeakMap 存储的值。
关于第 4 点的 WeakMap,这里我不进行过多的科普讲解了,你如果不清楚可以自己再通过相关资料了解一下。我也经常在给人面试中看到有人使用 WeakMap 来解决循环引用问题,但是很多解释都是不够清晰的。
当你不太了解 WeakMap 的真正作用时,我建议你不要在面试中写出这样的代码,如果只是死记硬背,会给自己挖坑的。因为你写的每一行代码都是需要经过深思熟虑并且非常清晰明白的,这样你才能经得住面试官的推敲。
当然,如果你在考虑到循环引用的问题之后,还能用 WeakMap 来很好地解决,并且向面试官解释这样做的目的,那么你所展示的代码,以及你对问题思考的全面性,在面试官眼中应该算是合格的了。
那么针对上面这几个问题,我们来看下改进后的递归实现的深拷贝代码应该是什么样子的,如下所示。
复制代码
const isComplexDataType = obj => (typeof obj === 'object' || typeof obj === 'function') && (obj !== null)
const deepClone = function (obj, hash = new WeakMap()) {
if (obj.constructor === Date)
return new Date(obj) // 日期对象直接返回一个新的日期对象
if (obj.constructor === RegExp)
return new RegExp(obj) //正则对象直接返回一个新的正则对象
//如果循环引用了就用 weakMap 来解决
if (hash.has(obj)) return hash.get(obj)
let allDesc = Object.getOwnPropertyDescriptors(obj)
//遍历传入参数所有键的特性
let cloneObj = Object.create(Object.getPrototypeOf(obj), allDesc)
//继承原型链
hash.set(obj, cloneObj)
for (let key of Reflect.ownKeys(obj)) {
cloneObj[key] = (isComplexDataType(obj[key]) && typeof obj[key] !== 'function') ? deepClone(obj[key], hash) : obj[key]
}
return cloneObj
}
// 下面是验证代码
let obj = {
num: 0,
str: '',
boolean: true,
unf: undefined,
nul: null,
obj: { name: '我是一个对象', id: 1 },
arr: [0, 1, 2],
func: function () { console.log('我是一个函数') },
date: new Date(0),
reg: new RegExp('/我是一个正则/ig'),
[Symbol('1')]: 1,
};
Object.defineProperty(obj, 'innumerable', {
enumerable: false, value: '不可枚举属性' }
);
obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj))
obj.loop = obj // 设置loop成循环引用的属性
let cloneObj = deepClone(obj)
cloneObj.arr.push(4)
console.log('obj', obj)
console.log('cloneObj', cloneObj)
我们看一下结果,cloneObj 在 obj 的基础上进行了一次深拷贝,cloneObj 里的 arr 数组进行了修改,并未影响到 obj.arr 的变化,如下图所示。
从这张截图的结果可以看出,改进版的 deepClone 函数已经对基础版的那几个问题进行了改进,也验证了我上面提到的那四点理论。
那么到这里,深拷贝的相关内容就介绍得差不多了。
总结
这一讲,我们探讨了如何实现一个深浅拷贝。在日常的开发中,由于开发者可以使用一些现成的库来实现深拷贝,所以很多人对如何实现深拷贝的细节问题并不清楚。但是如果仔细研究你就会发现,这部分内容对于你深入了解 JS 底层的原理有很大帮助。如果未来你需要自己实现一个前端相关的工具或者库,对 JS 理解的深度会决定你能把这个东西做得有多好。
其实到最后我们可以看到,自己完整实现一个深拷贝,还是考察了不少的知识点和编程能力,总结下来大致分为这几点,请看下图。
可以看到通过这一个问题能考察的能力有很多,因此千万不要用最低的标准来要求自己,应该用类似的方法去分析每个问题深入考察的究竟是什么,这样才能更好地去全面提升自己的基本功。
关于深浅拷贝如果你有不清楚的地方,欢迎在评论区留言,最好的建议还是要多动手,不清楚的地方自己敲一遍代码,这样才能加深印象,然后更容易地去消化这部分内容。
下一讲,我们将迎来继承方式的学习,这部分知识也是非常重要的,你需要熟练掌握并理解其原理。也欢迎你提前预习相关知识,这样才能在不同的角度有所收获。下一讲再见。