手写一个深拷贝?

5,605 阅读15分钟

深拷贝浅拷贝和赋值的原理及实现剖析


在工作中我们经常会用到深拷贝与浅拷贝,但是你有没有去分析什么场景下使用它,为什么需要使用呢,深浅拷贝有何异同呢,什么是深拷贝呢,如何实现呢,你会有这些问题吗,今天就为大家总结一下吧。

栈内存与堆内存

在了解这个问题之前需要先了解下栈内存与堆内存的概念,这里可以看看我的上一篇文章堆与栈的概念。了解完了这个概念再来了解这个就非常简单了。

区别
  • 浅拷贝---拷贝的是一个对象的指针,而不是复制对象本身,拷贝出来的对象共用一个指针,其中一个改变了值,其他的也会同时改变。
  • 深拷贝---拷贝出来一个新的对象,开辟一块新的空间,拷贝前后的对象相互独立,互相不会改变,拥有不同的指针。

简单的总结下,假设有个A,我们拷贝了一个为B,就是修改A或者B的时候看看另一个会不会也变化,如果改变A的值B也变了那么就是浅拷贝,如果改变A之后B的值没有发生变化就是深拷贝,当然这是基础理解,下面我们一起来分析下吧。

赋值
/** demo1基本数据类型 */
let a = 1;
let b = a;
b = 10;
console.log(a,b)//  1    10
/** demo2引用数据类型 */
let a = {
    name: '小九',
    age: 23,
    favorite: ['吃饭','睡觉','打豆豆']
}
let b = a;
a.name = '小七'
a.age = 18
a.favorite = ['上班','下班','加班']
console.log(a,b)
/** 
{ name: '小七', age: 18, favorite: [ '上班', '下班', '加班' ] } 
{ name: '小七', age: 18, favorite: [ '上班', '下班', '加班' ] }
*/

通过看上面的例子可以看出通过赋值去拿到新的值,赋值对于基本数据来说就是在栈中新开了一个变量,相当于是两个独立的栈内存,所以相互不会影响,但是对于引用数据类型,他只是复制了一份a在栈内存的指针,所以两个指针指向了同一个堆内存的空间,通过任何一个指针改变值都会影响其他的,通过这样的赋值可以产生多个指针,但是堆内存的空间始终只有一个,这就是赋值产生的问题,我们在开发中当然不希望改变B而影响了A,所以这个时候就需要用到浅拷贝和深拷贝了。

  • 针对基本数据类型,随便赋值都不会相互影响
  • 针对引用数据类型,赋值就会出现我们不想看到的,改动一方双方都变化。
浅拷贝
Object.assign()
/** Object.assign */
let A = {
    name: '小九',
    age: 23,
    sex: '男'
}
let B = Object.assign( {}, A);
B.name = '小七'
B.sex = '女'
B.age = 18
console.log(A,B)
/** { name: '小九', age: 23, sex: '男' } { name: '小七', age: 18, sex: '女' } */

首先实现浅拷贝的第一个方法是通过 Object.assign()这个 方法,Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。

先不管这个方法具体干嘛,我们先来看看结果,我们发现拷贝一个A之后的B改变了nameagesex之后A的值并没有发生变化,在这里,你可能会想,这不是就成功了么,AB宜家互相不影响了,可是和我们上面讲的浅拷贝会AB互相不变化就是深拷贝产生了矛盾,那么是为什么呢,其实上面已经说到了,这个demo里面用到的全是基本数据类型,所以拷贝和赋值一样,针对基本数据类型,都是在栈重新开辟一个变量,所以相互不会影响,那我们看看引用数据类型

let A = {
    name: '小九',
    age: 23,
    sex: '男',
    favorite: {
        item_a:['打游戏','上网'],
        item_b:['读书','网课']
    }
}
let B = Object.assign( {}, A);

B.name = '小七'
B.sex = '女'
B.age = 18
B.favorite.item_a =['打篮球'] 
B.favorite.item_b =['写笔记'] 
console.log(A)
console.log(B)
/** 打印结果对比 */
{ name: '小九',
  age: 23,
  sex: '男',
  favorite: { item_a: [ '打篮球' ], item_b: [ '写笔记' ] } }
----------------------------------------------------------------
{ name: '小七',
  age: 18,
  sex: '女',
  favorite: { item_a: [ '打篮球' ], item_b: [ '写笔记' ] } }

通过对比发现我们同样拷贝了A之后发现改变B的name age sex都不会影响,但是改变facorite的时候却影响了A,那么问题来了,这我们通过浅拷贝发现依然无法满足我们的需求,改变B同样影响了A,回到这个方法,Object.assign()这个方法是可以把任意的多个源对象的可枚举属性拷贝给目标对象,然后返回目标对象,它进行的是对象的浅拷贝,拷贝的是对象的引用,而不是对象本身,所以针对于这种有两层的数据结构就出出现只拷贝了第一层,第二层以下的对象依然拷贝不了,所以我们称Object.assign()为浅拷贝,只有在对象只有一层结构的时候才时候使用,

  • 很多人说Object.assign是深拷贝,其实是错误的,
  • 浅拷贝是按位拷贝对象,它会创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值;如果属性是内存地址(引用类型),拷贝的就是内存地址 ,因此如果其中一个对象改变了这个地址,就会影响到另一个对象。即默认拷贝构造函数只是对对象进行浅拷贝复制(逐个成员依次拷贝),即只复制对象空间而不复制资源。
  • 该方法只拷贝源对象自身的属性,不拷贝其继承的属性。
  • 该方法不会拷贝对象不可枚举的属性
  • undefined和null无法转成对象,他们不能作为Object.assign参数,但是可以作为源对象
  • 属性为Symbol的值,可以被该方法拷贝。
  • 浅拷贝,拷贝了第一层的基本数据类型结构,但是深层的依然没有拷贝到,也就是第一层基本类型数据已经不会影响了,但是引用却不行,所以还不够
Array.prototype.silce

看这个方法之前先给大家看看mdn对于这个方法的描述。

返回值

返回一个新的数组,包含从 start 到 end (不包括该元素)的 arrayObject 中的元素。

说明

请注意,该方法并不会修改数组,而是返回一个子数组。如果想删除数组中的一段元素,应该使用方法 Array.splice()。

看完它的描述大家就应该差不多明白了吧,让我们继续用刚刚的例子来实现下

let A = [1,2,3,[4,5]]
let B = A.slice();
B[3] = 4
console.log(A)
console.log(B)
/** 对比 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 4 ]

可以发现互相不会影响,也就实现了浅拷贝,同理针对于复杂的多层数据结构和之前一样也会互相影响,所以个人理解,这个浅字也是由此而来吧,所以上面的说法也不是很准备,不一定AB互相不影响就一定是深拷贝了,还得结合数据结构层级来看。

Array.from()

先来看一句mdn的描述

Array.from() 方法从一个类似数组或可迭代对象创建一个新的,浅拷贝的数组实例。

Array.form() 用于将两类对象转成真正的数组,一种是like-array(类数组),和可遍历的(iterable)对象,我们可以利用这个方法来进行一个浅拷贝。

let A = [1,2,3,[4,5]] ;
let B = Array.from(A) ;
B[3] = 6
console.log(A)
console.log(B)
/** 对比结果 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 6 ]

可以发现,也是一样的效果,可以实现。

Array.prototype.concat
let A = [1,2,3,[4,5]] ;
let B = [].concat(A) ;
B[3] = 6
console.log(A)
console.log(B)
/** 对比结果 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 6 ]

数组的方法原理大同小异,适当了解就行,可以自行操作试试看。

ES6 -> []

ES6的扩展运算符也可以轻松做到,也非常方便来看看吧

let A = [1,2,3,[4,5]]
let B =[...A]
B[3] = 6
console.log(A)
console.log(B)
/** 对比结果 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 6 ]

扩展运算符是es6新增的特性,作用很强大也非常方便,也是我日常爱用的一种方式,对象,数组都可以操作,除了轻松实现浅拷贝,合并对象也非常的轻松,可以多多使用。

for in

先写个简单版本,因为这个也可以实现深拷贝,所以直接动手吧,

let A = [1,2,3,[4,5]]
let B = []
for (var i in A){
    B[i] = A[i]
}

B[3] = 9
console.log(A,B)
/** 对比结果 */
[ 1, 2, 3, [ 4, 5 ] ]
[ 1, 2, 3, 9 ]

发现同样可以实现,原理也很简单,自行分析下。

浅拷贝的实现有很多种方法,不单单是我这里写出的六种,当然,实际开发中,我们更注重的是深拷贝,所以我们来看看如何实现一个深拷贝吧。

深拷贝
JSON.parse(JSon.stringify())
/** 乞丐版本  JSON.parse(JSON.stringify()) */
let A = {
    a: 1,
    b: 2,
    c: [4,5,6]
}
let B = JSON.parse(JSON.stringify(A))
B.a = 2 
B.b = 3
B.c = 4
console.log(A == B)
console.log(A,B)
/** 对比结果 
 * { a: 1, b: 2, c: [ 4, 5, 6 ] } 
 * { a: 2, b: 3, c: 4 }
 */

可以发现,使用这个方法可以做到拷贝之后的AB互相不受影响,成为单独一个新值,我们来分析下,这个方法里面我们用到了两个东西,分别是JSON.stringify()JSON.parse()这两个方法,首先通过stringify将json序列化(json字符串),然后在通过parse实现反序列(还原)js对象,序列化的作用是存储和传输,在这个过程中就会开启新的内存空间就会产生和源对象不一样的空间从而实现深拷贝,实际开发中这个用法已经可以解决很多场景了,但是依然有很多弊端。

  • 如果obj里面有时间对象,则JSON.stringify后再JSON.parse的结果,时间将只是字符串的形式。而不是时间对象;
  • 如果obj里有RegExp、Error对象,则序列化的结果将只得到空对象;
  • 如果obj里有函数,undefined,则序列化的结果会把函数或 undefined丢失;
  • 如果obj里有NaN、Infinity和-Infinity,则序列化的结果会变成null
  • JSON.stringify()只能序列化对象的可枚举的自有属性,例如 如果obj中的对象是有构造函数生成的, 使用这个方法后会丢弃对象的constructor。
  • 该方法不能拷贝function类型

综上所看,这个方法也有不少的问题,当然对于一个合格的程序员来说,这个版本也过于low,我们当然也希望实现的更加全面一点。

基础版本(浅拷贝)
/** 基础版本 for  in */

let A = {
    a: [1, 2, 3],
    b: { a: 1,b: 2},
    c: 99
}

function deepClone(target) {
    let return_result = {}
    for(let key in target) {
        return_result[key] = target[key]
    }
    return return_result
}

let B = deepClone(A)
B.a= 99
B.b = 88
console.log(A,'----------',B)
/** 对比结果 
 *  { a: [ 1, 2, 3 ], b: { a: 1, b: 2 }, c: 99 }
 *  { a: 99, b: 88, c: 99 }
 */

可以看到,通过for in可以实现一个基础的浅拷贝,和Object.assign()一样,只能拷贝第一层,但是我们初步已经成功了,

接下来我们需要考虑的是需要考虑数组了吧,上面只能是对象,也很简单,我们只需要加个判断就行,接下来改造一下:

兼容数组(浅拷贝)
/** 基础版本 for  in + 兼容数组 */
let A = [1,2,3,[4,5]]
function deepClone(target) {
    if (typeof target == 'object'){ //先判断是不是引用数据类型
        let return_result =  Array.isArray(target) ? [] : {}
        for(let key in target) {
            return_result[key] = target[key]
        }
        return return_result
    }else{
        return target;
    }
}
let B = deepClone(A)
B[3]= 99
B[2] = 88
console.log(A,'----------',B)
/** 对比结果
 *  [ 1, 2, 3, [ 4, 5 ] ]
 *  [ 1, 2, 88, 99 ]
 */

可以看到,现在已经可以兼容数组了,但是依然不够,我们依然只能拷贝第一层,所以接下来需要对深层侧的对象进行递归拷贝了,继续刚刚的方法改进吧:

基础版本+兼容数组+递归调用(深拷贝)
/** 兼容数组 + 递归调用 */
function deepClone(target) {
    let result;
    if (typeof target === 'object') {
        if (Array.isArray(target)) {
            result = []
            for (let i in target) {
                result.push(deepClone(target[i]))
            }
        } else {
            result = {}
            for (let key in target) {
                result[key] = target[key]
            }
        }
    } else {
        result = target;
    }
    return result;
}
let A = [1, 2, 3, { a: 1, b: 2 }]
let B = deepClone(A)
B[3].a = 99
console.log(A)
console.log(B)
/** 对比结果
 *  [ 1, 2, 3, [ 4, 5 ] ]
 *  [ 1, 2, 3, { a: 99, b: 2 } ]
 */

我们先判断其类型,再对对象和数组分别递归调用,如果是基本数据类型就直接赋值,至此,我们已经可以完成一个基础的深拷贝,但是还远远不够,因为我们这里只对数组做了类型判断,其他默认都是object,但是实际情况还会有很多类型,例如,RegExp,Date,Null,Undefined,function等等很多的类型,所以接下来我们将其完善,加上所以判断,由于类型比较多,我们可以把对象的判断单独抽离出来,接下来一起完善它吧:在这之前我们还需要考虑的一个点就是 关于js的循环引用问题当目前的这个方法去拷贝一个带有循环引用关系的对象时是有问题的,来看看:

 /** 基础版本 for  in + 兼容数组 + 递归调用 + 循环引用问题 */
function deepClone(target) {
    let result;
    if (typeof target === 'object') {
        if (Array.isArray(target)) {
            result = []
            for (let i in target) {
                result.push(deepClone(target[i]))
            }
        } else {
            result = {}
            for (let key in target) {
                result[key] = target[key]
            }
        }
    } else {
        result = target;
    }
    return result;
}
let A = [1, 2, 3, { a: 1, b: 2 }]
A[4] = A
let B = deepClone(A)
console.log(A,B)
/**  RangeError: Maximum call stack size exceeded */

会出现一个超出了最大调用堆栈大小的错误,这也是深拷贝中的一个坑,在这里我们可以通过js的一种weakmap的类型来解决这个问题,通过阅读mdn的文档可以了解到:

原生的 WeakMap 持有的是每个键对象的“弱引用”,这意味着在没有其他引用存在时垃圾回收能正确进行。原生 WeakMap 的结构是特殊且有效的,其用于映射的 key 只有在其没有被回收时才是有效的。

正由于这样的弱引用,WeakMap 的 key 是不可枚举的 (没有方法能给出所有的 key)。如果key 是可枚举的话,其列表将会受垃圾回收机制的影响,从而得到不确定的结果。因此,如果你想要这种类型对象的 key 值的列表,你应该使用 Map

基本上,如果你要往对象上添加数据,又不想干扰垃圾回收机制,就可以使用 WeakMap。

解决:使用一个WeakMap结构存储已经被拷贝的对象,每一次进行拷贝的时候就先向WeakMap查询该对象是否已经被拷贝,如果已经被拷贝则取出该对象并返回。

/** 基础版本 for  in + 兼容数组 + 递归调用 + 解决循环引用问题 */
function deepClone(val, hash = new WeakMap()) {
    if (hash.has(val)) return hash.get()
    let cloneVal;
    if (isObj(val)) { //判断是不是引用类型
        if (Array.isArray(val)) { // 判断是不是数组 
            cloneVal = []
            hash.set(val, cloneVal)
            for (let i in val) {
                cloneVal.push(deepClone(val[i]))
            }
        }
        else {
            cloneVal = {}
            hash.set(val, cloneVal)
            for (let key in val) {
                cloneVal[key] = val[key]
            }
        }
    } else {
        cloneVal = val;
    }
    return cloneVal;
}
/** 是否是引用类型 */
function isObj(val) {
    return (typeof val == 'object' || typeof val == 'function') && val != null
}
var a = {}
a.a = a
var b = deepClone(a)
console.log(b)

这样就可以初步解决循环调用问题,接下来要考虑的是如何为更多类型做不同处理,我们借用之前的一个检测js类型的文章,通过js检测数据类型 的这个方法来为多种类型分别处理。

/** 完整版本 */
function deepClonea(val, map = new WeakMap()) {
    let type = getType(val); //当是引用类型的时候先拿到其确定的类型
    if (isObj(val)) {
        switch (type) {
            case 'date':                   //日期类型重新new一次传入之前的值,date实例化本身结果不变
                return new Date(val);
                break;
            case 'regexp':                 //正则类型直接new一个新的正则传入source和flags即可
                return new RegExp(val.source, val.flags);
                break;
            case 'function':               //如果是函数类型就直接通过function包裹返回一个新的函数,并且改变this指向
                return new RegExp(val.source, val.flags);
                break;
            default:
                let cloneVal = Array.isArray(val) ? [] : {};
                if (map.has(val)) return map.get(val)
                map.set(val, cloneVal)
                for (let key in val) {
                    if (val.hasOwnProperty(key)) { //判断是不是自身的key
                        cloneVal[key] = deepClone(val[key]), map;//每一项就算是基本类型也需要走deepclone方法进行拷贝
                    }
                }
                return cloneVal;
        }
    } else {
        return val;     //当是基本数据类型的时候直接返回
    }
}
function isObj(val) {   //判断是否是引用类型
    return (typeof val == 'object' || typeof val == 'function') && val != null
}
function getType(data) { //获取类型
    var s = Object.prototype.toString.call(data);
    return s.match(/\[object (.*?)\]/)[1].toLowerCase();
};
// /** 测试 */
var a = {}
a.a = a
var b = deepClonea(a)
console.log(b)
最终完整版

上面差不多已经完成了一个可以应对大部分场景的深拷贝了,下面让我们用class类的方法来改造一下,方便后期对其进行扩展更改。

/**  改用class类写 */
class DeepClone {
    constructor(){
        cloneVal: null;
    }
    clone(val, map = new WeakMap()) {
        let type = this.getType(val); //当是引用类型的时候先拿到其确定的类型
        if (this.isObj(val)) {
            switch (type) {
                case 'date':             //日期类型重新new一次传入之前的值,date实例化本身结果不变
                    return new Date(val);
                    break;
                case 'regexp':           //正则类型直接new一个新的正则传入source和flags即可
                    return new RegExp(val.source, val.flags);
                    break;
                case 'function':        //如果是函数类型就直接通过function包裹返回一个新的函数,并且改变this指向
                    return new RegExp(val.source, val.flags);
                    break;
                default:
                    this.cloneVal = Array.isArray(val) ? [] : {};
                    if (map.has(val)) return map.get(val)
                    map.set(val, this.cloneVal)
                    for (let key in val) {
                        if (val.hasOwnProperty(key)) { //判断是不是自身的key
                            this.cloneVal[key] = new DeepClone().clone(val[key], map);
                        }
                    }
                    return this.cloneVal;
            }
        } else {
            return val;     //当是基本数据类型的时候直接返回
        }
    }
    /** 判断是否是引用类型 */
    isObj(val) {   
        return (typeof val == 'object' || typeof val == 'function') && val != null
    }
    /** 获取类型 */
    getType(data) { 
        var s = Object.prototype.toString.call(data);
        return s.match(/\[object (.*?)\]/)[1].toLowerCase();
    };
}
 /** 测试 */
var a ={
    a:1,
    b:true,
    c:undefined,
    d:null,
    e:function(a,b){
        return a + b
    },
    f: /\W+/gi,
    time: new Date(),

}
const deepClone = new DeepClone()
let b = deepClone.clone(a)
console.log(b)

好了上面就是本次总结的深拷贝,当然还不够完善,还有很多种场景,后期可能会补充,但是这个目前已经可以应对你很大一部分的场景了。