如何实现一个深拷贝?

赋值、浅拷贝


首先, 我们来看一个概念性的问题, 赋值、浅拷贝、深拷贝之间有什么区别?

  1. 赋值:

    const obj = { bar: 'foo' };
    const obj2 = obj;
    obj2.bar = 'baz';
    console.log(obj); //{ bar: 'baz' }
    

    当我们复制引用类型的变量时,实际上复制的是栈中存储的地址,所以复制出来的 obj2 实际上和 obj 指向的堆中同一个对象。因此, 我们改变其中任何一个变量的值,另一个变量都会受到影响,这就是为什么会有深拷贝和浅拷贝的原因。

  2. 浅拷贝:

    const foo = { name: 'foo' };
    const obj = { bar: 'str', baz: foo };
    const obj2 = { ...obj };
    obj2.bar = 'number';
    obj2.baz.name = 'foo2';
    console.log(obj); //{ bar: 'str', baz: { name: 'foo2' } }
    

    创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了这个地址,就会影响到另一个对象。

讲个题外话: 值传递和引用传递

先看两段代码:

function setName(name) {
  name = 'foo';
}

const bar = 'bar';

setName(bar);
console.log(bar); //'bar'

很明显,上面的执行结果是 bar,即函数参数仅仅是被传入变量赋值给了的一个局部变量,改变这个局部变量不会对外部变量产生影响.

function setName(obj) {
  obj.name = 'foo';
}

const obj = { name: 'baz' };

setName(obj);
console.log(obj); //{name: 'foo'; }

上面的代码可能让你产生疑惑,是不是参数是引用类型就是引用传递呢?

当函数参数是引用类型时,我们同样将参数复制了一个副本到局部变量,只不过复制的这个副本是指向堆内存中的地址而已,我们在函数内部对对象的属性进行操作,实际上和外部变量指向堆内存中的值相同,但是这并不代表着引用传递,下面我们再看一段代码:

const obj = {};

function setName(o) {
  o.name = 'bar';
  o = { name: 'baz' };
}
setName(obj);

console.log(obj);

最后 输出为 {name: 'bar'}, 函数参数传递的并不是变量的引用,而是变量拷贝的副本,当变量是原始类型时,这个副本就是值本身,当变量是引用类型时,这个副本是指向堆内存的地址.

深拷贝


回到主题, 来实现我们的深拷贝.

  1. 乞丐版:

    const deepClone = (obj) => JSON.parse(JSON.stringify(obj));
    

    最简易实现方式, 但对于值为 function、undefined、symbol 类型时无法处理

  2. 合格版

    const deepClone = (source) => {
      if (source === null) {
        return null;
      }
      if (source === undefined) {
        return undefined;
      }
    
      if (typeof source === 'object') {
        if (Array.isArray(source)) {
          const clone = [];
          source.forEach((v) => {
            clone.push(deepClone(v));
          });
          return clone;
        }
        const clone = {};
        Object.keys(source).forEach((key) => {
          clone[key] = deepClone(source[key]);
        });
        return clone;
      }
      return source;
    };
    

    能正确的处理 function、undefined、symbol, array 等类型, 但是当存在循环引用是, 会进入死循环.

  3. 较完整版

    const isArray = <T>(source: unknown): source is Array<T> =>
      Array.isArray(source);
    
    const isMap = (source: unknown): source is Map =>
      Object.prototype.toString.call(source) === '[object Map]';
    
    const isSet = <T>(source: unknown): source is Set<T> =>
      Object.prototype.toString.call(source) === '[object Set]';
    
    const isObject = (source: unknown) => {
      const type = typeof source;
      return source !== null && type === 'object';
    };
    
    const initData = (source: any) => {
      const { constructor } = source;
      if (!!constructor) {
        return new constructor();
      }
      return;
    };
    
    const deepClone = (source: unknown, map = new WeakMap()) => {
      if (!isObject(source)) {
        return source;
      }
      const clone = initData(source);
      if (!clone) {
        return source;
      }
      if (!!map.has(source as Object)) {
        return map.get(source as Object);
      }
      map.set(source as Object, clone);
    
      if (isArray<any>(source)) {
        source.forEach((v) => {
          (clone as Array<any>).push(deepClone(v, map));
        });
        return clone;
      }
    
      if (isMap<any, any>(source)) {
        source.forEach((v, key) => {
          (clone as Map<any, any>).set(key, deepClone(v, map));
        });
        return clone;
      }
    
      if (isSet<any>(source)) {
        source.forEach((v) => {
          (clone as Set<any>).add(deepClone(v, map));
        });
        return clone;
      }
    
      Object.keys(source as any).forEach((v) => {
        clone[v] = deepClone((source as any)[v], map);
      });
      return clone;
    };
    
    export default deepClone;
    

解决循环引用问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题。

这个存储空间,需要可以存储 key-value 形式的数据,且 key 可以是一个引用类型,我们可以选择 WeakMap 这种数据结构: 

  • 检查 map 中有无克隆过的对象
  • 有 - 直接返回
  • 没有 - 将当前对象作为 key,克隆对象作为 value 进行存储
  • 继续克隆

当然, 这里只考虑了 ArrayObjectMapSet 四种可继续遍历类型, 还有其他类型, 这里不继续研究了...

MapWeakMap


MapES6 中新增数据类型, 主要为了解决传统 Object 只能使用字符串作为键值, 并且不能够很方便的获取键的长度.

WeakMapMap 的区别有两点:

  1. WeakMap 只接受对象作为键名(null 除外),不接受其他类型的值作为键名。

  2. WeakMap 的键名所指向的对象,不计入垃圾回收机制。

利用第 2 点便不用担心当我们要拷贝的对象非常庞大时,使用 Map 会对内存造成非常大的额外消耗,而且我们需要手动清除 Map 的属性才能释放这块内存,而 WeakMap 会帮我们巧妙化解这个问题。

测试代码地址: https://github.com/LZS911/vue3-ts-vite2-ly-component/tree/master/src/utils/deepClone