JS:手写实现深拷贝,这次彻底搞懂
深拷贝和浅拷贝的区别
在 JS 中,数据类型 分为原始类型和引用类型,我们平常所说的深、浅拷贝都是针对引用类型而言,因为不管是深拷贝还是浅拷贝,在遇到原始类型的时候,都会直接拷贝它们的值,并无区别。
深拷贝和浅拷贝都是创建一个新的对象。区别是
-
浅拷贝是拷贝对象的内存地址(指针),如果原对象或新对象修改了这个地址指向的对象,那么双方都会受到影响。
-
深拷贝是从内存中完整的拷贝一个对象,拷贝后的新对象和原对象之间互不影响。
原生 JS 可以很简单实现浅拷贝,比如使用 Object.assign()
:
let source = { obj: { bio: 'I am an object' }, str: 'Hello' }
let target = Object.assign({}, source)
console.log(target) // { obj: { bio: "I am an object" }, str: "Hello" }
source.obj.bio = 'I am I am'
source.str = 'Hi'
console.log(source) // { obj: { bio: "I am I am" }, str: "Hi" }
console.log(target) // { obj: { bio: "I am I am" }, str: "Hello" }
我们看到,source
对象中 obj
是一个对象,对象是引用类型。当改变 obj
的 bio
时,source
和 target
两者都会改变,这就是浅拷贝。str
是一个字符串,是原始类型,会直接拷贝值,修改原对象不会影响新对象,所以只有 source
中的发生了改变。
而想要实现一个两者互不影响的深拷贝则没有这么简单。在实际项目中,我们可能都是直接使用下面这个方法:
const a = JSON.parse(JSON.stringify(b))
这种方法在大部分情况下虽然简单可行,但面对一些特殊对象,比如对象中包含函数、循环引用等,继续使用这个方法就行不通了。为了彻底搞懂深拷贝,我们不妨自己手动实现,在过程中逐渐理解。
第一步:实现拷贝
首先我们创建一个函数,在函数内部判断该对象是否属于引用类型 object
,如果是,创建一个全新的对象,通过遍历将对象内的属性和值全部存入到该对象,最后将其返回。反之则说明是原始类型,原始类型不存在深拷贝概念,直接返回该原始值即可。
function deepClone(source) {
if (typeof source === 'object') {
let target = {}
for (const key in source) {
target[key] = source[key]
}
return target
} else {
return source
}
}
第二步:处理多层对象
我们知道,对象中也是可以无限包含对象的。在上一步骤,我们已经有判断类型的过程,所以在这一步骤中递归调用该函数即可,直至遇到原始类型。
function deepClone(source) {
if (typeof source === 'object') {
let target = {}
for (const key in source) {
target[key] = deepClone(source[key]) // +
}
return target
} else {
return source
}
}
第三步:处理 Array
在上面的步骤中,我们给需要返回的 target
默认设置的是对象 {}
形式,很显然没有考虑数组的情况。这里我们只需要在设置 target
的时候判断一下 source
是否为数组即可。
function deepClone(source) {
if (typeof source === 'object') {
let target = Array.isArray(source) ? [] : {} // +
for (const key in source) {
target[key] = deepClone(source[key])
}
return target
} else {
return source
}
}
第四步:处理 null、正则、日期
我们知道,虽然 null
在数据类型中属于原始类型,但 typeof null
却输出为 object
。而正则、日期等也会输出 object
。此三者是不能被遍历的,所以在遍历对象和数组之前需要先处理一下 null、正则、日期,防止进入遍历。
function deepClone(source) {
// 处理 null
if (source === null) return source // +
// 处理正则
if (source instanceof RegExp) return new RegExp(source) // +
// 处理日期
if (source instanceof Date) return new Date(source) // +
// 处理对象和数组
if (typeof source === 'object') {
let target = Array.isArray(source) ? [] : {}
for (const key in source) {
target[key] = deepClone(source[key])
}
return target
}
// 处理原始类型
return source
}
第五步:处理 Symbol
Symbol
虽然属于原始类型,但同时它拥有一个特性,就是每个从 Symbol()
返回的 symbol
值都是唯一的。如果按照上面的步骤直接返回同一个 Symbol
,那显然就违背了该特性。此时我们生成一个相同描述的 Symbol
即可。
function deepClone(source) {
// 处理 null
if (source === null) return source
// 处理正则
if (source instanceof RegExp) return new RegExp(source)
// 处理日期
if (source instanceof Date) return new Date(source)
// 处理 Symbol
if (typeof source === 'symbol') return Symbol(source.description) // +
// 处理对象和数组
if (typeof source === 'object') {
let target = Array.isArray(source) ? [] : {}
for (const key in source) {
target[key] = deepClone(source[key])
}
return target
}
// 处理原始类型
return source
}
第六步:处理 Map 和 Set
在上面的步骤中,我们使用花括号 {}
收集了对象,但是如果用同样的方式收集 Map 和 Set,则失去了拷贝的准确性。为此,我们需要单独对它们进行处理。
function deepClone(source) {
// 处理 null
if (source === null) return source
// 处理正则
if (source instanceof RegExp) return new RegExp(source)
// 处理日期
if (source instanceof Date) return new Date(source)
// 处理 Symbol
if (typeof source === 'symbol') return Symbol(source.description)
// 处理 Map
if (Object.prototype.toString.call(source) === '[object Map]') {
let target = new Map()
source.forEach((value, key) => {
target.set(key, deepClone(value))
})
return target
}
// 处理 Set
if (Object.prototype.toString.call(source) === '[object Set]') {
let starget = new Set()
source.forEach(value => {
target.add(deepClone(value))
})
return target
}
// 处理对象和数组
if (typeof source === 'object') {
let target = Array.isArray(source) ? [] : {}
for (const key in source) {
target[key] = deepClone(source[key])
}
return target
}
// 处理原始类型
return source
}
第七步:处理循环引用
此时我们对类型的处理已经结束,还有一些其他的类型没有考虑,比如 DOM 元素,这里不再赘述。
在下面的代码中,source
对象的 obj
属性,引用了其本身。这个时候还能进行深拷贝吗?
let source = {
str: 'hello',
arr: [1, 2, 3],
}
source.obj = source
let target = deepClone(source)
调用 deepClone
方法,发现控制台抛出了以下错误:
RangeError: Maximum call stack size exceeded
这是因为 deepClone
处理对象时递归调用了 deepClone
方法,而 source
也在引用自身,所以会无限递归下去,从而造成了内存溢出问题。
为了防止无限递归,我们就要先判断有没有循环引用,如果出现循环引用,则直接返回,不再递归,避免出现内存溢出。所以我们需要额外的存储空间进行记录。当需要拷贝自身时,去这个存储空间查找是否已经存在该对象即可。
而 WeakMap 正好可以满足这种需求。
function deepClone(source, map = new WeakMap()) {
// 处理 null
if (source === null) return source
// 处理正则
if (source instanceof RegExp) return new RegExp(source)
// 处理日期
if (source instanceof Date) return new Date(source)
// 处理 Symbol
if (typeof source === 'symbol') return Symbol(source.description)
// 处理原始类型
if (typeof source !== 'object') return source
// 创建 target 实例
let target = new source.constructor()
// 处理循环引用
if (map.get(source)) {
return source
} else {
map.set(source, target)
}
// 处理 Map
if (Object.prototype.toString.call(source) === '[object Map]') {
source.forEach((value, key) => {
target.set(key, deepClone(value))
})
return target
}
// 处理 Set
if (Object.prototype.toString.call(source) === '[object Set]') {
source.forEach(value => {
target.add(deepClone(value))
})
return target
}
// 处理对象和数组
if (typeof source === 'object') {
for (const key in source) {
target[key] = deepClone(source[key])
}
return target
}
}
之前的代码在处理 Map、Set、对象和数组时分别创建了实例,比较麻烦,所以改用了 Object
原型上的 constructor
构造函数。调用该函数会自动创建实例对象。
第八步:优化
我们在处理非 object
类型之后,一共处理了四个可遍历的类型。但此代码存在两个问题,一是仍然有可遍历的类型,二是并不是所有 object
类型都有可执行的 constructor
构造函数。所以我们需要对此进行优化处理。
// 可遍历对象
// 如果想处理其他的可遍历对象,比如函数的 arguments,可加入此数组,便于维护
const iterations = [
'[object Object]',
'[object Array]',
'[object Map]',
'[object Set]',
]
function deepClone(source, map = new WeakMap()) {
// 处理 null
if (source === null) return source
// 获取对象类型
const type = Object.prototype.toString.call(source)
// 处理不可遍历对象
if (!iterations.includes(type)) {
// 处理日期
if (type === '[object Date]') return new Date(source)
// 处理正则
if (type === '[object RegExp]') return new RegExp(source)
// 处理 Symbol
if (type === '[object Symbol]') return Symbol(source.description)
// 其他未处理的类型,一般是原始类型或函数,直接返回
return source
}
// 处理可遍历对象
// 创建 target 实例
let target = new source.constructor() // {} | [] | Map(0) | Set(0)
// 处理循环引用,防止死循环
if (map.get(source)) {
return source // 如果已经处理过,则直接返回,不再遍历
} else {
map.set(source, target)
}
// 处理 Map
if (type === '[object Map]') {
source.forEach((value, key) => {
target.set(key, deepClone(value))
})
return target
}
// 处理 Set
if (type === '[object Set]') {
source.forEach(value => {
target.add(deepClone(value))
})
return target
}
// 处理对象和数组
for (const key in source) {
target[key] = deepClone(source[key])
}
return target
}
总结
到这里,我们实现的 deepClone
方法已经能够应对大部分情况了。但是仍然存在许多不足,尤其是对类型的判断和性能问题,还有很大的改进空间。但我们的目的在于——了解深拷贝的作用、原理以及对类型的更深入了解等方面。在实际项目中,更推荐使用成熟的开源库,比如 lodash 等。
参考资料: