背景
最近开发的小程序项目中,要使用深拷贝,但是不想用Lodash
库(太大,小程序受不了)。所以打算自己写,结果没写出来,于是网上找了现成的先应付着。
项目上线后,觉得是时候做个基础知识的回顾了。这是一篇对深浅拷贝探索的文章,借鉴了很多前辈的总结,在此表示感谢(文末会列出参考的文献)。
浅拷贝
浅拷贝指的是:拷贝后的引用类型数据与源对象是同一份数据,修改源对象的值,会把拷贝对象的也一起修改,反之亦然。
Object.assign是浅拷贝
var obj1 = {
a:111,
b:{c:222}
}
var obj2 = Object.assign({},obj1) // 浅拷贝
obj2.b.c = 333 // 修改引用类型的值
console.log(obj1) // obj1.b.c的值被修改未333,是浅拷贝
console.log(obj2)
复制代码
修改obj2的值,obj1的值也被修改,因为obj1和obj2的b属性引用的是同一个对象。
ES6对象解构也是浅拷贝
var obj1 = {
a:111,
b:{c:222}
}
var obj2 = {...obj1} // 浅拷贝
obj2.b.c = 666 // 修改引用类型的值
console.log(obj1) // obj1.b.c的值被修改未666,是浅拷贝
console.log(obj2)
复制代码
原因和上面说的一致,引用的是同一个对象,浅拷贝仅仅拷贝的是对象的引用地址,并没有拷贝对象的内容。
手写一个简单的浅拷贝
思考题:你可以手写一个浅拷贝吗?下方的浅拷贝实现存在哪些问题?
/**
* 一个简单的浅拷贝
* @param {Object} sourceObj 要复制的对象
* @returns 返回浅拷贝的对象
*/
function shallowCopy(sourceObj) {
var obj = {}
if (typeof sourceObj !== 'object') {
return sourceObj
}
for (var key in sourceObj) {
if (Object.prototype.hasOwnProperty.call(sourceObj, key)) {
obj[key] = sourceObj[key]
}
}
return obj
}
var obj1 = {
name: 'obj1',
info: {
sex: 'male',
age: 18
},
undefined: undefined,
func: function () { console.log('I am a simple function') },
exp: new RegExp('\\w+'),
createTime: new Date(),
[Symbol('foo')]: 'symbol'
}
// 浅拷贝,修改原对象的引用类型的值,拷贝出来的对象也一同被修改
var shObj = shallowCopy(obj1)
obj1.info.age = 16 // 修改源对象
console.log('shObj', shObj) // 浅拷贝出来的对象也被修改了
复制代码
控制台的输出如下,修改obj1的age为16,shObj对象的age也被修改了。
上述浅拷贝方法存在的问题有很多,包括判断对象不够严谨,没有考虑循环引用等。我们的重点不是浅拷贝,为什么还要讲浅拷贝?
因为了解浅拷贝,才知道为什么要做深拷贝,浅拷贝是基础,深拷贝是进阶!
深拷贝
深拷贝指的是:拷贝后的对象是独立的(包括引用类型),修改源对象的值,不会对拷贝对象产生影响,反之亦然。
JSON.stringify()的弊端
说到深拷贝,很多人喜欢一把梭(即使用JSON.stringify()
方法和JSON.parse()
方法)。你知道使用这个两个方法做深拷贝会有哪些问题和隐患吗?
据我所知,使用JSON.stringify()做深拷贝,至少存在以下问题:
1、会忽略 undefined;
2、会忽略 symbol;
3、会忽略函数;
4、不能正确处理new Date();
5、不能处理正则(变成空对象);
6、不能解决循环引用的对象(直接报错)。
举个例子:
/**
* 深拷贝0(JSON.stringify()一把梭)
* @param0 sourceObj 要复制的对象
* @returns {Object} 返回深拷贝的对象
*/
function deepCopy0(sourceObj) {
try {
return JSON.parse(JSON.stringify(sourceObj))
} catch (error) {
console.log('JSON.stringify报错:', error)
}
}
var obj0 = {
name: 'obj0',
info: {
sex: 'male',
age: 22
},
undefined: undefined,
func: function () { console.log('I am a simple function') },
exp: new RegExp('\\w+'),
createTime: new Date(),
[Symbol('foo')]: 'symbol',
}
// 循环引用(已注释,打开后会报错,不信你拷贝到控制台试一下)
// obj0.circularReference = obj0
// 深拷贝,完全独立的两份数据,修改原对象引用类型的值,对拷贝出来的对象没影响
var copyObj0 = deepCopy0(obj0)
console.log('copyObj0--origin', obj0)
obj0.info.age = 100
console.log('copyObj0--copy', copyObj0)
try {
copyObj0.func()
} catch (error) {
console.log('这里会报错:', error)
}
复制代码
从结果可以看出,以上问题确实存在!
手写一个简单的深拷贝
思考题:除了JSON.stringify()
方法,你还能想到其他办法实现深拷贝吗?
回顾上文中手写的浅拷贝,之所以是浅拷贝,是因为仅拷贝了引用类型的地址(保存在栈中的指针),没有拷贝引用类型的值(保存在堆中数据)。
我们稍微修改一下,如果是引用类型,就使用递归的方式拷贝属性的值,一个简单的深拷贝就出来了。
/**
* 深拷贝1
* @param0 sourceObj 要复制的对象
* @returns {Object} 返回深拷贝的对象
*/
function deepCopy1(sourceObj) {
var obj = {}
if (typeof sourceObj !== 'object') {
return sourceObj
}
Object.keys(sourceObj).forEach(key => {
// 新增代码,遇到引用类型的属性,递归拷贝后再赋值
if (typeof sourceObj[key] === 'object') {
obj[key] = deepCopy1(sourceObj[key])
} else {
obj[key] = sourceObj[key]
}
})
return obj
}
var obj1 = {
name: 'obj1',
info: {
sex: 'male',
age: 18
},
undefined: undefined,
func: function () { console.log('I am a simple function') },
exp: new RegExp('\\w+'),
createTime: new Date(),
[Symbol('foo')]: 'symbol'
}
// 循环引用
// obj1.circularReference = obj1
// 深拷贝,完全独立的两份数据,修改原对象引用类型的值,对拷贝出来的对象没影响
var copyObj1 = deepCopy1(obj1)
console.log('copyObj1-origin', obj1)
obj1.info.age = 16
console.log('copyObj1-copy', copyObj1)
copyObj1.func()
复制代码
拷贝效果比一把梭好一些,起码保留了undefined属性值。
但是问题还很多,需要继续完善。
deepCopy1方法存在的问题有:
1、会忽略 symbol
2、不能正确处理new Date()(变成空对象)
3、不能处理正则(变成空对象)
4、不能解决循环引用的对象(会导致堆栈溢出)
解决时间对象、正则表拷贝
/**
* 深拷贝2(解决时间对象、正则表达式拷贝问题)
* @param0 sourceObj 要复制的对象
* @returns {Object} 返回深拷贝的对象
*/
function deepCopy2(sourceObj) {
var obj = {}
if (typeof sourceObj !== 'object') {
return sourceObj
}
Object.keys(sourceObj).forEach(key => {
// 引用类型,递归
if (typeof sourceObj[key] === 'object') {
if (sourceObj[key].constructor.name === 'Date') {
obj[key] = sourceObj[key]
} else if (sourceObj[key].constructor.name === 'RegExp') {
var regexp = sourceObj[key]
var reFlags = /\w*$/ // 提取正则的标识位
// var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)) // lodash的做法(保留原型链上的内容?)
var result = new RegExp(regexp.source, reFlags.exec(regexp)) // 我的做法
result.lastIndex = regexp.lastIndex // 上一次匹配执行到的位置,默认为0
obj[key] = result
} else {
obj[key] = deepCopy2(sourceObj[key])
}
} else {
obj[key] = sourceObj[key]
}
})
return obj
}
var obj2 = {
name: 'obj2',
info: {
sex: 'male',
age: 18
},
undefined: undefined,
func: function () { console.log('I am a simple function') },
exp: new RegExp('\[0-9\]'),
createTime: new Date(),
[Symbol('foo')]: 'symbol'
}
// 循环引用
// obj2.circularReference = obj2
var copyObj2 = deepCopy2(obj2)
console.log('copyObj2-origin', obj2)
console.log('copyObj2-copy', copyObj2)
复制代码
控制台输出如下(成功拷贝时间和正则表达式):
deepCopy2方法存在的问题有:
1、会忽略 symbol
2、不能解决循环引用的对象(会导致堆栈溢出)
解决循环引用问题
deepCopy0
、deepCopy1
、deepCopy2
方法,遇到循环引用会直接爆栈。解决循环引用的办法是,利用WeakMap
来缓存遍历过的对象,如果存在就直接使用存在的对象,防止进入死循环。当然也可以用数组或者Map缓存。
思考题:为什么是WeakMap
? 答案在这里
/**
* 深拷贝3(解决循环引用问题)
* @param0 sourceObj 要复制的对象
* @param1 hash 哈希表(非必填)
* @returns {Object} 返回深拷贝的对象
*/
function deepCopy3(sourceObj, hash) {
if (typeof sourceObj !== 'object') {
return sourceObj
}
var obj = {}
var hash = hash || new WeakMap() // 使用哈希表存储遍历过的对象
// 查找hash表中是否存在相同的值
if (hash.has(sourceObj)) {
return hash.get(sourceObj)
}
// 缓存当前的数据对象
hash.set(sourceObj, obj)
Object.keys(sourceObj).forEach(key => {
// 引用类型,递归
if (typeof sourceObj[key] === 'object') {
if (sourceObj[key].constructor.name === 'Date') {
obj[key] = sourceObj[key]
} else if (sourceObj[key].constructor.name === 'RegExp') {
var regexp = sourceObj[key]
var reFlags = /\w*$/ // 提取正则的标识位
var result = new RegExp(regexp.source, reFlags.exec(regexp)) // 我的做法
// var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)) // lodash的做法
result.lastIndex = regexp.lastIndex // 上一次匹配执行到的位置,默认为0
obj[key] = result
} else {
obj[key] = deepCopy3(sourceObj[key], hash) // 新增代码,传入hash表
}
} else {
obj[key] = sourceObj[key]
}
})
return obj
}
var obj3 = {
name: 'obj3',
info: {
sex: 'male',
age: 18
},
undefined: undefined,
func: function () { console.log('I am a simple function') },
exp: new RegExp('\[0-9\]'),
createTime: new Date(),
[Symbol('foo')]: 'symbol'
}
// 循环引用
obj3.circularReference = obj3
var copyObj3 = deepCopy3(obj3)
console.log('copyObj3-origin', obj3)
console.log('copyObj3-copy', copyObj3)
复制代码
效果如下,circularReference是循环引用字段,没有进入死循环,且完成了拷贝工作。
思考题:对属性circularReference的拷贝是深拷贝还是浅拷贝?为什么?
deepCopy3方法存在的问题有:
1、会忽略 symbol
2、不能拷贝数组
解决Symbol和数组拷贝问题
Symbol
是ES6新的基本数据类型,任意两个Symbol值都不相等。Symbol的出现是为了解决对象属性覆盖问题(对象合并时,同名属性会被后来者覆盖)。
/**
* 深拷贝4(Symbol拷贝、数组拷贝)
* @param0 sourceObj 要复制的对象
* @param1 hash 哈希表(非必填)
* @returns {Object} 返回深拷贝的对象
*/
function deepCopy4(sourceObj, hash) {
if (typeof sourceObj !== 'object') {
return sourceObj
}
var obj = Array.isArray(sourceObj) ? [] : {}
var hash = hash || new WeakMap() // 使用哈希表存储遍历过的对象
// 查找hash表中是否存在相同的值
if (hash.has(sourceObj)) {
return hash.get(sourceObj)
}
// 缓存当前的数据对象
hash.set(sourceObj, obj)
// 新增代码,遍历symbols
var objSymbols = Object.getOwnPropertySymbols(sourceObj)// 获取所有Symbol的key
if (objSymbols.length) {
objSymbols.forEach(symKey => {
if (typeof objSymbols[symKey] === 'object') {
obj[symKey] = deepCopy4(sourceObj[symKey], hash)
} else {
obj[symKey] = sourceObj[symKey]
}
})
}
for (var key in sourceObj) {
if (sourceObj.hasOwnProperty(key)) {
if (typeof sourceObj[key] === 'object') {
if (sourceObj[key].constructor.name === 'Date') {
obj[key] = sourceObj[key]
} else if (sourceObj[key].constructor.name === 'RegExp') {
var regexp = sourceObj[key]
var reFlags = /\w*$/ // 提取正则的标识位
var result = new RegExp(regexp.source, reFlags.exec(regexp)) // 我的做法
// var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)) // lodash的做法
result.lastIndex = regexp.lastIndex // 上一次匹配执行到的位置,默认为0
obj[key] = result
} else {
// 引用类型,递归
obj[key] = deepCopy4(sourceObj[key], hash) // 新增代码,传入hash表
}
} else {
obj[key] = sourceObj[key]
}
}
}
return obj
}
var obj4 = {
name: 'obj4',
info: {
sex: 'male',
age: 18
},
undefined: undefined,
func: function () { console.log('I am a simple function') },
exp: new RegExp('\[0-9\]'),
createTime: new Date(),
[Symbol('foo')]: new Date(),
[Symbol('bar')]: 'symbol bar',
[Symbol('bar')]: new RegExp('\[a-z\]'),
arr: [1, 2, 3],
fetchData: ["2021-08-12", "2021-08-12"]
}
var arr4 = [
obj4,
111,
[222, 888],
[{ fetchData: ["2021-08-12", "2021-08-12"] }]
]
// 循环引用
obj4.circularReference = obj4
var copyObj4 = deepCopy4(obj4)
var copyArr4 = deepCopy4(arr4)
console.log('copyObj4-copy', copyObj4)
obj4.info.age = 100
obj4.arr[2] = 666
console.log('copyObj4-origin', obj4)
console.log('copyArr4-copy', copyArr4)
arr4[3][0].a = 'changed to bbb'
arr4[2][1] = 'change to 666'
console.log('copyArr4-origin', arr4)
复制代码
结果如下,Symbol类型的属性可以拷贝了:
数组也可以拷贝了:
deepCopy4存在的问题:
1、对引用类型的判断不够严谨;
2、采用递归,没有考虑爆栈问题。
解决递归爆栈问题(成品)
大家都知道,浏览器可以使用的内存受到操作系统的限制。如果需要深拷贝的对象层级太深(例如:10000级别的深度),就会导致内存溢出。所以我们要改用循环的方式,循环的好处是,拷贝过程中内存会得到释放,每一次循环结束都会执行出栈操作,这样一来占用的内存就不会一直增加。
思考题:代码中的continue
可否修改为break
?为什么?
/**
* 深拷贝5(改用循环,解决爆栈问题,并优化对象的判断方式)
* @param0 sourceObj 要复制的对象
* @param1 hash 哈希表(非必填)
* @returns {Object} 返回深拷贝的对象
*/
function deepCopy5(sourceObj, hash) {
// 优化代码,优化引用类型判断
if (!isObject(sourceObj)) {
return sourceObj
}
// 新增代码,兼容数组类型
var root = Array.isArray(sourceObj) ? [] : {}
var uniqueList = [] // 使用数组缓存
var loopList = [{
parent: root,
key: undefined,
data: sourceObj
}]
while (loopList.length) {
var node = loopList.pop() // 出栈
var key = node.key
var data = node.data
var parent = node.parent
// 初始化赋值目标,key为undefined则拷贝到父元素,否则拷贝到子元素
let res = parent
if (typeof key !== 'undefined') {
res = parent[key] = Array.isArray(data) ? [] : {}
}
console.log('uniqueList', uniqueList)
// debugger
// 数据已经存在
let uniqueData = find(uniqueList, data);
console.log('uniqueData',uniqueData)
if (uniqueData) {
parent[key] = uniqueData.target;
continue; // 中断本次循环,不能用break,否则会无法拷贝其他的对象({})类型呢
}
// 数据不存在
// 保存源数据,在拷贝数据中对应的引用
uniqueList.push({
source: data,
target: res
});
// 遍历symbols
var objSymbols = Object.getOwnPropertySymbols(data)// 获取所有Symbol的key
if (objSymbols.length) {
objSymbols.forEach(symKey => {
if (typeof objSymbols[symKey] === 'object') {
loopList.push({
parent: res,
key: key,
data: res[symKey]
})
} else {
res[symKey] = data[symKey]
}
})
}
for (var key in data) {
if (data.hasOwnProperty(key)) {
let tempObj = data[key]
if (isObject(tempObj)) {
if (tempObj.constructor.name === 'Date') {
loopList.push({
parent: res,
key: key,
data: new Date(tempObj)
})
} else if (tempObj.constructor.name === 'RegExp') {
loopList.push({
parent: res,
key: key,
data: copyRegExp(tempObj)
})
} else {
loopList.push({
parent: res,
key: key,
data: tempObj
})
}
} else {
res[key] = tempObj
}
}
}
}
return root
}
/**
* 判断输入是否为对象类型(数组也是对象)
* @param {any} obj
* @returns Boolean 返回true或false
*/
function isObject(obj) {
return Object.prototype.toString.call(obj) === '[object Object]' || Object.prototype.toString.call(obj) === '[object Array]'
}
/**
* 正则拷贝
* @param {RegExp} regexp
* @returns 拷贝后的正则
*/
function copyRegExp(regexp) {
var reFlags = /\w*$/ // 提取正则的标识位
var result = new RegExp(regexp.source, reFlags.exec(regexp)) // 我的做法
// var result = new regexp.constructor(regexp.source, reFlags.exec(regexp)) // lodash的做法
result.lastIndex = regexp.lastIndex // 上一次匹配执行到的位置,默认为0
return result
}
/**
* 数组查找
* @param {原数组} arr
* @param {要查找的目标} item
* @returns 返回找到的数据,找不到则null
*/
function find(arr, item) {
for (let i = 0; i < arr.length; i++) {
if (arr[i].source === item) {
return arr[i];
}
}
return null;
}
var obj5 = {
name: 'obj5',
info: {
sex: 'male',
age: 18
},
undefined: undefined,
func: function () { console.log('I am a simple function') },
exp: new RegExp('\[0-9\]'),
createTime: new Date(),
[Symbol('foo')]: new Date(),
[Symbol('bar')]: 'symbol bar',
[Symbol('bar')]: new RegExp('\[a-z\]'),
}
// 循环引用
obj5.circularRef = obj5
// 对象拷贝测试
var copyObj5 = deepCopy5(obj5)
console.log('copyObj5-copy', copyObj5)
copyObj5.info.age = 66
copyObj5.createTime = new Date('2021-08-10 18:16:40')
console.log('copyObj5-origin', obj5)
var arr5 = [
{
name: 'obj5',
info: {
sex: 'male',
age: 18
},
undefined: undefined,
func: function () { console.log('I am a simple function') },
exp: new RegExp('\[0-9\]'),
createTime: new Date(),
[Symbol('foo')]: new Date(),
[Symbol('bar')]: 'symbol bar',
[Symbol('bar')]: new RegExp('\[a-z\]'),
},
111,
[1, 2]
]
// 数组拷贝测试
var copyArr5 = deepCopy5(arr5)
console.log('copyArr5-copy', copyArr5)
copyArr5[0].info.age = 100
copyArr5[2][0] = 666
console.log('copyArr5-origin', arr5)
复制代码
对象深拷贝拷贝测试结果,修改引用类型的值不会互相影响,循环引用也不会有问题:
数组深拷贝测试结果,修改引用类型的值不会互相影响:
还存在的问题
目前没有实现的功能有:Map、Set、Weakmap、Weakset等新增数据类型,还有Function的拷贝,原型链的拷贝等等。
当然如果你能确保项目中没有这些数据类型,那么deepCopy5
基本可以满足要求。所谓优化无止境,如果发现错漏或者有优化的建议,欢迎在评论区指出,如果觉得文章对你有帮助,就大方的点个赞吧(^_^)
参考文献: