引言
说起 浅拷贝
和 深拷贝
可以说是面试中经常碰到的经典问题, 并且在实际项目开发过程中, 也常常会因为数据拷贝问题, 导致一些隐藏的 BUG
在 javascript
中有很多方法能够复制对象, 但是如果你对数据拷贝不是很了解, 在复制对象时就会很容易掉进陷阱里, 那么我们怎样才能正确地复制一个对象呢, 本文将会慢慢进行揭秘
一、前置知识
本节将对 JS
中数据存储方式进行简单介绍, 这里提到的是目前网上普遍的一个看法, 实际上 JS
中数据到底如何存储还是存在争议的,这里就不作深究, 后续如果有机会再进行详细的讲解。
1.1 数据分类
如下图所示, JS
中数据类型大体可划分为, 基本数据
和 引用数据
两大类
1.2「基本数据」存储方式
在 JS
中 基本数据
是存储在 栈内存(Stack Memory)
中, 它们的值是直接存储在变量访问的位置
那么什么是 栈内存
呢? 它是一种计算机内存中划分出来的一块 连续
的 存储区域
, 它的主要特点是 先进后出
当我们创建一个 基本数据
的变量时, 因为它占用空间小、大小固定, 所以会在 栈内存
中分配一个固定大小的空间来存储这个值, 当这个变量不再被使用时, 它所占用的空间会被自动释放, 因此 基本数据
的赋值和拷贝操作非常快速和高效
创建值: 下面是演示代码以及
栈内存
信息展示, 代码中创建了3
个变量, 对应的栈内存
中也开辟了3
块空间用于存储数据
const name = 'moyuanjun'
const age = 18
const address = '杭州'
修改值: 直接根据变量找到
栈内存
中对应值进行修改即可, 下面是演示代码以及对应的栈内存
修改前后的的变更
const name = 'moyuanjun'
let age = 18
const address = '杭州'
// 修改值
age = 20
复制值: 新开辟一个空间, 根据变量找到
栈内存
中对应值, 拷贝一份新的值, 下面是演示代码以及对应的栈内存
信息, 代码中对age
进行了拷贝, 同时修改了age
, 会发现只有变量age
发生变更, 因为age
和age2
的存储是独立的两个空间
const name = 'moyuanjun'
let age = 18
const address = '杭州'
// 拷贝一份
const age2 = age
// 修改值
age = 20
1.3「引用数据」存储方式
在 JS
中 引用数据
是存储在 堆内存(Heap Memory)
中的, 因为它们的大小是不确定的, 对象的属性和方法可能会动态增加或删除
那么什么是 堆内存
呢? 它是一种计算机内存中划分出来的一块 非连续
的 存储区域
, 它的特点是可以动态分配和释放内存空间, 但它需要手动管理内存空间
当我们创建一个 引用数据
, 会在 堆内存
中分配一个内存空间用来存储对象的所有属性和方法, 然后在 栈内存
中创建一个指向该内存空间的 指针
, 这个指针存储在变量访问的位置, 当这个变量不再被使用时,栈内存
中的指针被销毁, 但 堆内存
中的对象空间不会自动释放, 需要手动调用 垃圾回收机制
来释放这些空间
如下代码声明了两个
基本数据
分别是name
和age
, 它们将被直接存储在栈内存
中, 同时还声明了引用数据
user
, 它会被分两部分进行存储, 实体部分会被存储在堆内存
中, 在栈内存
中将存储着实体
的地址
const name = 'moyuanjun'
const age = 18
const user = { age: 18 }
上面例子中, 当我们访问 引用数据
时, 会先查找 栈内存
找到实体在 堆内存
中的地址, 取得地址后在 堆内存
中获得实体
修改变量为
基本数据
: 当我们修改变量的值时, 实际上是修改了栈内存
中的引用地址
, 下面是演示代码、以及对应内存修改前后的状态
const name = 'moyuanjun'
const age = 18
let user = { age: 18 }
user = 'lh'
修改变量(重新赋一个对象): 当我们修改变量的值时, 实际上是修改了
栈内存
中的引用地址
, 下面是演示代码、以及对应内存修改前后的状态
const name = 'moyuanjun'
const age = 18
let user = { age: 18 }
uuser = { name: 'lh' }
修改对象属性: 先从
栈内存
找到实体的引用地址
, 然后再根据引用地址
找到实例, 再对实体
进行修改, 下面是演示代码、以及修改前后内存状态
const name = 'moyuanjun'
const age = 18
const user = { age: 18 }
user.age = 20
user.name = 'lh'
注意: 在 JS
中对于 原始类型
的拷贝是直接复制数据的, 并没有深浅拷贝的区别, 我们讨论的深浅拷贝都只针对 引用数据
二、赋值
首先我们需要先区分一下赋值操作和拷贝的区别,
赋值
是将一个变量A
赋给另一个变量B
- 对于
基本数据
来说, 就是完全的复制了一份新值进行赋值- 对于引用数据则是将
A
的引用地址
拷贝了一份给了B
, 但他们公用的是一个实体
下面是 基本数据
、和 引用数据
赋值的演示代码、以及赋值后内存的情况, 代码中将 基本数据
赋给了对象的属性, 作为属性值进行使用
const age =18
const name = 'moyuanjun'
const user1 = { name, age }
const user2 = user1
赋值, 在我理解上也算是
拷贝
, 是对变量值的拷贝; 但我们今天要讲的是对引用数据
的拷贝, 在这儿就有了浅拷贝
和深拷贝
的区分了
三、深拷贝
深拷贝: 创建一个 新对象
, 拷贝对象的所有属性, 如果属性是 基本数据
, 拷贝的就是 基本数据
的值; 如果是 引用数据
, 则需要重新分配一块内存, 拷贝该 引用数据
的所有属性, 然后将 引用地址
赋值给对应的属性, 如果该 引用数据
中某个属性也是 引用数据
则需要继续一层层递归拷贝……
简单来说:
深拷贝
就是完整的拷贝了一份一模一样结构的数据, 拷贝后的数据和源数据是没有任何关联的, 修改原数据不会修改到拷贝后的数据
3.1 手写深拷贝
通过手写一个
深拷贝
方法, 来更深入了解深拷贝
, 总体思路如下:
- 目标类型判断, 如果是非
引用数据
则直接返回- 针对特殊的
引用数据
进行单独处理- 判断当前拷贝的目标数据, 是否已经拷贝过, 如果拷贝过则返回上次拷贝的数据: 目的是解决
共同引用
、循环引用
等问题- 新建一个对象
- 循环对象的所有属性, 如果属性值是个
基本数据
, 则直接返回该值, 如果属性值是个引用数据
, 则需要递归调用(新建对象、拷贝属性……)
// map 用于记录出现过的对象, 解决循环引用
const deepClone = (target, map = new WeakMap()) => {
// 1. 对于基本数据类型(string、number、boolean……), 直接返回
if (typeof target !== 'object' || target === null) {
return target
}
// 2. 函数 正则 日期 MAP Set: 执行对应构造题, 返回新的对象
const constructor = target.constructor
if (/^(Function|RegExp|Date|Map|Set)$/i.test(constructor.name)) {
return new constructor(target)
}
// 3. 解决 共同引用 循环引用等问题
// 借用 `WeakMap` 来记录每次复制过的对象, 在递归过程中, 如果遇到已经复制过的对象, 则直接使用上次拷贝的对象, 不重新拷贝
if (map.get(target)) {
return map.get(target)
}
// 4. 创建新对象
const cloneTarget = Array.isArray(target) ? [] : {}
map.set(target, cloneTarget)
// 5. 循环 + 递归处理
Object.keys(target).forEach(key => {
cloneTarget[key] = deepClone(target[key], map);
})
// 6. 返回最终结果
return cloneTarget
}
3.2 JSON.parse(JSON.stringify())
这里利用 JSON.stringify
将对象转成 JSON
字符串, 再用 JSON.parse
把字符串解析成对象, 如此一来一去就能够实现 引用数据
的一个深拷贝
const obj = {
age: 18,
name: 'moyuanjun',
}
const res = JSON.parse(JSON.stringify(obj))
注意该方法的 6
个局限性: 因为 JSON
不是 JS
独有的数据格式, 所以 JSON.stringify
需要抹平和其他语言的差异
NaN
Infinity
-Infinity
会被序列化为null
Symbol
undefined
function
会被忽略(对应属性会丢失)Date
将得到的是一个字符串- 拷贝
RegExp
Error
对象,得到的是空对象{}
const obj = {
num1: NaN,
num2: Infinity,
num3: -Infinity,
symbol: Symbol('xxx'),
name: undefined,
add: function(){},
date: new Date(),
reg: /a/ig,
error: new Error('错误信息')
}
console.log(JSON.parse(JSON.stringify(obj)))
// 打印结果
// {
// num1: null,
// num2: null,
// num3: null,
// date: '2023-03-03T03:40:38.594Z',
// reg: {},
// error: {}
// }
- 多个属性如果复用同一个
引用数据
A
时, 拷贝的结果和原数据结构不一致(会完整拷贝多个引用数据
A
), 如下代码所示: 对象obj
中base
和children
指向同一个对象, 但是JSON.parse(JSON.stringify())
复制出来的对象res
中base
和children
指向了不同的对象, 也就是说拷贝后的res
对象和原对象obj
数据结构不一致
const base = {
name: '张三',
age: 18,
}
const obj = {
base,
children: base
}
const res = JSON.parse(JSON.stringify(obj))
// 原对象, obj.base obj.children 指向同一个对象
obj.base.name = '李四'
console.log(obj.base === obj.children) // true
console.log(obj.children.name) // 李四
// 拷贝后, res.base res.children 指向了不同对象, 拷贝了两个(数据结构被改了)
res.base.name = '李四'
console.log(res.base === res.children) // false
console.log(res.children.name) // 张三
下图是对象
obj
和拷贝后对象res
的内存结构图
- 在存在
循环引用
的对象中使用将会报错
使用 JSON.stringify()
序列化循环引用的对象, 将会抛出错误
const base = {
name: '张三',
age: 18,
}
base.base = base
// TypeError: Converting circular structure to JSON
const res = JSON.parse(JSON.stringify(base))
更对细节可参考 MDN
3.3 使用 structuredClone
structuredClone
是一个新的 API
可用于对数据进行 深拷贝
, 同时还支持循环引用
const base = {
name: '张三',
age: 18,
}
const obj = { base }
obj.obj = obj
const res = structuredClone(obj)
注意: 使用 structuredClone
进行拷贝, 如果有个属性值是个函数, 方法会抛出错误
// DOMException [DataCloneError]: () => {} could not be cloned.
const res = structuredClone({
add: () => {}
})
有关
structuredClone
更多信息查看 MDN
3.4 使用第三方库
可以使用一些第三方库提供的工具方法来实现拷贝, 比如: lodash
中的 cloneDeep
方法
const base = {
name: '张三',
age: 18,
}
const obj = { base }
obj.obj = obj
const res = _.cloneDeep(obj)
四、浅拷贝
浅拷贝: 会新建一个对象, 拷贝对象的所有属性值, 对于 基本数据
来说就是拷贝一份对应的值, 但是对于 引用数据
则是拷贝一份 引用数据
的引用地址
4.1 手写浅拷贝
通过手写一个浅拷贝方法, 来更深入了解
浅拷贝
, 总体思路如下:
- 如果拷贝对象是个
基本数据
, 则直接返回该值- 新建一个对象
- 循环对象的所有属性, 并拷贝属性值, 如果该属性是
引用s数据
拷贝的则是数据的引用地址
const clone = (target) => {
// 1. 对于基本数据类型(string、number、boolean……), 直接返回
if (typeof target !== 'object' || target === null) {
return target
}
// 2. 创建新对象
const cloneTarget = Array.isArray(target) ? [] : {}
// 3. 循环 + 递归处理
Object.keys(target).forEach(key => {
cloneTarget[key] = target[key];
})
return cloneTarget
}
const res= clone({ name: 1, user: { age: 18 } })
4.2 Object.assign()
Object.assign(target, ...sources)
方法将 sources
中所有的源对象的可枚举属性复制到目标对象 target
中, 最后返回修改后的 target
对象
const address = ['杭州']
const base1 = {
age: 18,
name: 'lh',
}
const base2 = {
age: 20,
address,
name: 'moyuanjun',
}
// 将 base1 base2 中的属性添加到, 对象 {} 中
// base1 base2 存在相同属性, 会被 base2 的覆盖掉
const res = Object.assign({}, base1, base2)
res.address === address // true
res.address === base2.address // true
关于
Object.assign()
更多细节参考 MDN
4.3 展开运算符 ...
展开运算符 ...
, 可以数组或对象在语法层面展开, 从而实现数组或对象的一个浅拷贝
const address = ['杭州']
const base1 = {
age: 18,
name: 'lh',
}
const base2 = {
age: 20,
address,
name: 'moyuanjun',
}
const res = { ...base1, ...base2 }
res.address === address // true
res.address === base2.address // true
关于
展开运算符
更多细节参考 MDN
4.4 数组方法
对于数组可以使用, 数组的一些方法进行拷贝, 比如: Array.prototype.concat()
Array.prototype.slice()
Array.from
等方法, 它们的特点都是不改变原数组、同时返回一个新的数组
const base = {
age: 18,
name: 'lh',
}
const arr = [1, 'moyuanjun', base]
arr.concat([])
arr.slice()
Array.from(arr)
4.5 第三方库
可以使用一些第三方库提供的工具方法来实现拷贝, 比如: lodash
中的 clone
方法
const base = {
name: '张三',
age: 18,
}
const obj = {
base,
address: ['杭州'],
}
const res = _.clone(obj)
五、是浅拷贝还是深拷贝
如下代码: 当一个 引用数据
中所有属性都是 基本数据
, 那么对它使用上文提到的浅拷贝方法对它进行了拷贝
const obj = {
age: 18,
name: 'lh',
address: '杭州',
}
const res = {...obj}
请问上面例子是 浅拷贝
还是 深拷贝
呢? 这里个人看法是 深拷贝
, 因为从结果来看 obj
和 res
从内存、数据结构上来看是两个完全独立、毫不相干的, 并且对 obj
进行操作也都不会影响到 res
(当然你如果操作 Object.prototype
那另当别论)
六、总结
操作 | 基本类型 | 引用数据 | 结果 |
---|---|---|---|
赋值 | 重新创建值 | 复制引用地址 | 具有相同的变量、属性值 |
深拷贝 | 重新创建值 | 递归遍历, 拷贝所以属性 | 拷贝对象和源对象完成隔离 |
浅拷贝 | 重新创建值 | 复制引用地址 | 拷贝对象和源对象存在共同的引用对象 |