JS基础 - 手写实现浅拷贝与深拷贝

0 阅读9分钟

在实际项目开发中,我们有时需要对某些对象进行操作,但又不希望直接操作原对象,以防对其他逻辑产生影响,此时一般会复制一个副本进行操作。

而所谓的“复制”,在编程领域又可分为浅拷贝深拷贝两种方式。

本文将从理解深浅拷贝的含义出发,逐步尝试封装浅拷贝函数和深拷贝函数。

栈内存与堆内存

在聊深浅拷贝之前,先梳理一下 JS 中数据存储的方式。

在 JS 中,存储空间可分为栈内存与堆内存,可以简单理解为:原始值存放在栈内存中,引用值存放在堆内存中。

原始值与栈内存

当我们声明一个变量,赋值为原始值的时候,JS 引擎会开辟一个栈内存空间,把原始值存放在栈内存空间中,并且使变量指向这个栈内存空间的地址。
当我们读取这个变量的时候,其实就是读取这个变量所对应的栈内存中的值。示意图如下所示:

image.png

引用值与堆内存

而对于引用值,当我们声明一个变量为引用值的时候,同样会开辟一个栈内存空间,并且这个变量同样指向栈内存的地址。但是引用值实际上是存放在堆内存中的,而栈内存空间存放的是堆内存的地址。
当我们使用变量读取某个引用值的时候,会首先通过栈内存地址找到栈内存空间,然后通过里面存放的堆内存地址,找到真正存放引用值的堆内存空间,从而读取引用值具体的值,示意图如下所示: image.png

浅拷贝

所谓的浅拷贝,就是把栈内存空间的值直接复制一份,如果存放的是原始值,则毫无疑问地复制它的值。如果存放的是引用值(即堆内存地址),那么也是直接将堆内存地址复制下来。

所以浅拷贝有个显而易见的问题,就是拷贝引用值的时候,只抄下了它的门牌号,并没有对家里的东西进行复制,所以如果在原对象中对引用值进行修改,那么浅拷贝后的对象中所对应的引用值也会受影响(因为浅拷贝只拷贝了门牌号,最终访问的还是同一个房屋)。

理解了浅拷贝的含义后,将尝试手写一个浅拷贝函数,并作出如下约定:

  1. 只对数组和对象进行处理
  2. 只对自身且可枚举的属性进行拷贝
  3. 不处理 symbol 属性
function clone(origin) {
  if (origin instanceof Array) {
    // 处理数组的情况

    // 展示两种简易的方法
    // return origin.slice();
    // return [...origin];

    // 创建一个与原数组同样长度的数组,且全部元素填充为0。
    const result = new Array(origin.length).fill(0);

    origin.forEach((item, index) => {
      result[index] = item;
    });
    return result;
  } else if (Object.prototype.toString.call(origin) === "[object Object]") {
    // 处理对象的情况

    // 展示两种简易的浅拷贝方法
    // return { ...origin }
    // return Object.assign({}, origin)

    const result = {};

    // 遍历自身及原型链上的可枚举属性
    for (let key in origin) {
      // 是自身属性才进行克隆
      if (Object.hasOwn(origin, key)) {
        result[key] = origin[key];
      }
    }

    // 注:以下是对symbol属性进行处理,一般无需使用到,可根据业务进行调整
    // 获取自身所有symbol属性(包括不可枚举的)
    // const symbolKeys = Object.getOwnPropertySymbols(origin);
    // // 判断属性的可枚举性
    // for (let key of symbolKeys) {
    //   const descriptor = Reflect.getOwnPropertyDescriptor(origin, key)
    //   // 是可枚举属性才进行克隆
    //   if (descriptor.enumerable) {
    //     result[key] = origin[key]
    //   }
    // }

    return result;
  }

  // 其他情况如原始值,或Function等内置对象直接返回。
  return origin;
}

// ===================测试用例======================
const obj1 = {
  a: 'hello',
  b: [1, 2, 3]
}
const obj2 = clone(obj1)

console.log(obj2) // { a: 'hello', b: [ 1, 2, 3 ] }

obj1.a = 'hi'
obj1.b[0] = 99

console.log(obj1) // { a: 'hi', b: [ 99, 2, 3 ] }
console.log(obj2) // { a: 'hello', b: [ 99, 2, 3 ] }

从上面的测试用例可以看到,当对象中的属性是引用值时,那么此引用值的修改,会影响到拷贝后的对象,这是浅拷贝的缺点。

深拷贝

为了解决上面提到的浅拷贝对引用值的拷贝的缺陷,就有了深拷贝这一概念。
深拷贝与浅拷贝的差别主要体现在对引用值的处理上。

浅拷贝对引用值进行拷贝的时候,是直接复制栈内存中存储的堆内存的地址(即把引用值的门牌号给抄下来)。
而深拷贝则是会通过门牌号找到对应的房屋,并且进入房屋内,将里面的所有物品一一复制到另一间新的房屋中(当然,如果里面有对其他引用值的引用,那么也是需要对这些引用值进行递归处理)。
所以深拷贝之后,原对象中的引用值的修改,是不会影响到拷贝后的对象,反之亦然。

基础实现

基础逻辑及实现

函数实现逻辑为:

  1. 函数可以接收任何类型的值,并返回其深拷贝的值(传入原始值则直接返回)。
  2. 函数不做拷贝操作,直接返回。
  3. 因 JS 中有非常多的内置对象,所以只对 Array,Object,Date 等常见对象进行拷贝,其他直接返回。
function deepClone(origin) {
  // 原始值直接返回, 函数不作处理,直接返回
  if (origin === null || typeof origin !== "object") return origin;

  // 某些内置对象,这里只举几个常见的进行处理
  if (origin instanceof Date) return new Date(origin);
  if (origin instanceof RegExp) return new RegExp(origin);
  if (origin instanceof Map) return new Map(origin);
  if (origin instanceof Set) return new Set(origin);

  // 处理数组
  if (origin instanceof Array) {
    const result = [];

    origin.forEach((item, index) => {
      // 递归处理:如item是原始值,则直接得到此值。如是引用值,则对此引用值进行递归深拷贝
      result[index] = deepClone(item);
    });
    return result;
  }

  // 处理对象
  if (Object.prototype.toString.call(origin) === "[object Object]") {
    const result = {};

    // 获取对象自身及原型链上的可枚举属性
    for (let key in origin) {
      // 只处理自身属性(除symbol属性以外)
      if (Object.hasOwn(origin, key)) {
        // 递归处理:如item是原始值,则直接得到此值。如是引用值,则对此引用值进行递归深拷贝
        result[key] = deepClone(origin[key]);
      }
    }
    return result;
  }

  // 更多未处理的内置对象,暂不处理,直接返回
  return origin;
}

// ===================测试用例======================
const obj1 = {
  a: 'hello',
  b: {
    b1: [1, 2, 3]
  }
}
const obj2 = deepClone(obj1)

console.log(obj2) // { a: 'hello', b: { b1: [ 1, 2, 3 ] } }

obj1.b.b1[0] = 99

console.log(obj1) // { a: 'hello', b: { b1: [ 99, 2, 3 ] } }
console.log(obj2) // { a: 'hello', b: { b1: [ 1, 2, 3 ] } }

从上面测试用例可知,深拷贝可以解决浅拷贝对引用值拷贝的局限性。

隐患

如果使用下面的测试用例对上述深拷贝函数进行测试,那么程序将会报错

const obj1 = {
  name: 'obj1'
}
const obj2 = {
  name: 'obj2',
  // obj2 引用 obj1
  ref: obj1
}

// obj1循环引用obj2
obj1.ref = obj2

const obj3 = deepClone(obj1) // Uncaught RangeError: Maximum call stack size exceeded

上面代码中,obj1 和 obj2 两个对象,都互相引用了对方,即出现循环引用现象。

如果使用上面的深拷贝函数拷贝循环引用的对象时,就会无限地进行递归拷贝,出现调用栈溢出的报错。

进阶实现

为了解决上述深拷贝函数无法处理循环引用的问题,我们需要对上述函数进行改进。

上面基础函数存在的问题是,不会判断传递进来的引用值,在此之前是否已经处理过,而是当成一个新对象去进行拷贝,这样的话就会有无数个“新对象”需要进行拷贝,也就会出现调用栈溢出的报错。

所以我们的核心思路是:

  1. 创建一个“表”,用于记录每次进行深拷贝的引用值,以及拷贝之后生成的值(注意:记录引用值实际上是记录引用值的堆内存地址,而不是里面的具体内容,判断两个引用值是否是同一个,使用的也是堆内存地址作比较)。
  2. 在对引用值进行拷贝之前,在表中查找此引用值是否已经处理过,是的话直接返回拷贝后的值,否则才进行拷贝处理,并将其记录在表中。

上述所说的表,本文将使用 WeakMap 实现。WeakMap 与 Map 相比,对垃圾回收机制更加友好,用一句话概括它们之间的最大差异就是:

WeakMap 的键名所对应的引用值,它的被引用数不会 +1,当其他地方没有对其进行引用时,垃圾回收机制会将其回收。

具体代码如下所示:

function deepClone(origin, visited = new WeakMap()) {
  // 原始值直接返回, 函数不作处理,直接返回
  if (origin === null || typeof origin !== "object") return origin;

  // 判断此引用值之前是否进行过拷贝,是的话直接返回拷贝后的引用,不再继续递归
  if (visited.has(origin)) return visited.get(origin);

  // 某些内置对象,这里只举几个常见的进行处理
  if (origin instanceof Date) return new Date(origin);
  if (origin instanceof RegExp) return new RegExp(origin);
  if (origin instanceof Map) return new Map(origin);
  if (origin instanceof Set) return new Set(origin);

  // 处理数组
  if (origin instanceof Array) {
    const result = [];
    // 将“原引用:拷贝后的引用”记录下来
    visited.set(origin, result);

    origin.forEach((item, index) => {
      // 递归处理:如item是原始值,则直接得到此值。如是引用值,则对此引用值进行递归深拷贝
      result[index] = deepClone(item, visited);
    });
    return result;
  }

  // 处理对象
  if (Object.prototype.toString.call(origin) === "[object Object]") {
    const result = {};
    // 将“原引用:拷贝后的引用”记录下来
    visited.set(origin, result);

    // 获取对象自身及原型链上的可枚举属性
    for (let key in origin) {
      // 只处理自身属性(除symbol属性以外)
      if (Object.hasOwn(origin, key)) {
        // 递归处理:如item是原始值,则直接得到此值。如是引用值,则对此引用值进行递归深拷贝
        result[key] = deepClone(origin[key], visited);
      }
    }
    return result;
  }

  // 更多未处理的内置对象,暂不处理,直接返回
  return origin;
}

// ===========测试用例============
const obj1 = {
  name: 'obj1'
}
const obj2 = {
  name: 'obj2',
  // obj2 引用 obj1
  ref: obj1
}

// obj1循环引用obj2
obj1.ref = obj2

const obj3 = deepClone(obj1)

console.log(obj3) // <ref *1> { name: 'obj1', ref: { name: 'obj2', ref: [Circular *1] } }

总结

简而言之,浅拷贝和深拷贝的差别就是:是否会对引用值进行递归复制。浅拷贝性能开销小,深拷贝对数据而言更安全,可根据实际需求在二者中进行选择。

而在实现浅拷贝函数和深拷贝函数时,需要注意以下几点:

  1. 判断属性值的类型,采取不同的处理策略。
  2. 注意过滤原型链上的属性。
  3. 实现深拷贝函数时,需要注意循环引用的问题。