如何实现一个深拷贝?
赋值、浅拷贝
首先, 我们来看一个概念性的问题, 赋值、浅拷贝、深拷贝之间有什么区别?
-
赋值:
const obj = { bar: 'foo' }; const obj2 = obj; obj2.bar = 'baz'; console.log(obj); //{ bar: 'baz' }
当我们复制引用类型的变量时,实际上复制的是栈中存储的地址,所以复制出来的
obj2
实际上和obj
指向的堆中同一个对象。因此, 我们改变其中任何一个变量的值,另一个变量都会受到影响,这就是为什么会有深拷贝和浅拷贝的原因。 -
浅拷贝:
const foo = { name: 'foo' }; const obj = { bar: 'str', baz: foo }; const obj2 = { ...obj }; obj2.bar = 'number'; obj2.baz.name = 'foo2'; console.log(obj); //{ bar: 'str', baz: { name: 'foo2' } }
创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。
讲个题外话: 值传递和引用传递
先看两段代码:
function setName(name) {
name = 'foo';
}
const bar = 'bar';
setName(bar);
console.log(bar); //'bar'
很明显,上面的执行结果是 bar
,即函数参数仅仅是被传入变量赋值给了的一个局部变量,改变这个局部变量不会对外部变量产生影响.
function setName(obj) {
obj.name = 'foo';
}
const obj = { name: 'baz' };
setName(obj);
console.log(obj); //{name: 'foo'; }
上面的代码可能让你产生疑惑,是不是参数是引用类型就是引用传递呢?
当函数参数是引用类型时,我们同样将参数复制了一个副本到局部变量,只不过复制的这个副本是指向堆内存中的地址而已,我们在函数内部对对象的属性进行操作,实际上和外部变量指向堆内存中的值相同,但是这并不代表着引用传递,下面我们再看一段代码:
const obj = {};
function setName(o) {
o.name = 'bar';
o = { name: 'baz' };
}
setName(obj);
console.log(obj);
最后 输出为 {name: 'bar'}
, 函数参数传递的并不是变量的引用,而是变量拷贝的副本,当变量是原始类型时,这个副本就是值本身,当变量是引用类型时,这个副本是指向堆内存的地址.
深拷贝
回到主题, 来实现我们的深拷贝.
-
乞丐版:
const deepClone = (obj) => JSON.parse(JSON.stringify(obj));
最简易实现方式, 但对于值为
function、undefined、symbol
类型时无法处理 -
合格版
const deepClone = (source) => { if (source === null) { return null; } if (source === undefined) { return undefined; } if (typeof source === 'object') { if (Array.isArray(source)) { const clone = []; source.forEach((v) => { clone.push(deepClone(v)); }); return clone; } const clone = {}; Object.keys(source).forEach((key) => { clone[key] = deepClone(source[key]); }); return clone; } return source; };
能正确的处理
function、undefined、symbol, array
等类型, 但是当存在循环引用是, 会进入死循环. -
较完整版
const isArray = <T>(source: unknown): source is Array<T> => Array.isArray(source); const isMap = (source: unknown): source is Map => Object.prototype.toString.call(source) === '[object Map]'; const isSet = <T>(source: unknown): source is Set<T> => Object.prototype.toString.call(source) === '[object Set]'; const isObject = (source: unknown) => { const type = typeof source; return source !== null && type === 'object'; }; const initData = (source: any) => { const { constructor } = source; if (!!constructor) { return new constructor(); } return; }; const deepClone = (source: unknown, map = new WeakMap()) => { if (!isObject(source)) { return source; } const clone = initData(source); if (!clone) { return source; } if (!!map.has(source as Object)) { return map.get(source as Object); } map.set(source as Object, clone); if (isArray<any>(source)) { source.forEach((v) => { (clone as Array<any>).push(deepClone(v, map)); }); return clone; } if (isMap<any, any>(source)) { source.forEach((v, key) => { (clone as Map<any, any>).set(key, deepClone(v, map)); }); return clone; } if (isSet<any>(source)) { source.forEach((v) => { (clone as Set<any>).add(deepClone(v, map)); }); return clone; } Object.keys(source as any).forEach((v) => { clone[v] = deepClone((source as any)[v], map); }); return clone; }; export default deepClone;
解决循环引用问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题。
这个存储空间,需要可以存储 key-value
形式的数据,且 key
可以是一个引用类型,我们可以选择 WeakMap
这种数据结构:
- 检查
map
中有无克隆过的对象 - 有 - 直接返回
- 没有 - 将当前对象作为
key
,克隆对象作为value
进行存储 - 继续克隆
当然, 这里只考虑了 Array
、 Object
、 Map
和 Set
四种可继续遍历类型, 还有其他类型, 这里不继续研究了...
Map
与 WeakMap
Map
是 ES6
中新增数据类型, 主要为了解决传统 Object
只能使用字符串作为键值, 并且不能够很方便的获取键的长度.
WeakMap
与 Map
的区别有两点:
-
WeakMap
只接受对象作为键名(null
除外),不接受其他类型的值作为键名。 -
WeakMap
的键名所指向的对象,不计入垃圾回收机制。
利用第 2 点便不用担心当我们要拷贝的对象非常庞大时,使用 Map 会对内存造成非常大的额外消耗,而且我们需要手动清除 Map 的属性才能释放这块内存,而 WeakMap 会帮我们巧妙化解这个问题。
测试代码地址: https://github.com/LZS911/vue3-ts-vite2-ly-component/tree/master/src/utils/deepClone