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

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

概念剖析

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

js 复制代码
const originObj = {
    name: 'Ken',
    age: 18,
    childObj: {...}
}

这是它在内存中的存储方式: 我们再定义一个 copyObj, 并把 originObj 赋值给它:

js 复制代码
const copyObj = originObj;

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

实现浅拷贝

遍历

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

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

Object.assign()

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

js 复制代码
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' }

展开运算符

js 复制代码
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(),它们返回的就是一份数组的浅拷贝。

js 复制代码
let originArr = []
let shallowCopyArr1 = originArr.concat()
let shallowCopyArr2 = originArr.slice()

实现深拷贝

JSON.parse(JSON.stringify(Obj))

js 复制代码
let newObj = JSON.parse(JSON.stringify(someobj)); 

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

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

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

js 复制代码
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 :这是一个数组,包含了所有你想要转移而不是克隆的对象。转移的对象在原始对象中将不再可用❗仅对可转移对象生效,下面是一个文档里的示例。
js 复制代码
// 创建一个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 虽然对这些问题做了处理,但我们不用关心具体的实现。很显然,这两者都不会是面试官询问的重点😂。

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

js 复制代码
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 中会发现它已经被存入了,此时直接返回该对象即可。

js 复制代码
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

相关推荐
F-2H37 分钟前
C语言:指针4(常量指针和指针常量及动态内存分配)
java·linux·c语言·开发语言·前端·c++
gqkmiss1 小时前
Chrome 浏览器插件获取网页 iframe 中的 window 对象
前端·chrome·iframe·postmessage·chrome 插件
m0_748247553 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php
m0_748255024 小时前
前端常用算法集合
前端·算法
真的很上进4 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web130933203984 小时前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
NiNg_1_2345 小时前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
测试老哥5 小时前
外包干了两年,技术退步明显。。。。
自动化测试·软件测试·python·功能测试·测试工具·面试·职场和发展
如若1235 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~6 小时前
npm error code ETIMEDOUT
前端·npm·node.js