深拷贝和浅拷贝可以说是面试中最常见的问题之一,除了直接让你手写深浅拷贝,还有如何比较两个对象完全相同等变体题目。本文将先带读者明确引用赋值、浅拷贝、深拷贝三者的差异,再初步讲解深浅拷贝的多种实现方式。
概念剖析
在讲述如何实现深浅拷贝之前,我们先要搞清楚他们的定义。假如我们有一个原始对象 originObj
:
js
const originObj = {
name: 'Ken',
age: 18,
childObj: {...}
}
这是它在内存中的存储方式: 我们再定义一个 copyObj
, 并把 originObj
赋值给它:
js
const copyObj = originObj;
赋值很容易和浅拷贝混淆,赋值是仅限于栈中的操作,而浅拷贝会在堆中开辟空间: 浅拷贝顾名思义,就是拷贝了,但是很浅,只拷贝第一层,如果有子对象就不管了,直接共享原始对象的子对象内存,不会再为子对象去开辟堆内存了。而深拷贝就是无论有多少层子对象,它都会一五一十的拷贝下来: 如果你对基本数据类型和引用数据类型相关知识有了解的话,相信看到这里你已经能理解三者的区别了,可以动手尝试修改这几种拷贝后的对象,对原始对象有什么影响来印证自己的理解。
实现浅拷贝
遍历
遍历需要浅拷贝的对象,将这个对象的属性依次添加到一个新对象上,返回这个浅拷贝出来的新对象。
js
function clone(target) {
let cloneTarget = Array.isArray(target) ? [] : {};
for (const key in target) {
cloneTarget[key] = target[key];
}
return cloneTarget;
};
Object.assign()
Object.assign()
静态方法将一个或者多个源对象 中所有可枚举的自有属性复制到目标对象,并返回修改后的目标对象。
js
let originObj = { person: { name: "pony", age: 18 }, company: 'Tencent' };
let shallowCopyObj = Object.assign({}, originObj);
shallowCopyObj.person.name = "Ken";
shallowCopyObj.company = 'Alibaba'
console.log(originObj); // { person: { name: 'Ken', age: 18 }, sports: 'Tencent' }
展开运算符
js
let originObj = { person: { name: "pony", age: 18 }, company: 'Tencent' };
let shallowCopyObj = { ...originObj };
shallowCopyObj.person.name = "Ken";
shallowCopyObj.company = 'Alibaba'
console.log(originObj); // { person: { name: 'Ken', age: 18 }, sports: 'Tencent' }
数组对象
如果需要进行浅拷贝的对象是一个数组,可以使用一些返回一个新数组的方法,比如Array.prototype.concat() 和 Array.prototype.slice(),它们返回的就是一份数组的浅拷贝。
js
let originArr = []
let shallowCopyArr1 = originArr.concat()
let shallowCopyArr2 = originArr.slice()
实现深拷贝
JSON.parse(JSON.stringify(Obj))
js
let newObj = JSON.parse(JSON.stringify(someobj));
在《你不知道的JavaScript(上)》里介绍过这种方法,是最简单明了的实现方式。其缺点是不能处理复杂对象,比如函数、日期、正则等,也不能正确处理循环引用。
原生深拷贝的终结者 :structuredClone()
这是一个 HTML DOM
中提供的 API,几乎能够实现几乎对所有数据类型的深拷贝,但不兼容较老的浏览器和 node
版本,请结合具体情况使用。
js
structuredClone(value)
structuredClone(value, { transfer })
const original = { name: "MDN" };
original.itself = original;
const clone = structuredClone(original);
console.log(clone !== original); // true 并不指向同一个对象
console.log(clone.name === "MDN"); // true 拥有同样的属性值
console.log(clone.itself === clone); // true 循环引用正常
-
value:这是你想要克隆的值。
-
options:这是一个可选的对象,它可以有以下属性:
- transfer :这是一个数组,包含了所有你想要转移而不是克隆的对象。转移的对象在原始对象中将不再可用❗仅对可转移对象生效,下面是一个文档里的示例。
js
// 创建一个16MB的Uint8Array
const uInt8Array = new Uint8Array(1024 * 1024 * 16);
// 克隆它并转移其底层资源
const transferred = structuredClone(uInt8Array, { transfer: [uInt8Array.buffer], });
console.log(uInt8Array.byteLength); // 输出:0
console.log(transferred.byteLength); // 输出:16777216
递归遍历
接下来就是重头戏,递归遍历对象实现深拷贝。 JSON.parse
无法处理许多特殊的引用类型,也不能正确的处理循环引用;而 structuredClone API
虽然对这些问题做了处理,但我们不用关心具体的实现。很显然,这两者都不会是面试官询问的重点😂。
我们从浅拷贝出发,来一步一步解决这些问题。首先是对于引用类型的属性,我们需要通过递归遍历,将需要克隆的属性添加到一个新对象上:
js
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;
}
};
这个版本的深拷贝已经支持拷贝普通对象和数组了,但是如果对象内存中循环引用,即 target.target = target
,很显然我们的递归是无法跳出的,死循环下去最终导致栈内存溢出报错。
解决这个问题我们可以利用 map
来存储当前拷贝对象和属性的 key-value
键值对。比方说首次遇到 target.target
的时候我们会往 map
中存入 target-target
, 再往下一层遍历的时候检查 map
中是否已经存在以 target
为键值的对象。如果是循环引用,那么递归的下一层自然还是相同的对象,在 map
中会发现它已经被存入了,此时直接返回该对象即可。
js
function clone(target, map = new WeakMap()) {
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;
}
}
这里选择使用 WeakMap
而不是 Map
,主要原因在于他们的内存管理机制不同:
WeakMap
的一个重要特性是,如果没有对一个对象的引用,那么这个对象就可以被垃圾回收机制回收,即使这个对象作为一个WeakMap
的键。这意味着,WeakMap
不会阻止其键被垃圾回收 。这对于处理循环引用的问题非常有用,因为你不需要担心创建了不能被垃圾回收的引用。相比之下,只要一个Map
存在,它的键就不会被垃圾回收。
说老实话,笔者目前对这里的理解也比较浅显,如有疏漏还请指正:
Map
和 WeakMap
在 clone
函数执行完毕后都会释放内存。对于函数内部的局部变量,无论是 Map
还是 WeakMap
,它们的生命周期都与函数的执行周期相同。因此,从内存管理的角度来看,它们在函数执行完毕后都会被销毁,不会持续占用内存。
但在 clone
函数执行完毕后,Map
对象所跟踪的键和值(即对象和克隆对象)仍然存在于内存中。对于 WeakMap
,在 WeakMap
对象本身被销毁之后,它所跟踪的键值对也会被自动销毁。与 Map
不同,WeakMap
中的键是弱引用的,这意味着当没有其他地方引用键对象时,垃圾回收器会自动回收这些键对象,并自动删除与这些键对象相关联的值。
故而在拷贝非常庞大的对象时,使用Map
会对内存造成非常大的额外消耗,而且我们需要手动清除Map
的属性才能释放这块内存,而WeakMap
会帮我们巧妙化解这个问题。
结语
深拷贝还有很多值得探讨的地方,比如遍历时采用哪种循环方式性能最优;对于特殊引用类型的处理;对于类型判断考虑null
和函数等等。实际上面试时间有限,了解相关的思路即可,不大可能让现场实现一个非常完备的深拷贝函数,实际开发中可以再按需学习。本专栏面向面试中的JS手写题,笔者本身的水平也有限,之后有机会再继续补充,如果读者仍有兴趣可以看看下面这篇文章: Write a Better Deep Clone Function in JavaScript | by Shuai Li | JavaScript in Plain English