阅读 1476

JS 浅拷贝与深拷贝问题

1、对象拷贝、浅拷贝与深拷贝问题

JS中对象之间的赋值采用的是引用拷贝的方法。在理解这个之前,需要先理解JS运行时的堆栈空间。对象数据存放在堆内存中,对象变量存放在栈内存中,对象变量通过引用数据的堆地址实现对象访问。

与基本类型不同,对象之间的赋值,是拷贝了堆内存的地址空间,结果是两个变量指向了同一个对象实体,也称共享,任何一个对对象的修改都会影响另一个变量。

let obj1 = {
    value: 1
};
let obj2 = obj1;
obj2.value = 2;
console.log(obj1.value);    //2
复制代码

很多时候,这不是我们想要的效果。我们希望克隆出来的值不会影响到原来的对象。

为了实现对象之间互不影响,首先到的方法是对对象的每一个属性都做一次拷贝,类似下面的做法。

function shallowClone(item) {
    let clone = {};
    for (let prop in item) {
        clone[prop] = item[prop];
    }
    return clone;
}
复制代码

测试一下:

let obj1 = {
    value: 1
};
let obj3 = shallowClone(obj1);
console.log(obj3.value);     //1
obj3.value = 2;
console.log(obj1.value);     //1
复制代码

It works!是的,看上去是,让我们看一个稍微复杂的对象。

let compObj1 = {
    value: 1,
    param: {
        id: 1314,
        num: 3
    }
}
let compObj2 = shallowClone(compObj1);

console.log(compObj2);    //{ value: 1, param: { id: 1314, num: 3 } }
compObj2.param.num = 4;
console.log(compObj1.param.num);//4
复制代码

同样的问题又出现了。原因在于compObj1存在值为对象类型的属性。在shallowClone()中对每一个属性采用赋值拷贝,如果属性是对象类型,也只是得到了引用。

我们也称这种拷贝为浅拷贝。也就是说,浅拷贝事实上只拷贝了对象的最外层属性。与之相对的,如果我们得到的拷贝对象在每一层属性上都不共享,就称为深拷贝,深拷贝是一种彻底的克隆,源对象与克隆对象不会相互影响。

为了实现深拷贝,我们可以采取对对象属性进行递归拷贝的方法。

function deepClone(item) {
    let clone = {};
    for (let prop in item) {
        clone[prop] = (item[prop] instanceof Object ?
            deepClone(item[prop]) :
            item[prop]);
    }
    return clone;
}
复制代码

再使用上面的例子测试,证实确实是对param进行了拷贝。

尽管上面看似实现了对象的拷贝,但那不过是我为了解释简单处理,忽略和很多细节。问题远没有那么简单。

  1. 拷贝对象与原对象的一致性。上面的代码代码使用for...in循环遍历属性,但是for...in列出了对象自身及其原型链上的可枚举属性,也就是说不可枚举的属性没有被拷贝,原型也没有得到拷贝。
  2. 拷贝方法的通用性。 在上面的例子中,只考虑了对象的情况,但实际中,不可避免有人把基本类型作为item传入,这时会得到一个默认对象,显然不合理。另外,我们应该注意到,数组也存在深浅拷贝的问题,当传入数组时,我们希望得到一个拷贝的数组
  3. 拷贝方法的鲁棒性。它是否适用于所有的对象的拷贝?显然,上面的例子并不满足,当对象出现引用循环时,deepClone()将无限递归。

不幸的是,并不像语言一样,JS中对象拷贝的问题并没有一个通用的解决方案。但是,对特定的对象结构,不难找到合适的拷贝方案。下面,对一些常见的拷贝方法做个总结和比较。

2、深浅拷贝的实现

在探讨拷贝之前,我觉得有必要说明几个问题。

  1. 你是否真的需要深拷贝? 如果确定对象属性值都是基本类型,或者不会对对象的深层属性进行修改,这些情况下,浅拷贝就足够了。
  2. 你需要拷贝哪些内容? 一个对象的属性是复杂的,有自身属性和原型链上的属性,有可枚举属性和不可枚举属性,有字符串属性和Symbol属性,在选择拷贝方案时,请确定你的目标对象具有哪些属性,以及你需要拷贝哪些属性。
  3. 可能需要哪些拷贝数据类型? 一些特定的类型可能需要特殊的拷贝处理,如Data, RegExp

2.1 浅拷贝

2.1.1、利用Object.assign()进行浅拷贝

Object.assign(target, ...sources)实现将一组源对象中的自身可枚举属性(包括Symbol属性)复制到目标对象上。我们可以利用这个特点,进行浅拷贝,只需要一行代码。

let copyObj = Object.assign({}, sourceObj);
复制代码

很多时候,这种拷贝是足够的,它的局限性在于:

  • 只拷贝了自身的可枚举属性,没有拷贝正确的原型和不可枚举属性。
  • IE不兼容

2.1.2、利用展开语法实现浅拷贝

ES6中的展开语法...能方便地进行对象属性复制,同assign(),它拷贝了对象自身的可枚举属性。

let copyObj = {...target};
复制代码

相当简洁易懂有没有,这也是我个人比较喜欢的拷贝方式。更强大的在于,你可以决定你要拷贝哪些属性:

let copyObj={};
{copyObj.prop1:prop1, copyObj.prop2:prop2 } = {...target}
复制代码

另外,展开语法也能进行数组的拷贝:

let newArr = [...arr];
复制代码

2.1.3、借助Object.create()实现浅拷贝

利用Object.create(), 我们能拷贝出一个高度相似的对象:

let clone = Object.create(
	Object.getPrototypeOf(target), 
    Object.getOwnPropertyDescriptors(target)
    );
复制代码

这种拷贝在对象与对象直接几乎是完美的,正确的原型,正确的属性。但遗憾数组拷贝的结果会变成一个对象,尽管数组的访问方式和方法都可以,但Array.isArray(clone)===false

2.1.4、手动实现浅拷贝

如果Object.assign()不适用,你可能需要自己定制自己的拷贝函数,类似shallowClone()的例子。不过,现在,让我们来优化一下代码。

  • 一致性:为了更具有通用性,这里尽可能的保证拷贝对象和原对象的一致性。所以,我们会拷贝对象原型以及所有的对象属性。
  • 通用性:只实现数组和对象类型的拷贝,其它类型,我们只返回原来的对象即可。
function shallowClone(target) {
    //排除非对象和非数组变量
    if (!(target instanceof Object)) return target;

    let clone = (Array.isArray(target) ? [] : Object.create(target.__proto__))

    let keys = Reflect.ownKeys(target); //保证能获取到所有自身属性 
    for (let k of keys) {
        clone[k] = target[k];
    }
    return clone;
}
复制代码

需要注意的是,上面的方式是支持数组的(经过验证)。数组时特殊的对象,arr instanceof Object的结果为true。在for...of循环内进行数组内容或属性的拷贝。在拷贝数组时,不能直接返回数组的副本是因为数组上可能挂有其它的属性。

再次提醒,以上只是提供一种思路,你应该按自己的需要实现拷贝方法。

2.2、深拷贝

2.2.1、借助JSON转化实现深拷贝

使用JSON的解析功能可以快速进行深拷贝,很多时候,这是拷贝对象时的最先想到的方法。

let copyObj = JSON.parse(JSON.stringify(target));
复制代码

得益于Json格式对数据的处理,这个方法在很多时候都能派上用场。但是,也存在问题:

  • 对某些数据不支持:如Date类型会被转为字符串类型,UndefinedRegExp类型丢失等问题。
  • 无法拷贝存在循环引用的对象。
  • 拷贝自身可枚举字符串属性,原型链丢失。
  • 属性特性丢失。
  • 性能较差。

2.2.2、手动实现深拷贝

同样地,让我们来手动实现深拷贝。

function deepClone(target) {
    if (!(target instanceof Object) || 'isClone' in target)
        return target;
    
	let clone = null;
    if (target instanceof Date)
        clone = new target.constructor(); 
    else if(Array.isArray(target))
        clone = [];
    else
        clone = new target.constructor();
	let keys = Reflect.ownKeys(target);
    for (let key of keys) {
        if (Object.prototype.hasOwnProperty.call(target, key)) {
            target['isClone'] = null;
            clone[key] = deepClone(target[key]);
            delete target['isClone'];
        }
    }
    return clone;
}
复制代码

上面的isClone属性用于防止循环引用时发生无限迭代。

后记

上面很多是自己在网上查看资料后结合自己思考得出的结果,经过测试可行。如果有什么错误,还请指出。觉得本文有帮助就给点鼓励。thanks!

文章分类
前端
文章标签