深浅拷贝
什么是拷贝,什么是深浅拷贝
在 JavaScript 中,变量中保存的是对象的引用。比如像下面这样的操作
var a = 对象1;
var b = a;
a和b都在引用对象1,如果对对象1进行修改,则a和b两个变量都会受到影响,有时我们不希望出现这种情况,就要对对象进行拷贝操作来切换引用。比如
var a = 对象1;
var b = 拷贝(a);
那么如何实现拷贝呢?
我们知道对象是由多个key和value组成的,所以拷贝就是创建一个新的对象,将旧对象的key和value拷贝到新对象中,让新旧对象的key和value完全一样。比如像下面这样
Object.keys(对象1).forEach(key => {
新对象[key] = 对象1[key];
})
这样的操作需要注意value的类型,分为基本类型和引用类型1,如果所有的value都是基本类型,但是如果value是引用类型就会出现新旧对象中都引用了一个对象的情况,也就是上文提到的a和b变量的问题。..如果想完全切断引用,就要对引用类型的value在进行一次拷贝..。对于是否需要完全切断引用就有了两种拷贝方式,需要完全切断引用的就是「深拷贝」,不需要的就是「浅拷贝」。
简单概括一下。
「浅拷贝」是只对对象的key和value进行一次遍历拷贝,不管内部的引用。
「深拷贝」要将对象内部的引用完全切断,对对象进行递归地拷贝。
如何实现深拷贝
先看一种实现方式
1function clone(旧对象) {
2 var 新对象 = {};
3 Object.keys(旧对象).forEach(key => {
4 var value = 旧对象[key];
5 if(is基本类型(value)) {
6 新对象[key] = 旧对象[key];
7 } else {
8 新对象[key] = clone(value); // 递归
9 }
10 })
11 return 新对象;
12}
这种方法用的是递归,即当value不是基本类型时就对它进行拷贝。该方法有一个明显的问题,当出现循环引用时,就进入了一个死循环。
我们知道,函数的调用会形成一个「执行栈」,函数执行过程中如果又调用了函数就像该函数压入栈顶并执行,前一个函数就暂停了等待新函数执行完。
递归就是当前对象还没有拷贝完,就开始了下一次拷贝,就是在「执行栈」又压入了一次函数调用,而循环引用会导致不停滴往执行栈顶添加,对后执行栈超过限制大小,而中断。
为了避免爆栈问题,可以让一次拷贝进行完之后再开始下一次拷贝。就是把遇到的对象放在一个地方(数组1),当遇到对象时把它保存起来,完成一次拷贝后去检查看还有没有需要拷贝的对象。
这样虽然解决了爆栈,但是仍是一个死循环,因为会不停地向那个地方添加对象。所以需要一种方法来判断已经拷贝过的对象。
我们再创建一个数组2,用来保存拷贝过的对象,当遇到新的对象时,先去数组内查找,就不向数组1添加了,也就阻止了死循环。
下面的代码是对上面分析的过程的实现
// value 参数为原对象
function cloneLoop(oldObject) {
const uniqueList = []; // 用来去重
// 新的空对象
const root = {};
// 存储子对象的数组
const children = [
{
parent: root,
key: undefined,
value: oldObject,
}
];
// 如果children内有子对象,则将其copy
while(children.length) {
// 深度优先,如果使用shift()则是广度有限。
const node = children.pop();
const parent = node.parent;
const key = node.key;
const childObject = node.childObject;
// 当key是undefined时,parent是{},也就是刚开始
// 当key不是undefined时,就是已经复制到子对象了,这时key肯定时有值的,
// 需要把key对应的value初始化为空对象,后面的循环把子对象的 key 和 value 复制到空对象上。
let res = parent;
if (typeof key !== 'undefined') {
res = parent[key] = {};
}
// 当循环引用时,childObject会与拷贝过的对象相等,就可以把它直接赋值到新对象上。完成一次拷贝。
let uniqueData = find(uniqueList, childObject);
if (uniqueData) {
parent[key] = uniqueData.target;
break; // 中断本次循环
}
// 数据不存在
// 保存源数据,在拷贝数据中对应的引用
uniqueList.push({
source: childObject,
target: res,
});
// 遍历子对象
for(let k in childObject) {
if (data.hasOwnProperty(k)) {
// 如果value时对象,将子对象放入「子对象数组」中,待下一次while读取遍历
if (typeof childObject[k] === 'object') {
// 下一次循环
children.push({
parent: res,
key: k,
childObject: childObject[k],
});
} else {
res[k] = childObject[k];
}
}
}
}
return root;
}
function find(arr, item) {
for(let i = 0; i < arr.length; i++) {
if (arr[i].source === item) {
return arr[i];
}
}
return null;
}
总结一下要点:
- 使用一个数组存储copy过程中遇到的对象,以破解爆栈的问题。
- 使用另一个数组存储copy过的对象,以破解循环引用的问题。
对于拷贝一个对象的过程lodash使用的是「结构化克隆算法」2,普通对象直接复制key和value,数组等其他对象使用相应的复制方法。