浅拷贝与深拷贝

在准备面试的过程中,我发现对于深浅拷贝的细节还是需要捋一遍,写下本文记录思考过程。

  • 什么是深/浅拷贝,跟赋值有何区别?
  • 深/浅拷贝的实现方式有几种?

赋值与深浅拷贝

在了解这几个概念之前,我得先回顾下基本类型与引用类型的存储机制。

基本类型(Primitive Types)

包括:StringNumberBooleanNullUndefinedSymbolBigInt

  • 存储方式:直接存储在栈内存中,变量保存的是实际值

引用类型(Reference Types)

包括:ObjectArrayFunctionDateRegExpMapSet

  • 存储方式:实际数据存储在堆内存中,变量保存的是堆内存地址的引用

赋值

赋值操作是指将一个变量的值赋予另一个变量。对于原始类型来说,赋值意味着创建这个值的一个完全独立的副本。这意味着改变一个变量不会影响另一个变量 。

js 复制代码
let a = 10;
let b = a; // 赋值操作
a = 100;

console.log(b); // 10

特点

  • 创建值的完全独立副本
  • 修改新变量不影响原变量

当涉及到引用类型的赋值时,情况略有不同。当你将一个对象或数组赋值给另一个变量时,实际上是将引用(内存地址)复制了一份。两个变量指向同一个内存地址,因此修改其中一个变量会影响另一个变量

js 复制代码
let a = [1,2,3]
let b = a
a[0] = 100
console.log(b) //[100,2,3]

特点

  • 只复制内存地址引用
  • 新旧变量指向同一个对象
  • 通过任一变量修改都会影响另一个

为了克服引用类型赋值的问题,可以使用拷贝。拷贝分为浅拷贝和深拷贝。

如果遇到面试官问你深浅拷贝,可以先说这几句话:

  • 简单数据类型拷贝值
  • 复杂数据类型拷贝地址 (浅拷贝)
  • 复杂数据类型 开辟新的内存空间 拷贝值 (深拷贝) 再来详细说明这两个的区别。

浅拷贝(Shallow Copy)

  • 定义 :创建一个新对象,并复制原对象的属性。如果属性是基本类型 (如number, string),直接复制值;如果是引用类型 (如object, array),则复制其内存地址(即新旧对象共享同一引用)。

  • 特点

    • 修改原对象或新对象的引用类型属性时,另一方会受影响。
    • 仅复制一层,嵌套对象不独立。

深拷贝(Deep Copy)

  • 定义: 不仅复制顶级属性 ,递归复制对象及其所有嵌套属性,新旧对象完全独立,不共享任何引用。

  • 特点

    • 完全独立的对象结构,修改互不影响。
    • 处理复杂对象时性能开销较大。

浅拷贝的实现

1. 使用 Object.assign()

Object.assign() 方法可以将一个或多个源对象的可枚举属性复制到目标对象中,从而实现浅拷贝。

js 复制代码
const original = { a: 1, b: { c: 2 } };
const shallowCopy = Object.assign({}, original);

console.log(shallowCopy); // 输出: { a: 1, b: { c: 2 } }

// 修改第一层属性
shallowCopy.a = 10;
console.log(original.a); // 输出: 1 (不影响原对象)

// 修改嵌套对象
shallowCopy.b.c = 20;
console.log(original.b.c); // 输出: 20 (影响原对象)

2. 使用扩展运算符(Spread Operator)

扩展运算符 ... 可以用来展开对象或数组,从而实现浅拷贝。

js 复制代码
const original = { a: 1, b: { c: 2 } };
const shallowCopy = { ...original };

console.log(shallowCopy); // 输出: { a: 1, b: { c: 2 } }

// 修改第一层属性
shallowCopy.a = 10;
console.log(original.a); // 输出: 1 (不影响原对象)

// 修改嵌套对象
shallowCopy.b.c = 20;
console.log(original.b.c); // 输出: 20 (影响原对象)

3. 使用数组的 slice() 方法

对于数组,可以使用 slice() 方法实现浅拷贝。

js 复制代码
const originalArray = [1, 2, { value: 3 }];
const shallowCopyArray = originalArray.slice();

console.log(shallowCopyArray); // 输出: [1, 2, { value: 3 }]

// 修改第一层元素
shallowCopyArray[0] = 10;
console.log(originalArray[0]); // 输出: 1 (不影响原数组)

// 修改嵌套对象
shallowCopyArray[2].value = 30;
console.log(originalArray[2].value); // 输出: 30 (影响原数组)

自己实现一个

js 复制代码
function clone(target) {
    if(typeof target === 'object' && target !== null){
        let cloneTarget = Array.isArray(target) ? []:{}; //兼容数组
        for (const key in target) {
            cloneTarget[key] = target[key];
        }
        return cloneTarget;
    } else {
        return target
    }
};

// 测试
let obj = {a: 1, b: 2, c: {d: 3}};
let clonedObj = clone(obj);
clonedObj.c.d = 4; 
console.log(obj.c.d);//4 也被修改了

clonedObj.a = 5; 
console.log(obj.a);//1 没有被修改 互不影响

let a = clone('abc')
console.log(a) //abc
let b = clone(123)
console.log(b) //123
let c = clone(null)
console.log(c) //null
let d = clone(undefined)
console.log(d) //undefined

深拷贝的实现

最简单粗暴的做法

1. JSON.parse(JSON.stringify()):

js 复制代码
   let obj1 = { name: "Alice", details: { age: 25 } };
   let obj2 = JSON.parse(JSON.stringify(obj1));

   obj2.details.age = 30;
   console.log(obj1.details.age); //  25 不受影响

局限性

  • 无法处理函数、undefinedSymbolDateRegExp等特殊值。
  • 无法处理循环引用的对象。

2.structuredClone

structuredClone(obj) 是一个 原生 JavaScript API ,用于进行深拷贝操作,旨在创建对象的完全独立副本,包括嵌套的对象、数组、MapSetDateRegExp 等数据类型。与传统的深拷贝方法(如 JSON.parse(JSON.stringify(obj)))相比,structuredClone() 具有更强的功能和更高的效率。 局限性

  • 函数(Function) :拷贝会丢失函数内容,结果为 undefined

  • Symbol :会丢失 Symbol 类型的属性。

3.递归实现深拷贝

把之前浅拷贝的代码稍微改下就能实现一个基础的递归深拷贝了

js 复制代码
function clone(target){
    if(typeof target === 'object' && target !== null){
        let cloneTar = Array.isArray(target)? []:{}
        for(let key in target){
            cloneTar[key] = clone(target[key])
        }
        return cloneTar
    }else {
        return target
    }
}

测试:

js 复制代码
const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8]
};

let clonedObj = clone(target)
clonedObj.field4.push(10)
clonedObj.field3.child = 'hello'
clonedObj.field1 = 10
console.log(target,clonedObj)

目前来看没啥问题,但如果target的属性直接或间接引用自身呢?

js 复制代码
const target = {
    field1: 1,
    field2: undefined,
    field3: {
        child: 'child'
    },
    field4: [2, 4, 8],
};
target.self = target

console.log(clone(target))

第一次调用clone(target),之后遍历到self属性时,cloneTar[self] = clone(target[self]) 不就变成了cloneTar[self] = clone(target),再重复之前的操作,会不停的递归下去,不会命中停止递归的条件。

为了避免这种情况,可以采用一些高级的深拷贝策略来处理循环引用,比如:

记录已经拷贝过的对象: 在开始深拷贝之前,维护一个MapWeakMap来存储已经拷贝过的对象和它们的副本之间的映射。在递归过程中,如果发现当前需要拷贝的对象已经在映射中,则直接使用已有的副本,而不是再次尝试去拷贝。

WeakMap 对其键(对象)的引用是弱引用。这意味着如果一个对象仅被 WeakMap 作为键持有,并且没有其他强引用指向它,垃圾回收器可以在任何时间点回收该对象。相比之下,Map 持有的是强引用,只要对象存在于 Map 中,它就不会被垃圾回收,即使在代码的其他地方不再需要这个对象。因此,在深拷贝过程中使用 WeakMap 可以减少内存泄漏的风险,因为它允许未使用的对象被及时回收。

WeakMap 的API限制了键必须是对象,不能是原始类型值,这也恰好符合在深拷贝过程中使用对象作为键的需求。所以我们更倾向于用weakMap来记录已经拷贝过的对象。

具体步骤:

  • 检查weakmap中有无克隆过的对象
  • 有就直接返回
  • 没有就将当前对象作为key,克隆对象作为value进行存储
  • 继续克隆
js 复制代码
function clone(target) {
    const map = new WeakMap()
    function _clone(target) {
        if (typeof target === 'object' && target !== null) {
            let cloneTar = Array.isArray(target) ? [] : {}
            if (map.has(target)) {
                return map.get(target)
            }
            map.set(target, cloneTar)
            for (let key in target) {
                cloneTar[key] = clone(target[key])
            }
            return cloneTar
        } else {
            return target
        }
    }
    return _clone(target)
}

还可以使用es6的默认参数,这样更简洁。

js 复制代码
function clone(target,map = new WeakMap()){
    if(typeof target === 'object' && target !== null){
        let cloneTar = Array.isArray(target)? []:{}
        if(map.has(target)){
            return map.get(target) 
        }
        map.set(target,cloneTar)
        for(let key in target){
            cloneTar[key] = clone(target[key],map)
        }
        return cloneTar
    }else {
        return target
    }
}

本以为到了这一步,深拷贝就已经完成的很好了,考虑到了数组,会使用递归解决问题,还解决了循环引用,甚至考虑到了内存泄露,但是看了这篇文章才发现我还是太天真了。面试官可能还想在这道题目上看到你更多的品质。

for...in 会遍历对象自身和继承的可枚举属性,这意味着它需要检查原型链上的属性,因此性能开销较大。我们可以自己实现一个forEach方法来进行性能优化。

js 复制代码
/**
 * 自定义的数组遍历函数,对数组的每个元素执行指定的回调函数。
 * @param {Array} array - 需要遍历的数组。
 * @param {Function} iteratee - 对数组每个元素执行的回调函数,接收元素和索引作为参数。
 * @returns {Array} 返回原数组。
 */
function forEach(array, iteratee) {
    // 初始化索引为 -1
    let index = -1;
    // 获取数组的长度
    const length = array.length;
    // 循环遍历数组,对每个元素执行 iteratee 函数
    while (++index < length) {
        iteratee(array[index], index);
    }
    // 返回原数组
    return array;
}

再来改造一下上个版本的:

js 复制代码
function clone(target,map = new WeakMap()){
    if(typeof target === 'object' && target !== null){
        const isArray = Array.isArray(target);
        let cloneTar = isArray? []:{}
        if(map.has(target)){
            return map.get(target) 
        }
        map.set(target,cloneTar)

        const keys = isArray? undefined : Object.keys(target)
        //如果target是数组,forEach直接遍历target即可,如果是对象,forEach就是遍历由target的可枚举属性组成的数组
        forEach(keys || target, (value,key) => {
            if(keys){
                key = value
            }
            cloneTar[key] = clone(target[key],map)
        })
        return cloneTar
    }else {
        return target
    }
}

if (keys) { key = value; } 的作用

  • target 是普通对象时,keysObject.keys(target) 返回的键名数组。此时 forEach 遍历的是 keys 数组,回调函数中的 value 代表当前遍历到的键名,key 是遍历的索引。为了能正确使用键名访问对象属性,就需要把 key 赋值为 value
  • target 是数组时,keysundefinedforEach 直接遍历数组元素,value 是数组元素,key 是数组索引,这种情况下 key 就保持原本的索引值。

是为了能统一使用 cloneTar[key] = clone(target[key],map)

主要完成了对基本数据类型,对象,数组就差不多够了吧,其他的引用类型无非是多加些条件判断,跟面试官表达自己的想法即可。 比如:

js 复制代码
if (target instanceof Set) {
            const clonedSet = new Set();
            target.forEach(value => clonedSet.add(clone(value, map)));
            return clonedSet;
        }

总结:

对比项 深拷贝 (Deep Copy) 浅拷贝 (Shallow Copy) 赋值 (Assignment)
拷贝方式 递归复制所有嵌套数据 仅复制第一层,内部引用仍指向原对象 直接复制引用,两个变量指向同一对象
是否独立 ✅ 新对象完全独立 ❌ 共享引用,修改嵌套对象会影响原对象 ❌ 直接共享同一对象
影响范围 深层嵌套对象也被复制 只复制一层,内部对象仍然共享 变量名不同,但本质是同一个对象
修改后互不影响 ✅ 互不影响 ❌ 修改嵌套对象会影响原对象 ❌ 修改任意一方都会影响另一方
常见方法 JSON.parse(JSON.stringify(obj))(丢失方法 & undefinedstructuredClone(obj)(现代浏览器) 手写递归或 lodash.cloneDeep() Object.assign({}, obj) [...arr] (数组) { ...obj } (对象) let newVar = oldVar;
相关推荐
腾讯TNTWeb前端团队5 小时前
helux v5 发布了,像pinia一样优雅地管理你的react状态吧
前端·javascript·react.js
范文杰8 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪9 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪9 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy9 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom10 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom10 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom10 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom10 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom10 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试