最后更新于 2020-02-22
深浅拷贝也算是一个老生常谈的话题了,它也是一些面试题当中的高频题目,所以今天就抽些时间来深入的了解一下 JavaScript
中的深浅拷贝,也算是记录记录,不过在此之前我们先来了解一下可能会与深浅拷贝所混淆的『赋值』概念
变量的赋值 我们在之前的 JavaScript 中的数据类型 章节当中曾经提到过,在 JavaScript
中,变量包含两种不同的数据类型,即『基本类型』和『引用类型』,在将一个值赋给变量时,解析器必须确定这个值是基本类型还是引用类型
基本类型的值被直接存储在『栈』中,在变量定义时,栈就为其分配好了内存空间,由于栈中的内存空间的大小是固定的,那么注定了存储在栈中的变量就是不可变的
相对于具有不可变性的基本类型,我们习惯于把对象称为引用类型,引用类型的值实际存储在『堆内存』中,它在栈中只存储了一个固定长度的地址,这个地址指向堆内存中的值
与其他语言不同,JavaScript
不允许直接访问内存中的位置,也就是说我们不能直接操作对象的内存空间,所以在操作对象时,实际上是在操作对象的引用而不是实际的对象,所以变量的赋值行为可以分为『传值』与『传址』两种
给变量赋予基本数据类型的值,也就是『传值』,而给变量赋予引用数据类型的值,实际上是『传址』,基本数据类型变量的赋值、比较,只是值的赋值与比较,即栈内存中的数据的拷贝和比较
1 2 3 4 5 6 7 8 9 10 var a = 123 var b = 123 var c = aa === b a === c a = 456 a === b a === c
引用数据类型变量的赋值、比较,只是存于栈内存中的堆内存地址的拷贝、比较
1 2 3 4 5 6 7 const a = [1 , 2 , 3 ]const b = ab.push(4 ) a b a === b
由于 a
和 b
都是引用类型,采用的是『址』传递,即 a
将地址传递给 b
,那么 a
和 b
必然指向同一个地址(引用类型的地址存放在栈内存中),而这个地址都指向了堆内存中引用类型的值,当 b
改变了这个值的同时,因为 a
的地址也指向了这个值,故 a
的值也跟着变化
那么如果我们想让 b
的值在改变后不影响 a
的值的话,该如何解决呢?这也就引出了我们今天的主题『浅深拷贝』,下面我们就来看看如何解决这样的问题
什么是拷贝 在展开之前,我们先来直观的感受一下『赋值』与『拷贝』的区别,比如下面这个示例
1 2 3 4 5 const a = [1 , 2 , 3 ]const b = ab[0 ] = 4 b
这就是直接赋值的情况,不涉及任何拷贝,当我们改变 b
的时候,由于是同一个引用,所以 a
指向的值也会跟着改变,下面我们再来看看浅拷贝的情况,如下
1 2 3 4 5 6 const a = [1 , 2 , 3 ]const b = a.slice()b[0 ] = 4 a b
当我们修改 b
的时候,a
的值并没有改变,这是因为这里的 b
是 a
浅拷贝后的结果,所以 b
和 a
现在引用的已经不是同一块空间了,而这也就是所谓的『浅拷贝』,但是别急着高兴,我们上面的操作是存在一个潜在问题的,我们将其简单的调整一下
1 2 3 4 5 const a = [1 , 2 , { val : 3 }]const b = a.slice()b[2 ].val = 4 a
我们发现了问题所在,为什么改变了 b
当中的第二个元素的 val
值,a
当中的 val
也跟着变了,上面不是说引用已经不是同一块空间了吗?这也就是浅拷贝的限制所在了,因为它只能拷贝一层对象,如果有对象的嵌套,那么浅拷贝将无能为力
但幸运的是,深拷贝就是为了解决这个问题而生的,它能解决对象嵌套的拷贝问题,实现彻底的拷贝,下面我们就先来看看它们两者之间的区别,然后在一步一步的来实现一个我们自己的『深拷贝』
深拷贝与浅拷贝 我们先来明确一下深拷贝和浅拷贝的定义,其实两者的区别可以如下图所示
浅拷贝
简而言之,就是创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝
如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址,所以如果其中一个对象改变了这个地址,就会影响到另一个对象
深拷贝
将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象
话不多说,浅拷贝我们就不过多提及了,下面我们直接进入正题,来看看『深拷贝』到底该如何实现
入门版本 如果我们的对象只是普通的对象,没有函数,Symbol
,RegExp
等一系列特殊的对象的话,比较方便的方式就是使用下面这个方法
1 JSON .parse(JSON .stringify())
这种写法非常简单,而且可以应对大部分的应用场景,比如使用它来解决我们上面遇到的问题
1 2 3 4 5 6 const a = [1 , 2 , { val : 3 }]const b = JSON .parse(JSON .stringify(a))b[2 ].val = 4 a b
但是它是有很大缺陷的,对于某些严格的场景来说,这个方法是有巨大的坑的,首先,无法解决循环引用的问题,比如下面这个示例
1 2 3 const a = { val : 2 }a.target = a
我们使用上面的方式去拷贝 a
的话就会出现系统栈溢出的错误,因为出现了无限递归的情况,也就是说 JSON.stringify()
无法转换这样的结构
其次就是无法拷贝一些特殊的对象,诸如函数,Date
,Set
,Map
等,所以我们在某些要求比较严格的使用场景就需要另辟蹊径了
关于这两者更多内容可以参考我们之前整理过的 JSON.parse() && JSON.stringify()
基础版本 既然没有现成的 API
可用,那么我们就来尝试自己动手实现一个,如果只是浅拷贝的话,Object.assign()
,我们上面用到的 slice()
或是 concat()
,另外还有扩展运算符(...
)等都可以帮助我们完成目标,但是它们当中的某些方法比较有局限性,比如说只能适用于数组,所以一个比较通用的浅拷贝通常是下面这样的
1 2 3 4 5 6 7 const deepClone = (target ) => { const cloneTarget = Array .isArray(target) ? [] : {} for (let prop in target) { cloneTarget[prop] = target[prop] } return cloneTarget }
简单来说就是创建一个新的对象,遍历需要拷贝的对象,将需要拷贝对象的属性依次添加到新对象上,返回即可
如果是深拷贝的话,考虑到我们要拷贝的对象是不知道有多少层深度的,我们可以用递归来解决问题,所以针对上面的代码我们只需要稍微的调整一下就可以得到我们的『深拷贝』的基础版本,其中需要注意的有以下几点
如果是基本类型,无需继续拷贝,直接返回
如果是引用类型,创建一个新的对象,遍历需要拷贝的对象,将需要拷贝对象的属性执行『深拷贝』后依次添加到新对象上
很容易理解,如果有更深层次的对象可以继续递归直到属性为基本类型,这样我们就完成了一个最简单的深拷贝
1 2 3 4 5 6 7 8 9 10 11 12 13 const deepClone = (target ) => { if (typeof target === 'object' && target !== null ) { const cloneTarget = Array .isArray(target) ? [] : {} for (let prop in target) { if (target.hasOwnProperty(prop)) { cloneTarget[prop] = deepClone(target[prop]) } } return cloneTarget } else { return target } }
下面来简单的测试下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const target1 = { a: 1 , b: undefined , c: { name: 2 }, d: [3 , 4 , 5 ], e: null } const target2 = deepClone(target1)target2.c.name = 4 console .log(target1) console .log(target2)
现在基本功能已经基本实现了,但是之前遗留的几个问题,比如循环引用,特殊对象的拷贝等,我们都会在这个基础版本之上一步步来完善、优化我们的深拷贝代码
循环引用 我们先来测试这样的一个示例,如下
1 2 3 4 5 6 7 8 const target = { val: 1 } target.target = target deepClone(target)
很明显,因为递归进入死循环导致栈内存溢出了,而原因就是我们上面提到的对象循环引用的情况,即对象的属性间接或直接的引用了自身的情况,针对于这种循环引用的问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题
而这个存储空间,需要可以存储 key-value
形式的数据,且 key
可以是一个引用类型,所以我们可以选择 Map
这种数据结构(WeakMap
也可),具体流程如下
首先检查 Map
中有无拷贝过的对象
如果有,则直接返回
如果没有,则将当前对象作为 key
,拷贝对象作为 value
进行存储
继续拷贝
这里我们将之前放在函数体内部的判断提取了出来,让函数主体更为简洁明了一些
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 const isObject = (target ) => (typeof target === 'object' || typeof target === 'function' ) && target !== null const deepClone = (target, map = new Map ( )) => { if (isObject(target)) { if (map.get(target)) { return map.get(target) } const cloneTarget = Array .isArray(target) ? [] : {} map.set(target, cloneTarget) for (let prop in target) { if (target.hasOwnProperty(prop)) { cloneTarget[prop] = deepClone(target[prop], map) } } return cloneTarget } else { return target } }
现在我们再来测试一下上面循环引用的示例
1 2 3 4 5 6 7 8 9 10 11 const target = { val: 1 } target.target = target deepClone(target)
现在可以看到,执行后已经没有报错了,并且 target
属性变为了一个 Circular
类型,即循环引用的意思,现在循环引用的问题已经解决了,下面我们再来看看特殊对象的处理需要如何操作
特殊对象的处理 在上面的代码中,我们其实只考虑了普通的 object
和 array
两种数据类型,实际上引用类型的对象远远不止这两个,而对于特殊的对象,我们可以使用以下方式来进行鉴别
1 const getType = obj => Object .prototype.toString.call(obj)
这一部分内容我们在之前的 类型判断 章节当中已经详细梳理过了,所以在这里我们就直接抽离出一些常用的数据类型以便后面使用,如下所示
1 2 3 4 5 6 7 8 9 10 const mapTag = '[object Map]' const setTag = '[object Set]' const boolTag = '[object Boolean]' const numberTag = '[object Number]' const stringTag = '[object String]' const symbolTag = '[object Symbol]' const dateTag = '[object Date]' const errorTag = '[object Error]' const regexpTag = '[object RegExp]' const funcTag = '[object Function]'
在上面的这些类型当中,我们可以简单的将它们分为两类
一类是可以继续遍历的类型
另一类是不可以继续遍历的类型
所以我们下面就分别来为它们做对应不同的拷贝处理
可继续遍历的类型 上面我们提到过的 object
、array
都属于可以继续遍历的类型,因为它们当中都还可以存储其他类型的数据,另外还有 Map
,Set
等都是可以继续遍历的类型,但是这里需要注意的一点就是,我们在上面的实现当中,cloneTarget
是直接赋值给了 []
或是 {}
,这里存在的问题就是可能会造成原型的丢失
为了避免这个问题,我们在初始化的时候可以通过 target.constructor
的方式,然后在对其进行 new
操作,这是因为 {}
本质上就是 new Object()
的语法糖,另外我们还使用了原对象的构造方法,所以这样一来它就可以保留对象原型上的数据,而如果直接使用普通的 {}
,那么原型必然是丢失了的(当然这种方式也是存在一些小问题的,我们会在后面来进行处理)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 const isObject = (target ) => (typeof target === 'object' || typeof target === 'function' ) && target !== null const getType = obj => Object .prototype.toString.call(obj)const mapTag = '[object Map]' const setTag = '[object Set]' const canTraverse = { '[object Map]' : true , '[object Set]' : true , '[object Array]' : true , '[object Object]' : true , '[object Arguments]' : true , } const deepClone = (target, map = new Map ( )) => { if (!isObject(target)) { return target } let type = getType(target) let cloneTarget if (!canTraverse[type]) { return } else { let ctor = target.constructor cloneTarget = new ctor() } if (map.get(target)) { return target } map.set(target, true ) if (type === mapTag) { target.forEach((item, key ) => { cloneTarget.set(deepClone(key, map), deepClone(item, map)) }) } if (type === setTag) { target.forEach(item => { cloneTarget.add(deepClone(item, map)) }) } for (let prop in target) { if (target.hasOwnProperty(prop)) { cloneTarget[prop] = deepClone(target[prop], map) } } return cloneTarget }
再来简单的测试一下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const map = new Map ()map.set('key' , 'value' ) const set = new Set()set .add('key')set .add('value')const target = { a: 1 , b: undefined , c: { name: 2 }, d: [3 , 4 , 5 ], e: null , map, set , } // {a: 1 , b : undefined , c : { name : 2 }, d : [3 , 4 , 5 ], e : null , Map : { 'key' => 'value' }, Set : { 'key' , 'value' }} deepClone(target)
没有问题,下面我们再来继续处理其他类型
不可继续遍历的类型 针对于不可遍历的对象,不同的对象有不同的处理,下面我们来看看如何进行完善
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 const cloneReg = (target ) => { const { source, flags } = target return new target.constructor(source, flags) } const cloneFunc = (target ) => { } const cloneOtherType = (target, tag ) => { const Ctor = targe.constructor switch (tag) { case boolTag: case numberTag: case stringTag: case errorTag: case dateTag: return new Ctor(target) case regexpTag: return cloneReg(target) case funcTag: return cloneFunc(target) default : return new Ctor(target) } }
仔细观察的话可以发现,我们在这里少罗列了一种类型,那就是 Symbol
类型,不过不要着急,我们会在下面完善的时候一起来进行介绍
拷贝函数 虽然函数也是对象,但是它过于特殊,所以这里我们就单独把它拿出来进行拆解,实际上函数拷贝是没有实际应用场景的,两个对象使用一个在内存中处于同一个地址的函数也是没有任何问题的,我们可以参考 lodash
里的 _.clone(value)
方法的源码当中对于函数的处理,如下
1 2 3 4 5 const isFunc = typeof value == 'function' if (isFunc || !cloneableTags[tag]) { return object ? value : {} }
可见这里如果发现是函数的话就会直接返回了,没有做特殊的处理,话虽这么说,但是在这里我们还是简单的扩展一些,来看看到底如何完善函数的拷贝
在 JavaScript
中有两种函数,一种是普通函数,另一种是箭头函数,每个普通函数都是 Function
的实例,而箭头函数不是任何类的实例,并且每次调用都是不一样的引用,所以简单的归纳一下就是我们只需要处理普通函数的情况,而箭头函数的话就直接返回它本身就可以了
我们可以使用正则来处理普通函数,分别使用正则取出函数体和函数参数,然后使用 new Function([arg1[, arg2[, ...argN]]] functionBody)
构造函数重新构造一个新的函数
至于如何区分普通函数和箭头函数呢?我们可以通过 prototype
来进行区分,因为箭头函数是没有 prototype
的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const cloneFunc = (func ) => { if (!func.prototype) return func const bodyReg = /(?<={)(.|\n)+(?=})/m const paramReg = /(?<=\().+(?=\)\s+{)/ const funcString = func.toString() const param = paramReg.exec(funcString) const body = bodyReg.exec(funcString) if (!body) return null if (param) { const paramArr = param[0 ].split(',' ) return new Function (...paramArr, body[0 ]) } else { return new Function (body[0 ]) } }
到现在,我们的深拷贝就差不多比较完善了,不过还存在一些小问题,我们接着往下看
布尔包装类 我们在之前曾提到过,在初始化的时候,如果直接使用 {}
可能会造成原型的丢失问题,所以我们采用了 target.constructor
的方式,但是这样的方式对于布尔包装类会存在一些小问题,如下
1 2 3 4 5 const target = new Boolean (false )const Ctor = target.constructornew Ctor(target)
对于这样的问题,我们可以单独针对 Boolean
类型的拷贝做最简单的修改,即调用它的 valueOf
,也就是调整为下面这样
1 new target.constructor(target.valueOf())
但实际上,这种写法是不推荐的,因为在 ES6
后已经不再推荐使用这种对于基本类型直接使用 new
操作的语法了,所以 ES6
中的新类型 Symbol
是不能直接使用 new
的,而是需要通过 new Object(SymbelType)
来进行调用,所以我们可以来调整一下我们之前的写法,也顺路将我们之前遗留的 Symbol
类型统一进去
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const cloneOtherType = (target, tag ) => { const Ctor = target.constructor switch (tag) { case boolTag: return new Object (Boolean .prototype.valueOf.call(target)) case numberTag: return new Object (Number .prototype.valueOf.call(target)) case stringTag: return new Object (String .prototype.valueOf.call(target)) case symbolTag: return new Object (Symbol .prototype.valueOf.call(target)) case errorTag: case dateTag: return new Ctor(target) case regexpTag: return cloneFunc(target) case funcTag: return cloneFunc(target) default : return new Ctor(target) } }
这样一来,我们的深拷贝也算是比较完善了
完整代码 最终完善后的代码汇总如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 const getType = obj => Object .prototype.toString.call(obj)const isObject = (target ) => (typeof target === 'object' || typeof target === 'function' ) && target !== null const canTraverse = { '[object Map]' : true , '[object Set]' : true , '[object Array]' : true , '[object Object]' : true , '[object Arguments]' : true , } const mapTag = '[object Map]' const setTag = '[object Set]' const boolTag = '[object Boolean]' const numberTag = '[object Number]' const stringTag = '[object String]' const symbolTag = '[object Symbol]' const dateTag = '[object Date]' const errorTag = '[object Error]' const regexpTag = '[object RegExp]' const funcTag = '[object Function]' const handleRegExp = (target ) => { const { source, flags } = target return new target.constructor(source, flags) } const cloneFunc = (func ) => { if (!func.prototype) return func const bodyReg = /(?<={)(.|\n)+(?=})/m const paramReg = /(?<=\().+(?=\)\s+{)/ const funcString = func.toString() const param = paramReg.exec(funcString) const body = bodyReg.exec(funcString) if (!body) return null if (param) { const paramArr = param[0 ].split(',' ) return new Function (...paramArr, body[0 ]) } else { return new Function (body[0 ]) } } const cloneOtherType = (target, tag ) => { const Ctor = target.constructor switch (tag) { case boolTag: return new Object (Boolean .prototype.valueOf.call(target)) case numberTag: return new Object (Number .prototype.valueOf.call(target)) case stringTag: return new Object (String .prototype.valueOf.call(target)) case symbolTag: return new Object (Symbol .prototype.valueOf.call(target)) case errorTag: case dateTag: return new Ctor(target) case regexpTag: return cloneFunc(target) case funcTag: return cloneFunc(target) default : return new Ctor(target) } } const deepClone = (target, map = new Map ( )) => { if (!isObject(target)) return target let type = getType(target) let cloneTarget if (!canTraverse[type]) { return cloneOtherType(target, type) } else { let ctor = target.constructor cloneTarget = new ctor() } if (map.get(target)) return target map.set(target, true ) if (type === mapTag) { target.forEach((item, key ) => { cloneTarget.set(deepClone(key, map), deepClone(item, map)) }) } if (type === setTag) { target.forEach(item => { cloneTarget.add(deepClone(item, map)) }) } for (let prop in target) { if (target.hasOwnProperty(prop)) { cloneTarget[prop] = deepClone(target[prop], map) } } return cloneTarget }
最后我们再来简单的测试一下,如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 const map = new Map ()map.set('key' , 'value' ) const set = new Set()set .add('key')set .add('value')const target = { a: 1 , b: undefined , c: { name: 2 }, d: [3 , 4 , 5 ], e: null , map, set , bool: new Boolean(true), num: new Number(2), str: new String(2), symbol: Object(Symbol(1)), date: new Date(), reg: /\d+/, error: new Error(), func1: () => { console .log('func1' ) }, func2: function (a, b ) { return a + b } } deepClone(target)
结果如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 { a: 1 , b: undefined , bool: [Boolean : true ], c: { name : 2 }, d: [3 , 4 , 5 ], date: Mon Feb 22 2020 22 :14 :00 GMT+0800 (中国标准时间) {}, e: null , error: Error , func1: () => { console .log('func1' ) }, func2: [ƒ anonymous(a, b)], map: {'key' => 'value' } num: [Number : 2 ], reg: /\d+/ , set : {'key' , 'value' } str: [String : '2' ] , symbol: [Symbol : Symbol (1 )], }
Vuex 当中的实现 最后的最后,我们再来看看 Vuex
当中 deepCopy
的源码部分,与我们手动实现的版本可以做一个对比,加深印象,实现如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 function deepCopy (obj, cache = [] ) { if (obj === null || typeof obj !== 'object' ) { return obj } const hit = cache.filter(c => c.original === obj)[0 ] if (hit) { return hit.copy } const copy = Array .isArray(obj) ? [] : {} cache.push({ original: obj, copy }) Object .keys(obj).forEach(key => { copy[key] = deepCopy(obj[key], cache) }) return copy }
这里我们着重介绍这一部分
1 2 3 4 5 const hit = cache.filter(c => c.original === obj)[0 ]if (hit) { return hit.copy }
这一部分判断的作用主要是针对如果传入的对象与缓存的相等,则递归结束,这样可以防止循环,类似下面这种
1 2 var a = { b : 1 }a.c = a
更多详细内容可以参考 MDN
上面的 TypeError: cyclic object value
当然你可能会发现,这里并没有针对 Map
,Set
,Date
等特殊对象来进行处理,因为针对于我们平常的开发任务来说,针对性的处理 {}
和 []
就已经足够我们使用了,当然还是需要根据实际使用场景来选择最为适合的方式
参考