前言
在日常的开发过程中,我们常常会写一些看似简单的代码,却可能产生出人意料的结果,与我们设想的逻辑相悖。这种差异往往隐藏在细节之中,而深浅拷贝的误用可能导致代码的行为出现不可预期的变化。
考虑以下两个例子:
// 查找矩阵中每一层是否有小于target的值
const example = (grid:number[][],target:number):number[][] =>{
const ans:number[][] = []
const n = grid.length
const result:number[] = []
for(let i = 0; i < n; i++){
for(let j = 0; j < n; j++){
grid[i][j] <= target && result.push(grid[i][j])
}
ans.push(result)
result.length = 0 // 清空之前的内容
}
return ans
}
console.log(example([[1,2,3],[4,5,6],[7,8,9]],5)) // 输出:[[],[],[]]
在上面的例子中,我们通过对二维数组进行处理,得到每一层小于目标值的元素,但最终输出的却是空数组,违背了我们的预期。
// 对象赋值操作
const person = {
name:'Tom',
age:18,
info:{
father:'Francis',
friends:[{
name:'Bob',
age:19,
hobbies:['play','eat']
}]
}
}
const person2 = person
person2.age = 20
console.log(person,person2)
看到控制台打印的输出,尽管我们只修改了person2
中的age
,但却意外地影响了person
对象,这不符合代码逻辑。
这两个例子看似简单,然而它们潜藏的问题却可能引发一系列的错误。那接下来我们将深入去理解JavaScript
中的深拷贝和浅拷贝。
JavaScript 数据类型
JavaScript
中的数据类型分为基本数据类型和引用数据类型。
基本数据类型包括string
、number
、boolean
、undefined
、null
、symbol
、bigint
等,这些数据类型的值存储在栈内存中。在进行赋值操作时,会重新开辟内存空间,并将数据压入栈中。
引用数据类型包括 Array
、Object
、Function
(通过new
创建的各种实例)等,它们是复杂的数据结构,可以包含多个值或方法。这些引用数据类型的值存储在堆内存中,而在栈内存中存放着对应的内存地址,指向这个引用数据类型对象在堆中的位置。
const num = 10
const person = {
age:18,
name:'Tom'
}
let num2 = num
num2 = 20
const person2 = person
person2.age = 20
上面这个例子的数据在内存中的存储大致如下图所示:
对于基本数据类型,在赋值操作时,会重新开辟内存空间,将新数据压入栈中。而对于引用数据类型,赋值操作实际上是将新变量指向原有对象在堆内存中的地址。这导致多个变量指向同一个堆内存地址,即共享相同的对象。
当我们对其中一个变量所指向的对象进行修改时,由于它们共享同一内存地址,所有指向该地址的变量都会受到影响。有时候,我们需要在进行操作时创建一份新的数据副本,以确保不影响原始数据,这时候就需要进行拷贝。
在 JavaScript 中,拷贝分为两种主要类型:浅拷贝和深拷贝。接下来,我们将深入理解这两种拷贝机制。
浅拷贝
浅拷贝在拷贝过程中精确复制了原数据属性的值,但对于原数据中的引用类型数据,浅拷贝仅复制了它们的引用地址,而非实际数据。
实现浅拷贝的方法包括:
- 展开运算符
- Object.assign
- Object.create
- Array.from
- Array.prototype.concat
- Array.prototype.slice
上面只是列举了常见的浅拷贝方式,下面将演示一个浅拷贝的例子:
const person = {
name:'Tom',
age:18,
info:{
father:'Francis',
friends:[{
name:'Bob',
age:19,
hobbies:['play','eat']
}]
}
}
const person2 = Object.assign({},person)
person2.age = 20
person2.info = {}
console.log(person,person2)
上面的例子中,我们使用了Object.assign
进行浅拷贝。由于浅拷贝只拷贝一层,所以修改person2
中的age
,并没有影响到person
。当执行person2.info = {}
时,person2
的info
属性被赋值为空对象,这是一个新的对象引用,与person
的info
引用不再相同。对person2.info
的修改不会影响到person.info
。
那修改更深层的数据,结果又会变成什么呢?
const person2 = Object.assign({},person)
person2.age = 20
person2.info.father = 'Younger'
console.log(person,person2)
上面的例子表明,更深层次的数据中,浅拷贝依然保持对原数据中复杂数据内存地址的引用,对拷贝数据的修改将影响原数据。
浅拷贝只拷贝原数据的表层,对于更深层次的数据结构,仍然共享相同的内存地址。
在理解浅拷贝的原理之后,我们来手写一个简单的浅拷贝,代码如下:
function shadowClone<T extends Record<string, any>>(obj: T): T {
if (typeof obj !== 'object') return obj;
const target: T = {} as T;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
target[key] = obj[key];
}
}
return target;
}
function shadowClone<T>(obj: T): T {
// 处理基本数据类型
if(obj === null || typeof obj !== 'object') return obj
// 处理数组类型
if (Array.isArray(obj)) return [...obj] as T
// 处理对象和 new 创建的
if (obj.constructor) {
const target = new (obj.constructor as { new(): T })();
for (const [key, value] of Object.entries(obj)) {
if (obj.hasOwnProperty(key)) Object.assign(target,{[key]:value})
}
return target
}
}
浅拷贝仅在数据的表层进行复制,对于更深层次的结构,我们需要借助深拷贝。
深拷贝
深拷贝会逐一复制对象上的所有属性,确保新旧对象的同名属性虽然相同但在不同的内存地址。这样,修改一个对象的属性不会影响另一个对象的对应属性。
实现深拷贝的方法包括:
- 递归通过递归地遍历对象的每个属性,进行复制。
- JSON.stringify通过
JSON.stringify
将对象转换为字符串,再通过JSON.parse
解析,实现深层次的复制。 - deepClone第三方库中
Lodash
提供了deepClone
方法,它不仅处理对象的拷贝,还考虑了一些特殊情况,提供了更为全面和高效的深拷贝功能。
下面将演示使用上述方法实现深拷贝:
const person = {
name:'Tom',
age:18,
info:{
father:'Francis',
friends:[{
name:'Bob',
age:19,
hobbies:['play','eat']
}]
}
}
const person2 = JSON.parse(JSON.stringify(person))
person2.age = 20
person2.info.father = 'Younger'
person2.info.friends[0].age = 22
console.log(person,person2)
注意:
JSON.stringify
只能处理可序列化的对象,对于一些不可序列化的对象,如函数
、Symbol
、DOM
、undefined
等无法处理。
接下来,我们使用递归的方式来实现深拷贝。通过深度优先遍历对象,我们对每一层进行逐一拷贝。在每一层中,使用一个变量来存储拷贝的结果,若为基本数据类型则直接存入,若为引用类型则继续递归查询。递归结束后,将每层的拷贝结果合并,形成一个全新的对象。
function deepClone<T extends Record<string|number,any>| []>(obj:T):T{
// 处理基本数据类型
if(obj === null || typeof obj !== 'object') return obj
const isArray = Array.isArray(obj)
const target: T = isArray ? [] as T : {} as T
// 处理数组
if(isArray){
for(const item of obj){
// 基本数据类型
if(item === null || typeof item !== 'object'){
target.push(item)
}else{
target.push(deepClone(item))
}
}
return target
}else{
// 处理对象
for(const [key,value] of Object.entries(obj)){
if(typeof value !== 'object'){
Object.assign(target,{[key]:value})
}else{
Object.assign(target,{[key]:deepClone(value)})
}
}
}
return target
}
上述方法基本实现了对象的深拷贝,但对于Map
、Set
以及通过new
创建的实例尚未进行处理。下面进一步优化,考虑对这些特殊类型的处理。
function deepClone<T>(obj: T): T {
// 处理基本数据类型和不可变对象
if (obj === null || typeof obj !== 'object') return obj;
// 处理数组
if (Array.isArray(obj)) return obj.map(item => deepClone(item)) as T;
// 处理Date
if(obj instanceof Date ) return new Date(obj) as unknown as T
// 处理RegExp
if(obj instanceof RegExp) return new RegExp(obj) as unknown as T
// 处理 Map
if (obj instanceof Map) {
const cloneMap = new Map();
obj.forEach((key: unknown, value: unknown) => {
cloneMap.set(key, deepClone(value));
});
return cloneMap as unknown as T;
}
// 处理 Set
if (obj instanceof Set) {
const cloneSet = new Set();
obj.forEach((value: unknown) => {
cloneSet.add(deepClone(value));
});
return cloneSet as unknown as T;
}
// 处理对象和new构造的实例
if(obj.constructor) {
const target = new (obj.constructor as { new(): T })();
for (const [key, value] of Object.entries(obj)) {
Object.assign(target, { [key]: deepClone(value) });
}
return target;
}
}
在深拷贝的时候,还应该考虑一种特殊的情况:如果A对象中包含一个B对象,而B对象又引用A对象,会导致递归进入无限循环,最终导致调用栈溢出。
interface Type {
age: number;
y: Type | null;
}
const x: Type = {
age:18,
y: null,
};
x.y = x;
const x1 = deepClone(x);
为了解决这种情况,可以利用Map
或者WeakMap
来记录已经遍历过的对象,从而避免循环引用所带来的问题。
function deepClone<T>(obj: T, map = new WeakMap()): T {
// 处理基本数据类型和不可变对象
if (obj === null || typeof obj !== 'object') return obj;
// 处理Date
if(obj instanceof Date ) return new Date(obj) as unknown as T
// 处理RegExp
if(obj instanceof RegExp) return new RegExp(obj) as unknown as T
// 判断是否遍历过这个对象(处理循环引用的问题)
if(map.has(obj)) return map.get(obj)
// 处理数组
if (Array.isArray(obj)){
const cloneArray = obj.map(item => deepClone(item, map)) as T;
map.set(obj, cloneArray);
return cloneArray;
}
// 处理 Map
if (obj instanceof Map) {
const cloneMap = new Map();
map.set(obj, cloneMap);
obj.forEach((key: unknown, value: unknown) => {
cloneMap.set(key, deepClone(value,map));
});
return cloneMap as unknown as T;
}
// 处理 Set
if (obj instanceof Set) {
const cloneSet = new Set();
map.set(obj,cloneSet)
obj.forEach((value: unknown) => {
cloneSet.add(deepClone(value,map));
});
return cloneSet as unknown as T;
}
// 处理对象和new构造的实例
if(obj.constructor) {
const target = new (obj.constructor as { new(): T })();
map.set(obj, target);
for (const [key, value] of Object.entries(obj)) {
Object.assign(target, { [key]: deepClone(value,map) });
}
return target;
}
}
使用WeakMap
而不是Map
主要是为了避免在垃圾回收时可能导致的内存泄漏问题。WeakMap
的键是弱引用,不会阻止键对象被垃圾回收。
在深拷贝中,我们不希望因为缓存而导致整个对象无法被垃圾回收。如果使用Map
,即使深拷贝对象不再被使用,仍然会保持对原对象的引用,这可能导致内存泄漏。
使用WeakMap
,当键对象不再被其他地方引用时,它会被垃圾回收,WeakMap
中的对应项也会被自动清除。这样可以确保在拷贝对象不再被使用时,不会影响到原对象的垃圾回收。
上述使用递归的方式进行深拷贝,可能还有没考虑到的情况。在实际开发中,一般使用第三方库中Lodash
提供了deepClone
方法。
总结
浅拷贝能够准确地复制原数据,对于基本类型数据,它会拷贝其具体的值。而对于引用类型的数据,浅拷贝只复制其内存地址,新旧对象共享相同的地址。
因此,如果在新对象中改变引用类型数据的值,旧对象也会受到影响。但如果重新赋值引用类型的数据,会创建一个新的地址,不影响旧对象。浅拷贝拥有较高的速度,并且在处理简单数据类型时更加方便和高效。
深拷贝创建一个全新的对象,对于引用类型的对象,它会生成一个新的内存地址,不会对原对象产生影响。
因此,深拷贝适用于保证数据独立性和安全性的场景,相比于浅拷贝效率较低,在使用递归进行深拷贝时,还需要注意处理循环引用的情况,以避免栈溢出情况。