深拷贝可以说是前端面试中非常高频的问题,也是一道基础题。所谓的基础不是说深拷贝本身是一个非常简单、非常基础的问题,而是面试官要通过深拷贝来考察候选人的JavaScript基础,甚至是程序设计能力。
为什么需要深拷贝?
第一个问题,也是最浅显的问题,为什么 JavaScript 中需要深拷贝?或者说如果不使用深拷贝复制对象会带来哪些问题?
我们知道在 JavaScript 中存在“引用类型“和“值类型“的概念。因为“引用类型“的特殊性,导致我们复制对象不能通过简单的clone = target
,所以需要把原对象的属性值一一赋给新对象。
而对象的属性其值也可能是另一个对象,所以我们需要递归。
如何获取原对象的属性?
通过for...in
能够遍历对象上的属性;也可以通过Object.keys(target)
获取到对象上的属性数组后再进行遍历。
这里选用for...in
因为相比Object.keys(target)
它还会遍历对象原型链上的属性。
ES6 Symbol 类型也可以作为对象的 key ,如何获取它们?
如何判断对象的类型?
可以使用typeof
判断目标是否为引用类型,这里有一处需要注意:typeof null
也是object
:
function deepClone(target) {
const targetType = typeof target;
if (targetType === 'object' || targetType === 'function') {
let clone = Array.isArray(target)?[]:{}
for (const key in target) {
clone[key] = deepClone(target[key])
}
return clone;
}
return target;
}
上述代码就完成了一个非常基础的深拷贝。但是对于引用类型的处理,它仍然是不完善的:
它没法处理Date或者正则这样的对象。为什么?
“回字的四样写法“--具体类型的识别
获取一个对象具体类型有哪些方式?
常用的方式有target.constructor.name
、Object.prototype.toString.call(target)
和instanceOf
。
instacneOf
可以用来判断对象类型,但是Date
的实例同时也是Object
的实例,此处用于判断是不准确的;target.constructor.name
得到的是构造器名称,而构造器是可以被修改的;Object.prototype.toString.call(target)
返回的是类名,而在ES5
中只有内置类型对象才有类名。
所以此处我们最合适的选择是Object.prototype.toString.call(target)
。
Object.prototype.toString.call(target)
也存在一些问题,你知道吗?
稍微改进一下代码,做一些简单的类型判断:
function deepClone(target) {
const targetType = typeof target;
if (targetType === 'object' || targetType === 'function') {
let clone = Array.isArray(target)?[]:{};
if(Object.prototype.toString.call(target) === '[object Date]'){
clone = new Date(target)
}
if(Object.prototype.toString.call(target) === '[object Object]'
||Object.prototype.toString.call(target) === '[object Array]'){
for (const key in target) {
clone[key] = deepClone(target[key])
}
}
return clone;
}
return target;
}
怎么能够更优雅的做类型判断?
你听说过“循环引用“吗?
假如目标对象的属性间接或直接的引用了自身,就会形成循环引用,导致在递归的时候爆栈。
所以我们的代码需要循环检测,设置一个Map
用于存储已拷贝过的对象,当检测到对象已存在于Map
中时,取出该值并返回即可避免爆栈。
function deepClone(target, map = new Map()) {
const targetType = typeof target;
if (targetType === 'object' || targetType === 'function') {
let clone = Array.isArray(target)?[]:{};
if (map.get(target)) {
return map.get(target);
}
map.set(target, clone);
if(Object.prototype.toString.call(target) === '[object Date]'){
clone = new Date(target)
}
if(Object.prototype.toString.call(target) === '[object Object]'
||Object.prototype.toString.call(target) === '[object Array]'){
for (const key in target) {
clone[key] = deepClone(target[key],map)
}
}
return clone;
}
return target;
}
好多教程使用 WeakMap 做存储,相比Map,WeakMap好在哪儿?
通往优秀的阶梯
以上我们就完成了一个基础的深拷贝。但是它仅仅是及格而已,想要做到优秀,还要处理一下之前留下的几个问题。
获取Symbol属性
ES6Symbol
类型也可以作为对象的 key ,但是for...in
和Object.keys(target)
都拿不到 Symbol
类型的属性名。
好在我们可以通过Object.getOwnPropertySymbols(target)
获取对象上所有的Symbol
属性,再结合for...in
、Object.keys()
就能够拿到全部的 key。不过这种方式有些麻烦,有没有更好用的方法?
有!Reflect.ownKeys(target)
正是这样一个集优雅与强大与一身的方法。但是正如同人无完人,这个方法也不完美:顾名思义,ownKeys
是拿不到原型链上的属性的。所以需要结合具体场景来组合使用上述方法。
特殊的内置类型
Date
、Error
等特殊的内置类型虽然是对象,但是并不能遍历属性,所以针对这些类型需要重新调用对应的构造器进行初始化。JavaScript 内置了许多类似的特殊类型,然而我们并不是无情的 API 机器,面试中能够回答上述要点也就足够了。
上述内置类型我们都可以通过Object.prototype.toString.call(target)
的方式拿到,所以这里可以封装一个类型判断的方法用于判断target
是否能够继续遍历,以便于及后续的处理。
然而 ES6 新增了Symbol.toStringTag
方法,可以用来自定义类名,这就导致 Object.prototype.toString.call(target)
拿到的类型名也可能不够准确:
class ValidatorClass {
get [Symbol.toStringTag]() {
return "Validator";
}
}
Object.prototype.toString.call(new ValidatorClass());
// "[object Validator]"
使用WeakMap做循环检测,比使用Map好在哪儿?
原生的WeakMap
持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。如果 target 非常庞大,那么使用Map
后如果没有进行手动释放,这块内存就会持续的被占用。而WeakMap
则不需要担心这个问题。
后记
如果上面几个问题都得到了妥善的处理,那么这样的深拷贝就可以说是一个足够打动面试官的深拷贝了。当然这个深拷贝还不够优秀,有很多待完善的地方,相信善于思考的你已经有了自己的思路。
但本文的重点并不单单是实现一个深拷贝,更多的是希望它能够帮助你更好的理解面试官的思路,从而更好的发挥自身的能力。
参考资料
关注「JS漫步指南」公众号,获取更多面试秘籍!