34

深拷贝和浅拷贝

说起深拷贝和浅拷贝,首先我们来看两个栗子

// 栗子1
var a = 1,b=a;
console.log(a);
console.log(b)
b = 2;
console.log(a);
console.log(b)
// 栗子2
var obj1 = {x: 1, y: 2}, obj2 = obj1;
console.log(obj1) //{x: 1, y: 2}
console.log(obj2) //{x: 1, y: 2}
obj2.x = 2; //修改obj2.x
console.log(obj1) //{x: 2, y: 2}
console.log(obj2) //{x: 2, y: 2}

按照惯性思维,栗子1中obj1应该跟a一样,不会因另外一个值的改变而改变的啊,而这里却是obj1跟着obj2的改变而改变了?同样都是变量,怎么就表现不一样了呢?难道存在等级上的优劣?此处需要沉思一小会。要解决这个问题,就要引入一个JS中基本类型和引用类型的概念了。

基本类型和引用类型

ECMAScript变量包含两种不同数据类型的值:基本类型值和引用类型值。基本类型值指的是那些保存在栈内存中的简单数据段,即这种值完全保存在内存中的一个位置。而引用类型值是指那些保存堆内存中的对象,意思是变量中保存的实际上只是一个指针,这个指针指向内存中的另一个位置,该位置保存对象。

两类数据的保存方式

数据保存格式

从上图可以看到,栈内存主要用于存储各种基本类型的变量,包括Boolean、Number、String、Undefined、Null等以及对象变量的指针。而堆内存主要负责对象Object这种变量类型的存储。目前基本类型有:
Boolean、Null、Undefined、Number、String、Symbol,引用类型有:Object、Array、Function。Symbol就是ES6才出来的,之后也可能会有新的类型出来。

让我们再回到前面的案例,栗子1中的值为基本类型,栗子2中的值为引用类型,栗子2中的赋值就是典型的浅拷贝。我们需要明确一点,深拷贝与浅拷贝的概念只存在于引用类型。

既然已经知道了深拷贝与浅拷贝的来由,那么该如何实现深拷贝?我们分别来看看Array和Object自有方法是否支持:

var arr1 = [1, 2];
var arr2 = arr1.slice();
console.log(arr1); //[1, 2]
console.log(arr2); //[1, 2]

arr2[0] = 3; //修改arr2
console.log(arr1); //[1, 2]
console.log(arr2); //[3, 2]

此时,arr2的修改并没有影响到arr1,看来深拷贝的实现并没有那么难嘛。我们把arr1改成二维数组再来看看结果

var arr1 = [1, 2, [3, 4]];
var arr2 = arr1.slice();
console.log(arr1); //[1, 2, [3, 4]]
console.log(arr2); //[1, 2, [3, 4]]

arr2[2][1] = 5; 
console.log(arr1); //[1, 2, [3, 5]]
console.log(arr2); //[1, 2, [3, 5]]

咦,arr2又改变了arr1,看来slice()只能实现一维数组的深拷贝,并不能实现真正的深拷贝。与之有同等特性的还有:concat、Array.from() 。

研究完Array,我们来看看Object

var obj1 = {x: 1, y: 2};
var obj2 = Object.assign({}, obj1);
console.log(obj1) //{x: 1, y: 2}
console.log(obj2) //{x: 1, y: 2}

obj2.x = 2; //修改obj2.x
console.log(obj1) //{x: 1, y: 2}
console.log(obj2) //{x: 2, y: 2}
var obj1 = {
    x: 1, 
    y: {
        m: 1
    }
};
var obj2 = Object.assign({}, obj1);
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 1, y: {m: 1}}
obj2.y.m = 2; //修改obj2.y.m
console.log(obj1) //{x: 1, y: {m: 2}}
console.log(obj2) //{x: 2, y: {m: 2}}

经实践证明,Object.assign()跟Array一样也只能实现一维对象的深拷贝。造成只能实现一维对象深拷贝的原因是第一层的属性确实实现了深拷贝,拥有了独立的内存,但更深的属性却仍然公用了地址,所以才会造成上面的问题。

那怎么真正的实现引用类型的深拷贝呢?接下来要有请正主入场

1.JSON.parse(JSON.stringify(obj))

var obj1 = {
    x: 1, 
    y: {
        m: 1
    }
};
var obj2 = JSON.parse(JSON.stringify(obj1));
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 1, y: {m: 1}}

obj2.y.m = 2; //修改obj2.y.m
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 2, y: {m: 2}}

JSON.parse(JSON.stringify(obj)) 简单粗暴,简简单单让你功力倍增,不过MDN文档的描述有句话写的很清楚:

undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)。详情可以戳这里MDN文档
var obj1 = {
    x: 1,
    y: undefined,
    z: function add(z1, z2) {
        return z1 + z2
    },
    a: Symbol("foo")
};
var obj2 = JSON.parse(JSON.stringify(obj1));
console.log(obj1) //{x: 1, y: undefined, z: ƒ, a: Symbol(foo)}
console.log(JSON.stringify(obj1)); //{"x":1}
console.log(obj2) //{x: 1}

经实践证明,在将obj1进行JSON.stringify()序列化的过程中,y、z、a都被忽略了,也就验证了MDN文档的描述。既然这样,那JSON.parse(JSON.stringify(obj))的使用也是有局限性的,不能深拷贝含有undefined、function、symbol值的对象,不过JSON.parse(JSON.stringify(obj))简单粗暴,已经满足90%的使用场景了。
经过验证,我们发现JS 提供的自有方法并不能彻底解决Array、Object的深拷贝问题。只能祭出大杀器:递归

2.递归

function deepCopy(obj) {
    // 创建一个新对象
    let result = {}
    let keys = Object.keys(obj),
        key = null,
        temp = null;

    for (let i = 0; i < keys.length; i++) {
        key = keys[i];    
        temp = obj[key];
        // 如果字段的值也是一个对象则递归操作
        if (temp && typeof temp === 'object') {
            result[key] = deepCopy(temp);
        } else {
        // 否则直接赋值给新对象
            result[key] = temp;
        }
    }
    return result;
}

var obj1 = {
    x: {
        m: 1
    },
    y: undefined,
    z: function add(z1, z2) {
        return z1 + z2
    },
    a: Symbol("foo")
};

var obj2 = deepCopy(obj1);
obj2.x.m = 2;

console.log(obj1); //{x: {m: 1}, y: undefined, z: ƒ, a: Symbol(foo)}
console.log(obj2); //{x: {m: 2}, y: undefined, z: ƒ, a: Symbol(foo)}

可以看到,递归完美的解决了前面遗留的所有问题。但是,还有一个非常特殊极端的场景:循环引用拷贝

var obj1 = {
    x: 1, 
    y: 2
};
obj1.z = obj1;

var obj2 = deepCopy(obj1);

此时如果调用刚才的deepCopy函数的话,会陷入一个循环的递归过程,从而导致爆栈。解决这个问题也非常简单,只需要判断一个对象的字段是否引用了这个对象或这个对象的任意父级即可

function deepCopy(obj, parent = null) {
    // 创建一个新对象
    let result = {};
    let keys = Object.keys(obj),
        key = null,
        temp= null,
        _parent = parent;
    // 该字段有父级则需要追溯该字段的父级
    while (_parent) {
        // 如果该字段引用了它的父级则为循环引用
        if (_parent.originalParent === obj) {
            // 循环引用直接返回同级的新对象
            return _parent.currentParent;
        }
        _parent = _parent.parent;
    }
    for (let i = 0; i < keys.length; i++) {
        key = keys[i];
        temp= obj[key];
        // 如果字段的值也是一个对象
        if (temp && typeof temp=== 'object') {
            // 递归执行深拷贝 将同级的待拷贝对象与新对象传递给 parent 方便追溯循环引用
            result[key] = deepCopy(temp, {
                originalParent: obj,
                currentParent: result,
                parent: parent
            });

        } else {
            result[key] = temp;
        }
    }
    return result;
}

var obj1 = {
    x: 1, 
    y: 2
};
obj1.z = obj1;

var obj2 = deepCopy(obj1);
console.log(obj1); 
console.log(obj2); 

总结

  • 简单的一维层次的拷贝可以利用数组自身方法和对象的Object.assign实现,在二维层次上方法失效,无法实现深拷贝
  • 简单粗暴的常见的拷贝可以通过JSON.parse(JSON.stringify(obj))实现,但对于属性的某些特殊类型的值失效。
  • 终极方法,用递归实现引用类型的深拷贝
  • 当然还有其他方法,比如使用第三方库内封装的方法

suan_suan
1.1k 声望176 粉丝

努力写bug中......