# 导读
最近经常看到很多JavaScript手写代码的文章总结,里面提供了很多JavaScript Api的手写实现。
里面的题目实现大多类似,而且说实话很多代码在我看来是非常简陋的,如果我作为面试官,看到这样的代码,在我心里是不会合格的,本篇文章我拿最简单的深拷贝来讲一讲。
看本文之前先问自己三个问题:
-
你真的理解什么是深拷贝吗?
-
在面试官眼里,什么样的深拷贝才算合格?
-
什么样的深拷贝能让面试官感到惊艳?
本文由浅入深,带你一步一步实现一个惊艳面试官的深拷贝。
本文测试代码:https://github.com/ConardLi/ConardLi.github.io/tree/master/demo/deepClone
例如:代码clone到本地后,执行 node clone1.test.js查看测试结果。
建议结合测试代码一起阅读效果更佳。
# 深拷贝和浅拷贝的定义
深拷贝已经是一个老生常谈的话题了,也是现在前端面试的高频题目,但是令我吃惊的是有很多同学还没有搞懂深拷贝和浅拷贝的区别和定义。例如前几天给我提issue的同学:

很明显这位同学把拷贝和赋值搞混了,如果你还对赋值、对象在内存中的存储、变量和类型等等有什么疑问,可以看看我这篇文章:https://juejin.im/post/5cec1bcff265da1b8f1aa08f 。
你只要少搞明白拷贝和赋值的区别。
我们来明确一下深拷贝和浅拷贝的定义:
浅拷贝:

创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。
深拷贝:

将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象
话不多说,浅拷贝就不再多说,下面我们直入正题:
# 乞丐版
在不使用第三方库的情况下,我们想要深拷贝一个对象,用的最多的就是下面这个方法。
JSON.parse(JSON.stringify());
这种写法非常简单,而且可以应对大部分的应用场景,但是它还是有很大缺陷的,比如拷贝其他引用类型、拷贝函数、循环引用等情况。
显然,面试时你只说出这样的方法是一定不会合格的。
接下来,我们一起来手动实现一个深拷贝方法。
# 基础版本
如果是浅拷贝的话,我们可以很容易写出下面的代码:
function clone(target) {
let cloneTarget = {};
for (const key in target) {
cloneTarget[key] = target[key];
}
return cloneTarget;
};
创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性依次添加到新对象上,返回。
如果是深拷贝的话,考虑到我们要拷贝的对象是不知道有多少层深度的,我们可以用递归来解决问题,稍微改写上面的代码:
- 如果是原始类型,无需继续拷贝,直接返回
- 如果是引用类型,创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性执行深拷贝后依次添加到新对象上。
很容易理解,如果有更深层次的对象可以继续递归直到属性为原始类型,这样我们就完成了一个最简单的深拷贝:
function clone(target) {
if (typeof target === 'object') {
let cloneTarget = {};
for (const key in target) {
cloneTarget[key] = clone(target[key]);
}
return cloneTarget;
} else {
return target;
}
};
我们可以打开测试代码中的clone1.test.js对下面的测试用例进行测试:
const target = {
field1: 1,
field2: undefined,
field3: 'ConardLi',
field4: {
child: 'child',
child2: {
child2: 'child2'
}
}
};
执行结果:

这是一个最基础版本的深拷贝,这段代码可以让你向面试官展示你可以用递归解决问题,但是显然,他还有非常多的缺陷,比如,还没有考虑数组。
# 考虑数组
在上面的版本中,我们的初始化结果只考虑了普通的object,下面我们只需要把初始化代码稍微一变,就可以兼容数组了:
module.exports = function clone(target) {
if (typeof target === 'object') {
let cloneTarget = Array.isArray(target) ? [] : {};
for (const key in target) {
cloneTarget[key] = clone(target[key]);
}
return cloneTarget;
} else {
return target;
}
};
在clone2.test.js中执行下面的测试用例:
const target = {
field1: 1,
field2: undefined,
field3: {
child: 'child'
},
field4: [2, 4, 8]
};
执行结果:

OK,没有问题,你的代码又向合格迈进了一小步。
# 循环引用
我们执行下面这样一个测试用例:
const target = {
field1: 1,
field2: undefined,
field3: {
child: 'child'
},
field4: [2, 4, 8]
};
target.target = target;
可以看到下面的结果:

很明显,因为递归进入死循环导致栈内存溢出了。
原因就是上面的对象存在循环引用的情况,即对象的属性间接或直接的引用了自身的情况:

解决循环引用问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题。
这个存储空间,需要可以存储key-value形式的数据,且key可以是一个引用类型,我们可以选择Map这种数据结构:
- 检查
map中有无克隆过的对象 - 有 - 直接返回
- 没有 - 将当前对象作为
key,克隆对象作为value进行存储 - 继续克隆
function clone(target, map = new Map()) {
if (typeof target === 'object') {
let cloneTarget = Array.isArray(target) ? [] : {};
if (map.get(target)) {
return map.get(target);
}
map.set(target, cloneTarget);
for (const key in target) {
cloneTarget[key] = clone(target[key], map);
}
return cloneTarget;
} else {
return target;
}
};
再来执行上面的测试用例:

可以看到,执行没有报错,且target属性,变为了一个Circular类型,即循环应用的意思。
接下来,我们可以使用,WeakMap提代Map来使代码达到画龙点睛的作用。
function clone(target, map = new WeakMap()) {
// ...
};

为什么要这样做呢?,先来看看WeakMap的作用:
WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。
什么是弱引用呢?
在计算机程序设计中,弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。 一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收。
我们默认创建一个对象:const obj = {},就默认创建了一个强引用的对象,我们只有手动将obj = null,它才会被垃圾回收机制进行回收,如果是弱引用对象,垃圾回收机制会自动帮我们回收。
举个例子:
如果我们使用Map的话,那么对象间是存在强引用关系的:
let obj = { name : 'ConardLi'}
const target = new Map();
target.set(obj,'code秘密花园');
obj = null;
虽然我们手动将obj,进行释放,然是target依然对obj存在强引用关系,所以这部分内存依然无法被释放。
再来看WeakMap:
let obj = { name : 'ConardLi'}
const target = new WeakMap();
target.set(obj,'code秘密花园');
obj = null;
如果是WeakMap的话,target和obj

