浅拷贝与深拷贝

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

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

赋值与深浅拷贝

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

基本类型(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;
相关推荐
秋天的一阵风19 分钟前
突发奇想:border: 0 和boder: none 有区别吗?🤔🤔🤔
前端·css·html
秋天的一阵风23 分钟前
🌈尘埃落定!ECMASCRIPT 2025 标准来袭,开发者的新福音🎁
前端·javascript·ecmascript 8
Coffeeee33 分钟前
重新开始学Threejs,了解一下里面的一些高级几何体
前端·typescript·three.js
沉迷...41 分钟前
el-input限制输入只能是数字 限制input只能输入数字
开发语言·前端·elementui
xx24061 小时前
date-picker组件的shortcuts为什么不能配置在vue的data的return中
前端·javascript·vue.js
古时的风筝1 小时前
Caddy 比Nginx 还优秀吗
前端·后端·程序员
Anlici1 小时前
无脑字节面基🥲
前端·面试·架构
古时的风筝1 小时前
Cursor 建议搭配 CursorRules 食用
前端·后端·cursor
前端南玖1 小时前
通过performance面板验证浏览器资源加载与渲染机制
前端·面试·浏览器
树深遇鹿1 小时前
SSE(Server-Sent Events)的使用
前端·javascript·面试