面试官:请你聊聊浅拷贝与深拷贝

571 阅读8分钟

引言

最近在面试过程中,被面试官问到了关于JavaScript中的深浅拷贝,虽然回答出来了,但是感觉回答的过程不够流畅,所以还是觉着需要对这一方面的知识重新进行一个系统的总结。

在日常开发中,我们经常需要在不同对象之间复制数据,特别是当我们需要创建一个与原对象相似但又独立的新对象时。这种操作被称为“拷贝”。拷贝可以帮助我们避免直接修改原始数据,保持数据的一致性和安全性。然而,拷贝的复杂性在于如何处理引用类型的数据。在 JavaScript 中,拷贝主要分为两种类型:浅拷贝和深拷贝。

浅拷贝与深拷贝的概念

浅拷贝的特点

浅拷贝指的是在复制一个对象时,仅复制对象的第一层属性,而不会递归复制嵌套的引用类型属性。这样一来,浅拷贝后的新对象中的引用类型属性仍然指向与原对象相同的内存地址。因此,如果修改新对象的引用类型属性,原对象也会随之改变。

深拷贝的特点

深拷贝与浅拷贝不同,它会递归地复制对象的每一层属性,包括嵌套的引用类型属性。通过深拷贝生成的新对象是完全独立的,修改新对象中的任何属性,都不会对原对象产生任何影响。深拷贝通常用于需要对对象进行深层次修改,且不希望原对象被意外修改的场景中

浅拷贝和深拷贝的区别在于它们对引用类型的处理方式。浅拷贝仅复制对象的第一层属性,若属性是引用类型(如对象、数组),新对象与原对象将共享同一块内存地址。因此,修改新对象的引用类型属性会直接影响到原对象。深拷贝则会递归地复制对象的所有层级属性,确保新对象是原对象的完全独立副本,修改新对象的任何属性都不会对原对象产生影响。

浅拷贝的实现方法

在 JavaScript 中,浅拷贝是一种常用的操作,尤其在处理对象或数组时。以下是一些实现浅拷贝的方法,分为普通对象的浅拷贝和数组的浅拷贝。

普通对象的浅拷贝

  1. 使用 Object.create(obj) 创建新对象并继承原型链
  • Object.create(obj) 方法创建一个新的对象,并将其原型设置为指定的对象 obj。这种方法不会复制 obj 的属性,而是让新对象继承 obj 的原型链,因此仅适用于需要复制对象原型的情况。
const original = { a: 1, b: { c: 2 } };
const copy = Object.create(original);
console.log(copy.a); // 1
console.log(copy.b.c); // 2
  1. 使用解构语法 {...obj},仅复制第一层属性
  • 使用对象解构语法 {...obj} 可以快速地创建对象的浅拷贝。它会复制对象的所有可枚举属性到新对象中,但只复制第一层属性。如果对象中包含嵌套的引用类型(如数组、对象),这些引用类型属性仍然会指向原来的内存地址。
const original = { a: 1, b: { c: 2 } };
const copy = { ...original };
copy.b.c = 3;
console.log(original.b.c); // 3
  1. 使用 Object.assign({}, obj),也是浅层拷贝
  • Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它也是一种浅拷贝,只复制对象的第一层属性,嵌套的引用类型属性仍然会指向原来的内存地址。
const original = { a: 1, b: { c: 2 } };
const copy = Object.assign({}, original);
copy.b.c = 3;
console.log(original.b.c); // 3

数组的浅拷贝

  1. 使用 [].concat(arr) 方法进行数组拷贝
  • concat() 方法通常用于合并数组,但当它只传入一个数组时,它会返回一个新数组,浅拷贝原数组的所有元素。这些元素的引用类型属性仍然会指向原数组的内存地址。
const original = [1, 2, { a: 3 }];
const copy = [].concat(original);
copy[2].a = 4;
console.log(original[2].a); // 4
  1. 使用 arr.slice() 方法获取新的数组副本
  • slice() 方法返回数组的一个新副本,包含从原数组中选择的元素。它不会改变原数组,只是对元素的浅拷贝。
const original = [1, 2, { a: 3 }];
const copy = original.slice();
  1. 使用数组解构 [...arr]
  • 数组解构语法 [...arr] 通过展开运算符将原数组的元素复制到一个新数组中。这个方法也只进行浅拷贝,嵌套的引用类型元素会继续指向原数组中的内存地址。
const original = [1, 2, { a: 3 }];
const copy = [...original];
copy[2].a = 4;
console.log(original[2].a); // 4
  1. 使用 arr.toReversed().reverse()
  • arr.toReversed() 是 ES2022 引入的新方法,它返回数组的一个新副本,数组中的元素顺序与原数组相同。这种方法创建了一个新数组副本,适合用于需要保持顺序但避免原数组被修改的场景。注意,这里使用的 reverse() 只是为了确保返回的新数组与原数组不同,以便验证拷贝的有效性。
const original = [1, 2, 3];
const copy = original.toReversed().reverse();
console.log(copy); // [3, 2, 1]
console.log(original); // [1, 2, 3]

深拷贝的实现方法

深拷贝用于创建一个对象的完整副本,其中所有的层级和属性都会被复制,而不会引用原对象的内存地址。以下是几种常见的深拷贝方法及其特点:

  1. 使用 JSON.parse(JSON.stringify(obj))
  • 该方法通过先将对象转化为 JSON 字符串,然后再将其解析回对象来实现深拷贝。这种方法简单易用,适合大多数场景。

  • 优点

    • 简单易用,代码少且可读性高。
    • 能够处理大多数简单对象的深拷贝需求。
  • 缺点

    • 无法处理 bigIntSymbolundefined、函数、Date 对象等特殊类型。
    • 无法处理循环引用,会导致 TypeError
const original = { a: 1, b: { c: 2 }, d: [3] };
const copy = JSON.parse(JSON.stringify(original));
copy.b.c = 4;
console.log(original.b.c); // 2
  1. 使用 structuredClone(obj)
  • structuredClone 是 ES2021 引入的新方法,用于克隆对象,包括嵌套的对象和数组。它能够处理更多的数据类型,例如 DateMap

  • 优点

    • 支持大部分数据类型,包括 DateMapSetArrayBuffer 等。
    • 能够处理循环引用。
  • 缺点

    • 不支持拷贝函数、bigInt 和某些特殊对象。
const original = { a: 1, b: { c: new Date() }, d: [3] };
const copy = structuredClone(original);
copy.b.c.setFullYear(2025);
console.log(original.b.c.getFullYear()); // 原始年份
  1. 递归拷贝
  • 手写深拷贝函数,通过递归地遍历对象的每一层属性,创建新对象并复制每一层属性,确保完全独立。

  • 优点

    • 可以自定义深拷贝的逻辑,处理各种复杂数据类型。
    • 适合需要完全控制拷贝行为的场景。
  • 缺点

    • 实现复杂,需要处理循环引用和各种特殊类型的情况。
  1. 使用 new MessageChannel() 进行深拷贝
  • MessageChannel 是一个用于在不同线程或 Worker 之间传递消息的 API。利用它可以在主线程和 Worker 之间进行数据克隆,适用于需要跨线程或进程拷贝数据的高级场景。

  • 优点

    • 能处理大多数复杂数据类型,包括DateMapSet,并支持处理循环引用。
    • 可以用于跨线程数据传递时的深拷贝。
  • 缺点

    • 并不是常见的深拷贝手段,适合高级应用场景。
    • 不能拷贝函数和 bigInt 类型的值。

手写浅拷贝与深拷贝函数

浅拷贝函数

function shallowCopy(obj) {
    // 如果 obj 不是对象或为null 直接返回 obj
    if (typeof obj !== 'object' && obj === null) return obj;

    //  确保处理对象和数组
    const newObj = Array.isArray(obj) ? [] : {};

    // 复制对象的第一层属性
    for (let key in obj) {
        // 判断属性是否是显式的
        if (obj.hasOwnProperty(key)) {
            newObj[key] = obj[key];
        }
    }

    return newObj;
}

// 测试浅拷贝
const obj = { a: 1, b: { c: 2 }, d: [3] };
const newObj = shallowCopy(obj);
newObj.b.c = 4;
console.log(obj.b.c); // 4,因为 b 是引用类型,复制的是引用

深拷贝

手写一:

function deepClone(obj, map = new WeakMap()) {
    // 处理非对象或null
    if (typeof obj !== 'object' || obj === null) return obj;

    // 处理循环引用
    if (map.has(obj)) {
        return map.get(obj);
    }

    // 创建一个新的对象或数组
    const newObject = Array.isArray(obj) ? [] : {};

    // 将当前对象记录到map中
    map.set(obj, newObject);

    // 遍历对象属性
    for (const key in obj) {
        if (obj.hasOwnProperty(key)) {
            if (typeof obj[key] === 'object' && obj[key] !== null) {
                newObject[key] = deepClone(obj[key], map);
            } else {
                newObject[key] = obj[key];
            }
        }
    }
    return newObject;
}

const obj = {
    a: 1,
    b: {
        n: 2
    },
};
obj.c = obj;
const newObj = deepClone(obj);
newObj.b.n = 20;
console.log(obj, '---'); // <ref *1> { a: 1, b: { n: 2 }, c: [Circular *1] } ---

如果遇到循环引用,WeakMap 确保不会重复克隆同一对象,从而避免堆栈溢出。

手写二:

const obj = {
    a: 1,
    b: {
        n: 2
    }
}

function deepClone(obj) {
    return new Promise((resolve) => {
        const { port1, port2 } = new MessageChannel();
        port1.postMessage(obj)
        port2.onmessage = (msg) => {
            resolve(msg.data)
        }
    })
}

deepClone(obj).then(res => {
    obj.b.n = 20
    console.log(res, '---');
})

MessageChannel 包含两个端口 port1port2,它们可以在不同的上下文之间发送和接收消息。数据通过 postMessage 方法从一个端口传递到另一个端口时,浏览器会自动克隆这个数据,这个克隆操作就是深拷贝。

这种拷贝方法使用的是 MessageChannel,这是 Web API 中的一部分,通常用于在不同的浏览器上下文之间传递数据,比如 Web Workers、Iframes 等。然而,由于 MessageChannel 实现了数据的克隆机制,它也可以用于深拷贝对象。

总结

这次总结了多种实现浅拷贝和深拷贝的方法,包括使用 JavaScript 原生的 Object.create、解构语法、Object.assign 等实现浅拷贝,以及通过递归、JSON.parse(JSON.stringify)structuredCloneMessageChannel 等实现深拷贝。特别是通过递归实现深拷贝时,我理解了如何处理循环引用问题,以及为什么需要使用 WeakMap 来避免无限递归。

此外,我也明白了在实际应用中,根据不同的需求和性能考虑,选择合适的拷贝方法非常重要。对于简单场景,浅拷贝或 JSON.parse(JSON.stringify) 可能已足够,而对于复杂对象,递归深拷贝或 structuredClone 更为合适。