理解JavaScript中的深浅拷贝

692 阅读9分钟

前言

在日常的开发过程中,我们常常会写一些看似简单的代码,却可能产生出人意料的结果,与我们设想的逻辑相悖。这种差异往往隐藏在细节之中,而深浅拷贝的误用可能导致代码的行为出现不可预期的变化。

考虑以下两个例子:

// 查找矩阵中每一层是否有小于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)

image.png

看到控制台打印的输出,尽管我们只修改了person2中的age,但却意外地影响了person对象,这不符合代码逻辑。

这两个例子看似简单,然而它们潜藏的问题却可能引发一系列的错误。那接下来我们将深入去理解JavaScript中的深拷贝浅拷贝

JavaScript 数据类型

JavaScript中的数据类型分为基本数据类型引用数据类型

基本数据类型包括stringnumberbooleanundefinednullsymbolbigint等,这些数据类型的值存储在栈内存中。在进行赋值操作时,会重新开辟内存空间,并将数据压入栈中。

引用数据类型包括 ArrayObjectFunction(通过new创建的各种实例)等,它们是复杂的数据结构,可以包含多个值或方法。这些引用数据类型的值存储在堆内存中,而在栈内存中存放着对应的内存地址,指向这个引用数据类型对象在堆中的位置。

image.png

const num = 10
const person = {
    age:18,
    name:'Tom'
}
let num2 = num
num2 = 20
const person2 = person
person2.age = 20

上面这个例子的数据在内存中的存储大致如下图所示:

image.png

对于基本数据类型,在赋值操作时,会重新开辟内存空间,将新数据压入栈中。而对于引用数据类型,赋值操作实际上是将新变量指向原有对象在堆内存中的地址。这导致多个变量指向同一个堆内存地址,即共享相同的对象。

当我们对其中一个变量所指向的对象进行修改时,由于它们共享同一内存地址,所有指向该地址的变量都会受到影响。有时候,我们需要在进行操作时创建一份新的数据副本,以确保不影响原始数据,这时候就需要进行拷贝。

在 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)

image.png

上面的例子中,我们使用了Object.assign进行浅拷贝。由于浅拷贝只拷贝一层,所以修改person2中的age,并没有影响到person。当执行person2.info = {}时,person2info属性被赋值为空对象,这是一个新的对象引用,与personinfo引用不再相同。对person2.info的修改不会影响到person.info

那修改更深层的数据,结果又会变成什么呢?

const person2 = Object.assign({},person)
person2.age = 20
person2.info.father = 'Younger' 
console.log(person,person2)

image.png

上面的例子表明,更深层次的数据中,浅拷贝依然保持对原数据中复杂数据内存地址的引用,对拷贝数据的修改将影响原数据。

image.png

浅拷贝只拷贝原数据的表层,对于更深层次的数据结构,仍然共享相同的内存地址。

在理解浅拷贝的原理之后,我们来手写一个简单的浅拷贝,代码如下:

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
  }
}

浅拷贝仅在数据的表层进行复制,对于更深层次的结构,我们需要借助深拷贝。

深拷贝

深拷贝会逐一复制对象上的所有属性,确保新旧对象的同名属性虽然相同但在不同的内存地址。这样,修改一个对象的属性不会影响另一个对象的对应属性。

image.png

实现深拷贝的方法包括:

  • 递归通过递归地遍历对象的每个属性,进行复制。
  • 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)

image.png

注意:

JSON.stringify只能处理可序列化的对象,对于一些不可序列化的对象,如函数SymbolDOMundefined等无法处理。

image.png

接下来,我们使用递归的方式来实现深拷贝。通过深度优先遍历对象,我们对每一层进行逐一拷贝。在每一层中,使用一个变量来存储拷贝的结果,若为基本数据类型则直接存入,若为引用类型则继续递归查询。递归结束后,将每层的拷贝结果合并,形成一个全新的对象。

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
}

上述方法基本实现了对象的深拷贝,但对于MapSet以及通过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方法。

总结

浅拷贝能够准确地复制原数据,对于基本类型数据,它会拷贝其具体的值。而对于引用类型的数据,浅拷贝只复制其内存地址,新旧对象共享相同的地址。

因此,如果在新对象中改变引用类型数据的值,旧对象也会受到影响。但如果重新赋值引用类型的数据,会创建一个新的地址,不影响旧对象。浅拷贝拥有较高的速度,并且在处理简单数据类型时更加方便和高效。

深拷贝创建一个全新的对象,对于引用类型的对象,它会生成一个新的内存地址,不会对原对象产生影响。

因此,深拷贝适用于保证数据独立性和安全性的场景,相比于浅拷贝效率较低,在使用递归进行深拷贝时,还需要注意处理循环引用的情况,以避免栈溢出情况。