likes
comments
collection
share

JS深拷贝进阶指南

作者站长头像
站长
· 阅读数 13

前言

通过本文可以学习到深拷贝的多种写法的实现思路与性能差异

首先,我们要理解什么是深拷贝,以及为什么要实现深拷贝

深拷贝是什么

通俗来讲,深拷贝就是深层的拷贝一个变量值。

为什么要实现深拷贝

因为在拷贝引用值时,由于复制一个变量只是将其指向要复制变量的引用内存地址,他们并没有完全的断开,而使用就可以实现深拷贝将其完全拷贝为两个单独的存在,指向不同的内存地址。

如何实现深拷贝

一行实现

let deepClone = JSON.parse(JSON.stringify(obj))

这种是最简单的实现方法,但缺点是无法拷贝 Date()或是RegExp()。  

简单实现

function deepClone(obj) {
    // 判断是否是对象
    if (typeof obj !== 'object') return obj
    // 判断是否是数组 如果是数组就返回一个新数组 否则返回一个新对象
    var newObj = obj instanceof Array ? [] : {};
    // 遍历obj
    for (var key in obj) {
        // 将key值拷贝,再层层递进拷贝对象的值
        newObj[key] = deepClone(obj[key]);
    }
    // 返回最终拷贝完的值
    return newObj;
}

对于普通的值(如数值、字符串、布尔值)和常见的引用类型(如对象和数组),这个写法完全够用。

但因为少了对 Date()  和  RegExp() 这些引用类型的特殊处理,这个写法一样不够完备。

普通版

function deepClone(origin, target) {
  // 判断target是否传入,如果未传入则创建一个{}
  let tar = target || {}; 
  // 遍历origin对象
  for (var key in origin) {
    // 判断是否origin的自有属性
    if (origin.hasOwnProperty(key)) {  
      // 如果值是对象并且不为null,递归调用
      if (typeof origin[key] === 'object' && origin[key] !== null) {  
        // 根据值是否为数组创建初始化对象或数组
        tar[key] = Array.isArray(origin[key]) ? [] : {};
        // 递归调用 
        deepClone(origin[key], tar[key]);
      } else {  
        // 如果是简单类型,直接复制值
        tar[key] = origin[key];
      }
    }
  }
  // 返回最终拷贝完的值
  return tar;
}

这个深拷贝方法通过判断属性的值类型,实现了对 对象数组 以及 DateRegExp 等引用类型对象的递归拷贝,同时也考虑了拷贝基本类型值的情况,能够满足大多数场景的要求。

循环引用版

什么是深拷贝中的循环引用?

上面的案例,可以应对一般场景。

但是对于有两个对象相互拷贝的场景,会导致循环的无限递归,造成死循环!

Uncaught RangeError: Maximum call stack size exceeded

场景:

JS深拷贝进阶指南

如何解决无限递归的问题?

首先我们要了解 WeakMap: WeakMap 的键名所引用的对象都是弱引用,不会被垃圾回收机制考虑,所以当对象只被WeakMap引用时,其所占用的内存会被垃圾回收。

而通过 WeakMap 记录已经拷贝过的对象,能防止循环引用导致的无限递归。

代码

实现简述:利用 WeakMap() 在属性遍历完绑定,并在每次循环时获取当前键名,如果存在则返回数据,不存在则拷贝。

function deepClone(origin, hashMap = new WeakMap()) {
    // 判断是否是对象
    if (origin == undefined || typeof origin !== 'object') return origin;
    // 判断是否是Date/RegExp类型
    if (origin instanceof Date) return new Date(origin);
    if (origin instanceof RegExp) return new RegExp(origin);

    // 判断是否已经克隆过此对象, 如果是直接返回
    const hashKey = hashMap.get(origin);
    if (hashKey) return hashKey;

    // *利用原型构造器获取新的对象 如: [], {}
    const target = new origin.constructor();
    // 将对象存入map
    hashMap.set(origin, target);
    // 循环遍历当前层数据
    for (let k in origin) {
        // 判断当前属性是否为引用类型
        if (origin.hasOwnProperty(k)) {
            target[k] = deepClone(origin[k], hashMap);
        }
    }
    return target;
}

我们再来看一下使用最新版后的两个对象互相拷贝:

JS深拷贝进阶指南

可以看到,通过使用 WeakMap 记录已经拷贝的对象,有效防止循环引用导致的栈溢出错误。

进阶版 - 2023/5/9 更新

上一次写了一个简单的循环引用深拷贝版本,在评论区有网友提到可以使用 structuredClone 的思想进行优化👍。基于此迭代了一个具有更丰富类型支持的进阶深拷贝版本。

这一版多添加了8个类型检测,Funciton、Set、Map、WeakSet、WeakMap、Symbol、Uint8Array、BigInt。

function deepClone(origin, hashMap = new WeakMap()) {
    ...
    
    // 检测Function类型(函数)
    // 截取通过将函数转换字符串,再截取函数内字符串的操作来进行深拷贝
    if (origin instanceof Function) {
        let str = origin.toString();
        let subStr = str.subString(str.indexOf('{'), 1, str.lastIndexOf('}'));
        return new Function(subStr);
    }
    
    // 检测Date类型(时间)
    if (origin instanceof Date) return new Date(origin.getTime());
    // 检测RegExp类型(正则)
    if (origin instanceof RegExp) return new RegExp(origin);
    // 检测Set类型(集合)
    if (origin instanceof Set) return new Set([...origin]);
    // 检测Map类型(哈希)
    if (origin instanceof Map) return new Map([...origin]);
    // 检测WeakSet类型(弱集合)
    if (origin instanceof Set) return new WeakSet([...origin]);
    // 检测WeakMap类型(弱哈希)
    if (origin instanceof Map) return new WeakMap([...origin]);
    // 检测Symbol类型(符号)
    if (origin instanceof Symbol) return new Symbol(origin.description);
    // 检测Uint8Array类型(二进制)
    if (origin instanceof Uint8Array) return new Uint8Array(origin);
    // 检测BigInt(特大数字)-> 虽然不是引用类型,但为了完备性,还是加上了
    if (typeof origin === 'bigint') return new BigInt(origin);
    ...
}

并且优化了hashMap的判断;调研测试了先has的性能比先get好。 has(O(1)) < get(O(n))

    // 去除旧代码
-    const hashKey = hashMap.get(origin);
-    if (hashKey) return hashKey;

    // 采用 weakmap 自带的has检测
+    if (hashMap.has(origin)) return hashMap.get(origin);

代码

更改后完整进阶版如下:

function deepClone(origin, hashMap = new WeakMap()) {
    // 判断是否是对象
    if (origin == undefined || typeof origin !== 'object') return origin;

    // 检测Function类型(函数)
    // 截取通过将函数转换字符串,再截取函数内字符串的操作来进行深拷贝
    if (origin instanceof Function) {
        let str = origin.toString();
        let subStr = str.subString(str.indexOf('{'), 1, str.lastIndexOf('}'));
        return new Function(subStr);
    }

    // 检测Date类型(时间)
    if (origin instanceof Date) return new Date(origin.getTime());
    // 检测RegExp类型(正则)
    if (origin instanceof RegExp) return new RegExp(origin);
    // 检测Set类型(集合)
    if (origin instanceof Set) return new Set([...origin]);
    // 检测Map类型(哈希)
    if (origin instanceof Map) return new Map([...origin]);
    // 检测WeakSet类型(弱集合)
    if (origin instanceof Set) return new WeakSet([...origin]);
    // 检测WeakMap类型(弱哈希)
    if (origin instanceof Map) return new WeakMap([...origin]);
    // 检测Symbol类型(符号)
    if (origin instanceof Symbol) return new Symbol(origin.description);
    // 检测Uint8Array类型(二进制)
    if (origin instanceof Uint8Array) return new Uint8Array(origin);
    // 检测BigInt(特大数字)-> 虽然不是引用类型,但为了完备性,还是加上了
    if (typeof origin === 'bigint') return new BigInt(origin);

    // 这条优化了,先has性能比先get较好。 has(O(1)) < get(O(n))
    if (hashMap.has(origin)) return hashMap.get(origin);

    // 从原型上复制一个值
    // *:利用原型构造器获取新的对象 如: [], {}
    const target = new origin.constructor();
    // 将数据存入map
    hashMap.set(origin, target);
    // 循环遍历当前层数据
    for (let k in origin) {
        // 判断当前属性是否为引用类型
        if (origin.hasOwnProperty(k)) {
            target[k] = deepClone(origin[k], hashMap);
        }
    }
    return target;
}

测试一下

测试数据如下,可以自行拷贝检测:

const complexObj = {
    name: 'cloneMan',
    age: 99,
    address: {
        province: 'Guangdong',
        city: 'Guangzhou',
    },
    hobbies: ['music', { hobbyName: 'reading' }, new Uint8Array([1, 2, 3])],
    friends: new Set(['tom', 'jerry']),
    weakSet: new WeakSet([{ keyName: 'ws1' }, { keyName: 'ws2' }]),
    weakMapData: new WeakMap([[{ keyName: 'wm1' }, { keyName: 'wm2' }]]),
    mapData: new Map([
        ['key1', 'value1'],
        ['key2', 'value2'],
    ]),
    dates: [new Date(2020, 0, 1), new Date(2020, 0, 2)],
    reg: /[a-z]+/g,
    fn: function () {
        console.log('fn');
    },
    sym: Symbol('symbol key'),
    bigN: 123n,
};

接着是测试代码,如下所示:

// 深深的拷贝一下
const cloneObj = deepClone(complexObj);

// 修改区
cloneObj.bigN = 224n;
cloneObj.friends.add('Vito');
cloneObj.dates[0] = cloneObj.dates[0].getDate();
cloneObj.hobbies[2].set([101], 2);
cloneObj.mapData.set('key3', 'value33');
cloneObj.weakMapData.set({ keyName: 'ws3' });
cloneObj.sym = Symbol('new symbol key');

// 对比打印区
console.log(cloneObj === complexObj);
console.log(complexObj);
console.log(cloneObj);

从打印结果可以看出,我们成功实现了具备更多类型检测的深拷贝版本 JS深拷贝进阶指南

这是一个功能较丰富的深拷贝实现,但对于某些复杂场景仍需进一步优化。例如WebWorker中的MessageChannel、Promise对象,Proxy兼容等等...

我会在未来进一步迭代,以实现一个更加完备与高性能的深拷贝✅。同时,也十分欢迎小伙伴们在评论区一起交流;我们共同学习,共同进步!

ps: 对于想学习更深层的深拷贝通用方案的同学,可以看看lodash的版本

总结

深拷贝可以完全拷贝一个对象,生成两个独立的且相互不影响的对象。

明白各种深拷贝实现的思路和性能差异,可以在不同场景选用最优的方案。