likes
comments
collection
share

每天搞透一道JS手写题💪「Day6 浅拷贝与深拷贝」

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

深拷贝和浅拷贝可以说是面试中最常见的问题之一,除了直接让你手写深浅拷贝,还有如何比较两个对象完全相同等变体题目。本文将先带读者明确引用赋值、浅拷贝、深拷贝三者的差异,再初步讲解深浅拷贝的多种实现方式。

概念剖析

在讲述如何实现深浅拷贝之前,我们先要搞清楚他们的定义。假如我们有一个原始对象 originObj

const originObj = {
    name: 'Ken',
    age: 18,
    childObj: {...}
}

这是它在内存中的存储方式: 每天搞透一道JS手写题💪「Day6 浅拷贝与深拷贝」 我们再定义一个 copyObj, 并把 originObj 赋值给它:

const copyObj = originObj;

每天搞透一道JS手写题💪「Day6 浅拷贝与深拷贝」 赋值很容易和浅拷贝混淆,赋值是仅限于栈中的操作,而浅拷贝会在堆中开辟空间: 每天搞透一道JS手写题💪「Day6 浅拷贝与深拷贝」 浅拷贝顾名思义,就是拷贝了,但是很浅,只拷贝第一层,如果有子对象就不管了,直接共享原始对象的子对象内存,不会再为子对象去开辟堆内存了。而深拷贝就是无论有多少层子对象,它都会一五一十的拷贝下来: 每天搞透一道JS手写题💪「Day6 浅拷贝与深拷贝」 如果你对基本数据类型和引用数据类型相关知识有了解的话,相信看到这里你已经能理解三者的区别了,可以动手尝试修改这几种拷贝后的对象,对原始对象有什么影响来印证自己的理解。

实现浅拷贝

遍历

遍历需要浅拷贝的对象,将这个对象的属性依次添加到一个新对象上,返回这个浅拷贝出来的新对象。

function clone(target) {
    let cloneTarget = Array.isArray(target) ? [] : {};
    for (const key in target) {
        cloneTarget[key] = target[key];
    }
    return cloneTarget;
};

Object.assign()

Object.assign()  静态方法将一个或者多个源对象中所有可枚举自有属性复制到目标对象,并返回修改后的目标对象。

let originObj = { person: { name: "pony", age: 18 }, company: 'Tencent' };
let shallowCopyObj = Object.assign({}, originObj);
shallowCopyObj.person.name = "Ken";
shallowCopyObj.company = 'Alibaba'
console.log(originObj); // { person: { name: 'Ken', age: 18 }, sports: 'Tencent' }

展开运算符

let originObj = { person: { name: "pony", age: 18 }, company: 'Tencent' };
let shallowCopyObj = { ...originObj };
shallowCopyObj.person.name = "Ken";
shallowCopyObj.company = 'Alibaba'
console.log(originObj); // { person: { name: 'Ken', age: 18 }, sports: 'Tencent' }

数组对象

如果需要进行浅拷贝的对象是一个数组,可以使用一些返回一个新数组的方法,比如Array.prototype.concat()Array.prototype.slice(),它们返回的就是一份数组的浅拷贝。

let originArr = []
let shallowCopyArr1 = originArr.concat()
let shallowCopyArr2 = originArr.slice()

实现深拷贝

JSON.parse(JSON.stringify(Obj))

let newObj = JSON.parse(JSON.stringify(someobj)); 

在《你不知道的JavaScript(上)》里介绍过这种方法,是最简单明了的实现方式。其缺点是不能处理复杂对象,比如函数、日期、正则等,也不能正确处理循环引用。

每天搞透一道JS手写题💪「Day6 浅拷贝与深拷贝」

原生深拷贝的终结者structuredClone()

这是一个 HTML DOM 中提供的 API,几乎能够实现几乎对所有数据类型的深拷贝,但不兼容较老的浏览器和 node 版本,请结合具体情况使用。

structuredClone(value)
structuredClone(value, { transfer })

const original = { name: "MDN" };
original.itself = original;

const clone = structuredClone(original);

console.log(clone !== original); // true 并不指向同一个对象
console.log(clone.name === "MDN"); // true  拥有同样的属性值
console.log(clone.itself === clone); // true 循环引用正常
  1. value:这是你想要克隆的值。

  2. options:这是一个可选的对象,它可以有以下属性:

    • transfer:这是一个数组,包含了所有你想要转移而不是克隆的对象。转移的对象在原始对象中将不再可用❗仅对可转移对象生效,下面是一个文档里的示例。
// 创建一个16MB的Uint8Array
const uInt8Array = new Uint8Array(1024 * 1024 * 16);

// 克隆它并转移其底层资源
const transferred = structuredClone(uInt8Array, { transfer: [uInt8Array.buffer], });

console.log(uInt8Array.byteLength); // 输出:0
console.log(transferred.byteLength); // 输出:16777216

递归遍历

接下来就是重头戏,递归遍历对象实现深拷贝。 JSON.parse 无法处理许多特殊的引用类型,也不能正确的处理循环引用;而 structuredClone API 虽然对这些问题做了处理,但我们不用关心具体的实现。很显然,这两者都不会是面试官询问的重点😂。

我们从浅拷贝出发,来一步一步解决这些问题。首先是对于引用类型的属性,我们需要通过递归遍历,将需要克隆的属性添加到一个新对象上:

function clone(target) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        for (const key in target) {
            cloneTarget[key] = clone(target[key]);
        }
        return cloneTarget;
    } else {
        return target;
    }
};

这个版本的深拷贝已经支持拷贝普通对象和数组了,但是如果对象内存中循环引用,即 target.target = target,很显然我们的递归是无法跳出的,死循环下去最终导致栈内存溢出报错。

解决这个问题我们可以利用 map 来存储当前拷贝对象和属性的 key-value 键值对。比方说首次遇到 target.target 的时候我们会往 map 中存入 target-target, 再往下一层遍历的时候检查 map 中是否已经存在以 target 为键值的对象。如果是循环引用,那么递归的下一层自然还是相同的对象,在 map 中会发现它已经被存入了,此时直接返回该对象即可。

function clone(target, map = new WeakMap()) {
    if (typeof target === 'object') {
        let cloneTarget = Array.isArray(target) ? [] : {};
        if (map.get(target)) {
            return map.get(target);
        }
        map.set(target, cloneTarget);
        for (const key in target) {
            cloneTarget[key] = clone(target[key], map);
        }
        return cloneTarget;
    } else {
        return target;
    }
}

这里选择使用 WeakMap 而不是 Map,主要原因在于他们的内存管理机制不同:

WeakMap的一个重要特性是,如果没有对一个对象的引用,那么这个对象就可以被垃圾回收机制回收,即使这个对象作为一个WeakMap的键。这意味着,WeakMap不会阻止其键被垃圾回收。这对于处理循环引用的问题非常有用,因为你不需要担心创建了不能被垃圾回收的引用。相比之下,只要一个Map存在,它的键就不会被垃圾回收

说老实话,笔者目前对这里的理解也比较浅显,如有疏漏还请指正:

MapWeakMapclone 函数执行完毕后都会释放内存。对于函数内部的局部变量,无论是 Map 还是 WeakMap,它们的生命周期都与函数的执行周期相同。因此,从内存管理的角度来看,它们在函数执行完毕后都会被销毁,不会持续占用内存。

但在 clone 函数执行完毕后,Map 对象所跟踪的键和值(即对象和克隆对象)仍然存在于内存中。对于 WeakMap,在 WeakMap 对象本身被销毁之后,它所跟踪的键值对也会被自动销毁。与 Map 不同,WeakMap 中的键是弱引用的,这意味着当没有其他地方引用键对象时,垃圾回收器会自动回收这些键对象,并自动删除与这些键对象相关联的值。

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

结语

深拷贝还有很多值得探讨的地方,比如遍历时采用哪种循环方式性能最优;对于特殊引用类型的处理;对于类型判断考虑null和函数等等。实际上面试时间有限,了解相关的思路即可,不大可能让现场实现一个非常完备的深拷贝函数,实际开发中可以再按需学习。本专栏面向面试中的JS手写题,笔者本身的水平也有限,之后有机会再继续补充,如果读者仍有兴趣可以看看下面这篇文章: Write a Better Deep Clone Function in JavaScript | by Shuai Li | JavaScript in Plain English