JavaScript 中的深拷贝和浅拷贝有什么区别?请实现一个深拷贝函数。

各位观众老爷,大家好!欢迎来到今天的技术讲座,我是你们的老朋友——代码界的老司机。今天我们要聊聊JavaScript中的深拷贝和浅拷贝,这俩兄弟听起来高深莫测,其实理解起来就像吃冰淇淋一样简单(希望如此!)。准备好了吗?系好安全带,咱们要发车啦!

一、什么是拷贝?为什么要拷贝?

在开始深浅拷贝的“爱恨情仇”之前,我们先来聊聊什么是拷贝。简单来说,拷贝就是把一个东西复制一份。在JavaScript里,这个“东西”通常指的是对象或数组。

为什么要拷贝呢?想象一下,你有一个存着重要数据的对象,你不希望直接修改它,而是想基于它创建一个新的对象,然后在新对象上进行各种操作,这样原始数据就能保持不变了。这时候,你就需要拷贝。

二、浅拷贝:只拷贝一层皮

浅拷贝就像克隆了一只绵羊,虽然外表看起来一样,但内核(内部器官)还是原来的那只绵羊的。换句话说,浅拷贝只复制了对象的顶层属性,如果属性值是原始类型(如数字、字符串、布尔值等),那么就直接复制这些值;如果属性值是引用类型(如对象、数组等),那么就仅仅复制这些引用,而不是复制引用指向的实际对象。

这意味着,如果原始对象和浅拷贝对象共享同一个引用类型的属性,那么修改其中一个对象的这个属性,会影响到另一个对象。

浅拷贝的几种常见方式:

  1. Object.assign()

    这是ES6提供的一个用于对象合并的方法,也可以用来做浅拷贝。

    const obj1 = {
      name: '张三',
      age: 25,
      address: {
        city: '北京',
        street: '长安街'
      }
    };
    
    const obj2 = Object.assign({}, obj1);
    
    obj2.name = '李四';
    obj2.address.city = '上海';
    
    console.log(obj1.name); // 张三 (原始对象的name属性没变)
    console.log(obj1.address.city); // 上海 (原始对象的address.city属性变了!)

    看到了吗?obj2.name的修改没有影响到obj1.name,因为name是原始类型。但是,obj2.address.city的修改影响到了obj1.address.city,因为address是引用类型,obj1obj2address属性指向的是同一个对象。

  2. 展开运算符 (...)

    这也是ES6提供的一个方便的语法糖,可以用来展开对象或数组,从而实现浅拷贝。

    const obj1 = {
      name: '张三',
      age: 25,
      address: {
        city: '北京',
        street: '长安街'
      }
    };
    
    const obj2 = { ...obj1 };
    
    obj2.name = '李四';
    obj2.address.city = '上海';
    
    console.log(obj1.name); // 张三
    console.log(obj1.address.city); // 上海

    效果和Object.assign()一样。

  3. 数组的 slice()concat() 方法

    对于数组,slice()concat() 方法在不传入参数的情况下,可以实现浅拷贝。

    const arr1 = [1, 2, { name: '张三' }];
    const arr2 = arr1.slice(); // 或者 const arr2 = arr1.concat();
    
    arr2[0] = 3;
    arr2[2].name = '李四';
    
    console.log(arr1[0]); // 1
    console.log(arr1[2].name); // 李四

    同样的,修改arr2[0]不会影响到arr1[0],但修改arr2[2].name会影响到arr1[2].name,因为数组中的对象是引用类型。

总结一下浅拷贝的特点:

特性 描述
拷贝深度 只拷贝顶层属性
原始类型 复制值,修改拷贝对象不会影响原始对象
引用类型 复制引用,修改拷贝对象的属性会影响原始对象
适用场景 只需要拷贝顶层属性,不需要深度拷贝,或者明确知道对象中没有嵌套的引用类型时。

三、深拷贝:连皮带肉一起复制

深拷贝就像克隆了一只完整的绵羊,从外表到内核都是全新的,和原来的绵羊没有任何关系。换句话说,深拷贝会递归地复制对象的所有属性,包括嵌套的引用类型,创建一个完全独立的新对象。

这意味着,修改原始对象或深拷贝对象的任何属性,都不会影响到另一个对象。

深拷贝的几种常见方式:

  1. JSON.parse(JSON.stringify(obj))

    这是一种简单粗暴的深拷贝方法,利用了JSON.stringify()将对象序列化成JSON字符串,然后再用JSON.parse()将JSON字符串解析成新的对象。

    const obj1 = {
      name: '张三',
      age: 25,
      address: {
        city: '北京',
        street: '长安街'
      },
      hobbies: ['coding', 'reading'],
      birthday: new Date()
    };
    
    const obj2 = JSON.parse(JSON.stringify(obj1));
    
    obj2.name = '李四';
    obj2.address.city = '上海';
    obj2.hobbies.push('swimming');
    
    console.log(obj1.name); // 张三
    console.log(obj1.address.city); // 北京
    console.log(obj1.hobbies); // ['coding', 'reading']
    console.log(obj1.birthday); // 原来的Date对象
    console.log(typeof obj2.birthday); // string

    看起来很完美,对不对?但是,这种方法有一些局限性:

    • 无法拷贝函数: JSON.stringify() 会忽略函数。
    • 无法拷贝 undefined JSON.stringify() 会将 undefined 转换为 null
    • 无法拷贝 Symbol JSON.stringify() 会忽略 Symbol 类型的属性。
    • 无法拷贝循环引用: 如果对象存在循环引用,会报错。
    • Date 对象会被转换成字符串: JSON.stringify() 会将 Date 对象转换为 ISO 格式的字符串。
    • 无法拷贝 RegExp、Error 等特殊对象。

    所以,这种方法只适用于简单的数据对象,不适用于复杂的对象结构。

  2. 递归实现深拷贝

    这是一种更通用的深拷贝方法,通过递归遍历对象的属性,如果属性值是原始类型,则直接复制;如果属性值是引用类型,则递归调用深拷贝函数,直到所有属性都被复制。

    function deepClone(obj, map = new WeakMap()) {
      // 处理null和undefined
      if (obj === null || obj === undefined) {
        return obj;
      }
    
      // 处理Date
      if (obj instanceof Date) {
        return new Date(obj);
      }
    
      // 处理RegExp
      if (obj instanceof RegExp) {
        return new RegExp(obj);
      }
    
      // 处理循环引用
      if (map.has(obj)) {
        return map.get(obj);
      }
    
      // 处理对象和数组
      if (typeof obj === 'object') {
        const newObj = Array.isArray(obj) ? [] : {};
        map.set(obj, newObj); // 存储已拷贝的对象,用于处理循环引用
    
        for (let key in obj) {
          if (obj.hasOwnProperty(key)) {
            newObj[key] = deepClone(obj[key], map); // 递归调用深拷贝函数
          }
        }
    
        return newObj;
      }
    
      // 处理原始类型
      return obj;
    }
    
    const obj1 = {
      name: '张三',
      age: 25,
      address: {
        city: '北京',
        street: '长安街'
      },
      hobbies: ['coding', 'reading'],
      birthday: new Date(),
      reg: /abc/g,
      func: function() { console.log('hello'); } ,//函数不会被拷贝
      undef: undefined
    };
    
    // 循环引用示例
    obj1.circular = obj1;
    
    const obj2 = deepClone(obj1);
    
    obj2.name = '李四';
    obj2.address.city = '上海';
    obj2.hobbies.push('swimming');
    
    console.log(obj1.name); // 张三
    console.log(obj1.address.city); // 北京
    console.log(obj1.hobbies); // ['coding', 'reading']
    console.log(obj1.birthday); // 原来的Date对象
    console.log(obj2.birthday); // 拷贝后的Date对象
    console.log(obj1.func); // function() { console.log('hello'); }
    console.log(obj2.func); // undefined
    console.log(obj1.circular); // [Circular]
    console.log(obj2.circular); // [Circular]
    

    这个deepClone函数做了以下几件事:

    • 处理 nullundefined 直接返回 nullundefined
    • 处理 Date 对象: 创建一个新的 Date 对象,并复制原始 Date 对象的时间戳。
    • 处理 RegExp 对象: 创建一个新的 RegExp 对象,并复制原始 RegExp 对象的模式和标志。
    • 处理循环引用: 使用 WeakMap 存储已经拷贝过的对象,如果再次遇到相同的对象,则直接返回之前拷贝的对象,避免无限递归。
    • 处理对象和数组: 创建一个新的对象或数组,然后递归地拷贝所有属性。
    • 处理原始类型: 直接返回原始值。

    为什么要用 WeakMap 处理循环引用?

    WeakMap 是一种特殊的 Map,它的键必须是对象。WeakMap 的特点是:

    • 弱引用: 如果一个对象只被 WeakMap 引用,那么当垃圾回收器运行时,这个对象会被回收,WeakMap 中的引用也会被移除。
    • 不可枚举: WeakMap 中的键是不可枚举的,这意味着你无法遍历 WeakMap 中的键。

    使用 WeakMap 可以避免内存泄漏。如果使用普通的 Map,即使原始对象不再使用,Map 中仍然会保留对这个对象的引用,导致垃圾回收器无法回收这个对象,从而造成内存泄漏。

  3. 使用第三方库

    有很多第三方库提供了深拷贝的功能,例如:

    • Lodash: _.cloneDeep()
    • jQuery: $.extend(true, {}, obj)

    这些库通常经过了高度优化,性能更好,也更健壮。

总结一下深拷贝的特点:

特性 描述
拷贝深度 递归拷贝所有属性
原始类型 复制值,修改拷贝对象不会影响原始对象
引用类型 递归复制引用指向的对象,修改拷贝对象的属性不会影响原始对象
适用场景 需要完全复制一个对象,并且不希望修改拷贝对象影响原始对象时。
额外注意 需要处理循环引用,避免内存泄漏。
局限性 无法拷贝函数,无法拷贝 Symbol 类型的属性(需要特殊处理)。

四、深拷贝 vs 浅拷贝:选择困难症怎么办?

对比项 浅拷贝 深拷贝
拷贝深度 仅拷贝顶层属性 递归拷贝所有属性
内存占用 较小 较大
性能 较快 较慢
适用场景 只需要拷贝顶层属性,或者明确知道对象中没有嵌套的引用类型时。 需要完全复制一个对象,并且不希望修改拷贝对象影响原始对象时。
修改影响 修改拷贝对象的引用类型属性会影响原始对象 修改拷贝对象的任何属性都不会影响原始对象
实现方式 Object.assign()、展开运算符 (...)、数组的 slice()concat() 方法等。 JSON.parse(JSON.stringify(obj))、递归实现、使用第三方库(如 Lodash 的 _.cloneDeep())等。
局限性 无法处理嵌套的引用类型 JSON.parse(JSON.stringify(obj)) 无法拷贝函数、undefinedSymbol、循环引用、Date 对象会被转换成字符串等;递归实现需要处理循环引用,避免内存泄漏;某些情况下,可能需要特殊处理一些特殊对象(如 ErrorRegExp 等)。

如何选择?

就像选择冰淇淋口味一样,深拷贝和浅拷贝各有各的优点和缺点。选择哪种方式取决于你的具体需求。

  • 如果你的对象很简单,没有嵌套的引用类型,或者你明确知道只需要拷贝顶层属性,那么浅拷贝就足够了。 比如,你只是想创建一个新的对象,然后修改一些简单的属性,而不需要修改嵌套的对象。

  • 如果你的对象很复杂,包含嵌套的引用类型,并且你希望修改拷贝对象不会影响原始对象,那么就需要深拷贝。 比如,你有一个包含大量数据的对象,你需要在新对象上进行复杂的计算,并且不希望破坏原始数据。

  • 如果你对性能有很高的要求,并且对象结构比较简单,可以考虑使用 JSON.parse(JSON.stringify(obj)),但要注意它的局限性。

  • 如果你需要处理循环引用,或者需要拷贝特殊对象,那么最好使用递归实现深拷贝,或者使用第三方库。

五、总结

深拷贝和浅拷贝是JavaScript中一个重要的概念,理解它们的区别和适用场景,可以帮助你编写更健壮、更可维护的代码。记住,没有银弹,选择哪种方式取决于你的具体需求。

好了,今天的讲座就到这里。希望大家有所收获!如果还有什么疑问,欢迎留言提问。下次再见!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注