JS 从零手写一个深拷贝(进阶篇)
壹 ❀ 引
在深拷贝与浅拷贝的区别,实现深拷贝的几种方法一文中,我们阐述了深浅拷贝的概念与区别,普及了部分具有迷惑性的浅拷贝api
。当然,我们也实现了乞丐版的深拷贝方法,能解决部分拷贝场景,虽然它仍有很多缺陷。那么这一篇文章我们将从零手写一个强大的深拷贝方法,在方法逐渐升级的过程中,我们也能亲身感受深拷贝中需要考虑的边界问题,那么本文开始。
贰 ❀ 从零手写深拷贝
贰 ❀ 壹 从基础对象复制开始
在上文中,我们已经得知深拷贝主要还是考虑引用数据类型,假定现在有一个deepClone
方法,假设我们传递的拷贝参数是基础类型,我们完全可以原封不动返回即可。而引用数据类型类型繁多,可以猜到这个方法要做的事应该集中在类型判断,以及针对不同对象类型如何去复制。
先不说深拷贝,我们由浅至深,如何复制一个{}
呢?你可能毫不犹豫能写出如下代码:
const deepClone = (obj) => {
// 创建一个新对象
const obj_ = {};
for (let i in obj) {
// 按key依次拷贝
obj_[i] = obj[i];
};
return obj_;
};
我们可以使用for...in
遍历对象的所有key
,从而达到复制所有属性的目的。但这个实现只能满足复制value
是基本数据类型的例子:
const obj = {
name: '听风',
age: 29
}
一旦属性的value
有引用数据类型,上述方法就只能达到浅拷贝的作用了,比如:
const obj = {
name: '听风',
age: 29,
other: {
gender: 'male'
}
};
const o = deepClone(obj);
// 修改原对象引用数据类型的值
obj.other.gender = null;
// 可以看到简单的复制没办法解决深拷贝的问题
console.log(obj, o)
贰 ❀ 贰 增加对象判断与递归深度复制
前文我们已经说过了,假设拷贝传递的是基本类型的值,我们只需原封不动返回即可;其次,考虑到对象的某个值可能还是对象,比如上面的other
,我们继续遍历{gender: 'male'}
复制,这样依次把gender
属性拿过来,就不可能有引用的问题了,因此这里我们将方法升级:
const deepClone = (obj) => {
// 是对象吗?是就新建对象开始复制
if (typeof obj === 'object') {
const obj_ = {};
for (let i in obj) {
// 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
obj_[i] = deepClone(obj[i]);
};
return obj_;
// 不是对象?直接返回
} else {
return obj;
};
};
有同学第一眼看上去可能就比较奇怪,为什么obj_[i] = deepClone(obj[i]);
这一句不继续加一个是否是对象的判断?不是对象没必要递归啊。其实递归的诀窍就是,想好当前要做什么以及什么时候跳出递归,之后递归会重复帮你做好你预期的操作。
比如当遇到name: '听风'
这个属性后,再次调用deepClone
后因为typeof
的判断不是对象,会直接原封不动返回,所以并不会有什么大的性能浪费。
贰 ❀ 叁 兼容数组类型
除了{}
类型,数组也是我们打交道非常多的引用类型,很明显上述代码并不满足数组的拷贝,我们需要对obj
的类型进一步细化,如下:
const deepClone = (obj) => {
// 是对象吗?是就新建对象开始复制
if (typeof obj === 'object') {
// 是对象,我们进一步确定是数组还是{}
const obj_ = Array.isArray(obj) ? [] : {};
for (let i in obj) {
// 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
obj_[i] = deepClone(obj[i]);
};
return obj_;
// 不是对象?直接返回
} else {
return obj;
};
};
现在我们再来测试对象值是对象以及数组的情况,可以看到此时已经满足了预期。
const obj = {
name: '听风',
age: 29,
other: {
gender: 'male',
arr: [1, 2, 3]
}
};
const o = deepClone(obj);
obj.other.gender = null;
obj.other.arr[0] = 1;
console.log(obj, o)
贰 ❀ 肆 解决typeof类型判断误差
上述的实现我们都依赖了typeof
来判断参数是不是一个对象,如果不是对象哪来的for...in
呢?但typeof
一直有一个javascript
遗留bug,那就是typeof null
的类型是object
,所以如果参数传递null
,我们的深拷贝方法会返回一个{}
而不是null
,这里得优化下。
这里我查阅了typeof MDN,具体类型如下:
类型 | 结果 |
---|---|
Undefined | "undefined" |
Null | "Object" |
Boolean | "boolean" |
Number | "Number" |
String | "String" |
Symbol | "Symbol" |
Bigint | "bigint" |
Function | "Function" |
其它任何对象 | "object" |
我们总结下,只要参数不是null
,且类型结果不是object
和function
,那说明这个参数一定不是对象类型,我们来定义一个更精准的对象类型判断方法isObject
,同时优化之前的代码:
const isObject = (obj) => {
const type = typeof obj;
return obj !== null && (type === 'object' || type === 'function');
};
const deepClone = (obj) => {
// 如果不是对象直接返回
if (!isObject(obj)) {
return obj;
};
// 是对象,我们进一步确定是数组还是{}
const obj_ = Array.isArray(obj) ? [] : {};
for (let i in obj) {
// 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
obj_[i] = deepClone(obj[i]);
};
return obj_;
};
贰 ❀ 伍 解决对象循环引用
虽然不常见,但对象其实可以将自己设置成自己的属性,比如:
const obj = {
name: '听风',
age: 29,
other: {
gender: 'male',
arr: [1, 2, 3]
}
};
obj.obj = obj;
我们现在来使用自己定义的深拷贝方法拷贝上述对象,你会发现直接爆栈:
为什么?我们来打印一下obj
的结构就清楚了:
当key
遇到obj
时,因为是对象类型,继续递归,结果发现这个key
可以无限递归下去,直接爆栈。
那怎么解决呢?我们可以想想被拷贝的原对象是怎么诞生的,将对象的key
指向自己即可。也就是在拷贝时,我们只要保证执行一次obj['obj'] = obj
即可,只要让自己指向自己,这个循环引用自然就会诞生,并不需要我们无限递归来模拟这个循环引用。
怎么跳出这个递归呢?设想下,obj
在第一次传入后,开始第一次递归,然后把自己又作为参数传了下去,后续做的事情完全是相同的,那我们是不是可以记录我们要拷贝的obj
以及它拷贝的后的结果,当下次遇到相同的obj
跳出递归,直接返回之前的结果就好了。
考虑到我们要记录的参数可能是对象类型,使用普通的对象肯定不行,而es6
新增的Map
数据类型key
就可以使用对象:
const deepClone = (obj, map = new Map()) => {
// 如果不是对象直接返回
if (!isObject(obj)) {
return obj;
};
// 之前有拷贝过吗?
if (map.has(obj)) {
return map.get(obj);
};
// 是对象,我们进一步确定是数组还是{}
const obj_ = Array.isArray(obj) ? [] : {};
// 存储当前拷贝的对象,以及我们要返回的对象
map.set(obj, obj_);
for (let i in obj) {
// 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
obj_[i] = deepClone(obj[i], map);
};
return obj_;
};
此时再拷贝上述的循环引用的对象,你会发现爆栈的问题已经得到解决,我们成功拷贝了一个循环引用的问题。
贰 ❀ 陆 兼容其它可遍历引用数据类型
虽然上文我们做了isObject
的判断,但事实上我们也只做了{}
与[]
两种数据的拷贝,像正则,日期以及new Number(1)
这种对象其实都未兼顾。
说直白点就是,isObject
只是告诉了我们这个参数是不是对象,是对象后我们得进一步细化,看看它到底是什么类型,毕竟有些对象根本不可遍历,那我们现在的代码就无法拷贝这类对象。
我们可以通过如下代码来精准获取当前参数的对象类型:
const getObjectType = (obj) => {
return Object.prototype.toString.call(obj);
}
举个例子,比如当传入一个函数或者一个正则就能精准得到它的对象类型:
Object.prototype.toString.call(function (){});// '[object Function]'
Object.prototype.toString.call(/1/);// '[object RegExp]'
我们可以列举常见的部分对象类型,将其定义成常量便于后续使用:
// 可遍历类型
const arrType = '[object Array]';
const objType = '[object Object]';
const mapType = '[object Map]';
const setType = '[object Set]';
const argType = '[object Arguments]';
// 不可遍历
const boolType = '[object Boolean]';
const numType = '[object Number]';
const strType = '[object String]';
const dateType = '[object Date]';
const errType = '[object Error]';
const regexpType = '[object Regexp]';
const symbolType = '[object Symbol]';
const funType = '[object Function]';
// 将可遍历类型做个集合
const traverseTypes = [arrType, objType, mapType, setType, argType];
其实初略做个分类,大家虽然都是对象,但并不是所有对象都可以遍历,比如日期,数字对象,这种我们都不太好直接遍历。
而数组,普通对象{}
,arguments
以及新增的Map Set
虽然都可以遍历,但像Map
添加属性通过add
方法,并不是传统的key-value
赋值形式,所以并不能通过for...in
一招通吃。
有同学这里可能就已经有疑问了,不是数字,字符串直接返回吗?怎么对象还考虑这些呢?这是因为我们创建数字习惯使用对象字面量的创建方式,比如:
const s = '听风';
但我们依然可以通过构造器String
来创建一个字符串对象:
const s = new String('听风');
OK,让我们回到上文已实现的深拷贝,目前我们根据isArray
来判断是否是一个数组,从而初始化obj_
是一个[]
或者{}
,很显然这种做法没办法满足需求,当时一个对象时,我们希望直接创建一个同类型的空对象,然后再往这个空对象上复制属性。
怎么做呢?其实这里我们可以借用传递参数的constructor
属性,访问到该参数的原始构造函数,举个例子:
const num = 1;
const arr = [];
const bool = true;
num.__proto__.constructor === Number;// true
arr.__proto__.constructor === Array;// true
bool.__proto__.constructor === Boolean;// true
因此只要当前可以认定这是一个值得深拷贝的对象,我们直接通过.constructor
访问到构造器,然后执行new
操作即可。
若对于这一步有疑惑,说明你对于javascript
中的原型掌握不是很扎实,这里可以阅读博主JS 疫情宅在家,学习不能停,七千字长文助你彻底弄懂原型与原型链一文,这里就不多赘述了。
让我们改写深拷贝方法,让它能根据任意对象类型创建对应空对象:
const deepClone = (obj, map = new Map()) => {
// 如果不是对象直接返回
if (!isObject(obj)) {
return obj;
};
// 获取当前参数的对象类型
const objType = getObjectType(obj);
// 根据constructor找到原始构造器,创建初始化对象
let obj_ = new obj.constructor();
// 解决循环引用问题
if (map.has(obj)) {
return map.get(obj);
};
// 存储当前拷贝的对象,以及我们要返回的对象
map.set(obj, obj_);
// 拷贝Set
if (objType === setType) {
obj.forEach((val, key) => {
obj_.add(deepClone(val, map));
});
return obj_;
};
// 拷贝Map
if (objType === mapType) {
obj.forEach((val, key) => {
obj_.set(key, deepClone(val, map));
});
return obj_;
};
// 如果是数组或者{}
for (let i in obj) {
// 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
obj_[i] = deepClone(obj[i], map);
};
return obj_;
};
const obj = {
name: '听风',
arr: [1, 2, 3],
set: new Set([1, 2, 3]),
map: new Map([
['age', 29]
])
};
const o = deepClone(obj);
obj.name = '1';
obj.set.add(4);
obj.map.set('sex', 'male');
console.log(obj, o);
上述代码我们兼容了五种可遍历的对象类型,Set Map
需要特有的复制的方式,除此之外的对象,数组以及arguments
均可通过for...in
复制,运行了例子发现非常顺利。
题外话,我在查阅深拷贝资料时,发现有不少评论说使用Object.create(obj.constructor.prototype)
来取代new obj.constructor()
的做法,因为前者是直接使用你要复制对象的原型来创建空对象,这要比后者再次调用构造器性能要好,这个说法是有问题的,我们来看个例子:
const arr1 = Object.create([].constructor.prototype);
const arr2 = new [].constructor();
arr1[0] = 1;;
arr2[0] = 1;
console.log(arr1.length, arr2.length); // 0 1
可以看到,通过以数组原型创建的空数组,它自身居然没有带length
属性,假设我们以此拷贝出了一个数组,你会发现虽然它有元素,但因为缺少自己的length
从而无法成功遍历。
贰 ❀ 柒 兼容不可遍历类型
OK,让我们继续分析剩余不可遍历或者说不便于遍历的对象类型,像布尔值,数字,字符串以及日期这类对象,我们要拷贝比较简单,我们可以通过valueOf
访问到对象的原始值,举个例子:
const num = new Number(1);
console.log(num.vauleOf());// 1
但需要注意的是Symbol
这个类型它不能使用new
调用,且Symbole
本身就是基础数据类型,一般情况下我们让它跟普通的数字一样,传入原封不动返回,但我们需要额外考虑包装对象形式的Symbol
,比如:
const s = Object(Symbol(1));
Object.prototype.toString.call(s);// '[object Symbol]'
这种形式的对象,我们可以也利用Object(obj.valueOf())
进行返回即可。
现在,我们将这些不方便遍历的类型单独做个抽离,能遍历的对象还是用使用上述实现,具体实现如下:
// 拷贝不便于遍历的对象类型
const cloneOtherType = (obj, type) => {
switch (type) {
case boolType:
case numType:
case strType:
case dateType:
return new obj.constructor(obj.valueOf());
case symbolType:
return Object(obj.valueOf());
case regexpType:
// 待实现
case funType:
// 待实现
}
};
const deepClone = (obj, map = new Map()) => {
// 如果不是对象直接返回
if (!isObject(obj)) {
return obj;
};
// 获取当前参数的对象类型
const objType = getObjectType(obj);
// 根据constructor找到原始构造器,创建初始化对象
let obj_;
if (traverseTypes.includes(objType)) {
// 如果是可遍历类型,直接创建空对象
obj_ = new obj.constructor();
} else {
// 若不是,则走额外的处理
return cloneOtherType(obj, objType);
}
// 相同代码省略.....
};
现在我们只差正则和函数的实现了,先来说说正则。
贰 ❀ 捌 实现正则深拷贝
声明一个正则一般有两种写法,常见的正则字面量直接创建,或者使用new
结合正则构造器来创建,如下:
cosnt reg1 = /123/g;
const reg2 = new Regexp(/123/,'g');
当我们接受一个正则时,肯定也是希望通过new
然后传入参数来得到一个新的正则对象,这里我们就得提取两部分,一部分是正则的匹配文本(123),一部分是正则的修饰符(g)。至于前者,我们能通过source
属性访问,后者则可以通过flags
访问,举个例子:
const reg = /1/ig;
const {source, flags} = reg;
console.log(source, flags);// 1 gi
所以我们很容易写出如下的代码:
return new Regexp(obj.source, obj.flags);
但需要注意的是,正则表达式有个lastIndex
属性,用于指定下次匹配从什么时候开始,举个例子:
const reg = /echo/g;
const str = 'echo echo echo';
let res;
// exec匹配到会返回一个数组,匹配不到返回null
while (res = reg.exec(str) !== null) {
console.log(reg.lastIndex); // 4 9 14
}
第一匹配到echo
后,下一次匹配的位置很明显是第一个空格处,所以索引肯定是4,第二次匹配成功后lastIndex
就变成9了,以此类推。而lastIndex
这个东西是可以手动设置的,我们改改上面的例子:
const reg = /echo/g;
const str = 'echo echo echo';
reg.lastIndex = 9;
let res;
// exec匹配到会返回一个数组,匹配不到返回null
while (res = reg.exec(str) !== null) {
console.log(reg.lastIndex); // 14
}
所以你看看,单纯拿source
和flags
还不够,我们还得把传递的正则的lastIndex
也抄过来,不然匹配的行为可能跟原正则不一致,因此完整的正则拷贝应该是:
// 克隆正则
const cloneRegexp = (obj) => {
const {
resource,
flags,
lastIndex
} = obj;
const obj_ = new Regexp(resource, flags);
obj_.lastIndex = lastIndex;
return obj_;
}
贰 ❀ 玖 实现函数克隆
实话实说,一般针对函数的拷贝,我们都是原封不动的返回,即便博主工作了5年,说实话也没遇到要拷贝函数的场景。这里我也查阅了网上一些拷贝函数的思路,简单说说。
第一种思路与正则一样,把函数转为字符串,然后正则匹配函数的参数,函数体,最后通过new Function()
的形式创建一个新函数,但考虑到函数柯里化,闭包,等等复杂的场景,以及函数还存在自调用函数,箭头函数,匿名函数等复杂因素,目前我能找到的此类实现其实都有问题,所以这里我就不做代码补充了。
第二种,借用eval
,看个例子:
const foo = x => console.log(x + 1);
const fn = eval(foo.toString())
fn(1);// 2
通过toString
将函数转为字符串后,借用eval
执行再次得到函数,看似可以,但只要函数是函数声明,这种做法就完全行不通了:
function foo(x) {
console.log(x + 1);
};
console.log(foo.toString())
const fn = eval(foo.toString());
fn(1);// 报错,fn是undefined
第三种,借用bind
返回一个boundFn
,也算是投机取巧的一种做法,并不符合我们心中的函数深拷贝,所以综合来说,不如不拷贝,毕竟本身就没这个需求在,如果面试真的问到,可以阐述以上三种做法,其中复杂性我想面试官自己也能体会。
叁 ❀ 总
那么总结上述所有改写,我们直接贴上完整版代码:
// 可遍历类型
const arrType = '[object Array]';
const objType = '[object Object]';
const mapType = '[object Map]';
const setType = '[object Set]';
const argType = '[object Arguments]';
// 不可遍历
const boolType = '[object Boolean]';
const numType = '[object Number]';
const strType = '[object String]';
const dateType = '[object Date]';
const errType = '[object Error]';
const regexpType = '[object Regexp]';
const symbolType = '[object Symbol]';
const funType = '[object Function]';
// 将可遍历类型做个集合
const traverseTypes = [arrType, objType, mapType, setType, argType];
const isObject = (obj) => {
const type = typeof obj;
return obj !== null && (type === 'object' || type === 'function');
};
const getObjectType = (obj) => {
return Object.prototype.toString.call(obj);
};
// 克隆正则
const cloneRegexp = (obj) => {
const {
resource,
flags,
lastIndex
} = obj;
const obj_ = new Regexp(resource, flags);
obj_.lastIndex = lastIndex;
return obj_;
}
// 拷贝不便于遍历的对象类型
const cloneOtherType = (obj, type) => {
switch (type) {
case boolType:
case numType:
case strType:
case dateType:
return new obj.constructor(obj.valueOf());
case symbolType:
return Object(obj.valueOf());
case regexpType:
return cloneRegexp(obj);
case funType:
return obj;
}
}
const deepClone = (obj, map = new Map()) => {
// 如果不是对象直接返回
if (!isObject(obj)) {
return obj;
};
// 获取当前参数的对象类型
const objType = getObjectType(obj);
// 根据constructor找到原始构造器,创建初始化对象
let obj_;
if (traverseTypes.includes(objType)) {
// 如果是可遍历类型,直接创建空对象
obj_ = new obj.constructor();
} else {
// 若不是,则走额外的处理
return cloneOtherType(obj, objType);
}
// 解决循环引用问题
if (map.has(obj)) {
return map.get(obj);
};
// 存储当前拷贝的对象,以及我们要返回的对象
map.set(obj, obj_);
// 拷贝Set
if (objType === setType) {
obj.forEach((val, key) => {
obj_.add(deepClone(val, map));
});
return obj_;
};
// 拷贝Map
if (objType === mapType) {
obj.forEach((val, key) => {
obj_.set(key, deepClone(val, map));
});
return obj_;
};
// 如果是数组或者{}
for (let i in obj) {
// 不管是不是对象,直接递归,外面的typeof会帮我们做判断是否要继续遍历
obj_[i] = deepClone(obj[i], map);
};
return obj_;
};
如果你是跟着本文思路一步步走下来,上述代码理论上来说不存在难以理解的点,简单测试下例子,也暂未发现有什么问题,不过再怎么说,上述实现也不能用于生产环境,毕竟当下就有更专业的三方库来帮我解决深拷贝的问题。
你也许会想,面试真的会让你手写一个深拷贝吗?我没遇到过手写,但确实遇到过讲解实现思路以及有哪些边界情况需要考虑的问题,若真手写,能将上述思路实现到七七八八,就已经非常优秀了,看似在实现深拷贝,其实这段实现中,我想大家也发现考核了javascript
中很多基础知识,大的有原型链与继承,es6
的Map Set
,小的知识点甚至考虑到了正则的lastIndex
;所以站在我的角度,这篇文章也能作为js
综合复习的一个入口。
写到最后,若文章存在错误以及有所疑问,也欢迎留言讨论,那么到这里本文结束。