JavaScript 对象拷贝:浅拷贝与深拷贝的差异、应用及实现

524 阅读10分钟

在 JavaScript 开发里,对象复制极为常见。不管是传递数据、管理状态,还是处理函数参数,我们常需复制对象,避免直接修改原始对象,或在不同场景复用相同结构数据。浅拷贝与深拷贝是实现对象复制的两种关键手段,理解并合理运用它们,对编写优质代码意义重大。

浅拷贝(Shallow Copy)

定义与特点

浅拷贝仅复制对象的第一层属性。所谓第一层属性,指的是直接隶属于该对象的属性,而非对象内部嵌套对象的属性。

在浅拷贝过程中,针对不同数据类型,处理方式和结果各异:

  • 基础数据类型 :在 JavaScript 里,数字(number)、字符串(string)、布尔值(boolean)、null 以及 undefined 都属于基础数据类型。浅拷贝时,针对这些类型,会在新对象中生成全新且独立的值。
  • 引用数据类型 :对象(object)和数组(array)这类引用数据类型,在内存中存储的是指向实际数据存储位置的引用地址。
    • 浅拷贝对于引用数据类型,仅仅复制该引用地址,并非实际数据。这就致使新对象和原对象中的引用类型属性指向同一块内存区域,进而对其中一个对象的引用类型属性进行修改,另一个对象也会受到影响。

以如下对象为例:

let obj = {
  name: 'John',
  age: 30,
  address: {
    street: '123 Main St',
    city: 'Anytown'
  }
};
  • 在 obj 中,nameage 和 address 属于第一层属性,它们直接存在于 obj 中。而 street 和 city 是嵌套在 address 对象内的属性,不属于第一层属性。

  • 浅拷贝时,仅会复制 nameage 和 address 这一层属性,其中对于基础数据类型的 name 和 age,会创建新值;对于引用类型的 address,则复制其引用地址,使得新对象和原对象中的 address 属性指向同一内存区域 。

适用场景

当我们仅关注对象的第一层属性,且确定引用类型属性在后续操作中不会被修改,又或是期望新对象与原对象在某些引用类型属性上保持同步变化,浅拷贝便是不错的选择。

在简单的数据展示场景中,这种情况尤为适用。比如展示用户的基本信息等。

对象浅拷贝

  1. Object.assign()
  • Object.assign() 是 JavaScript 内置的用于对象合并和浅拷贝的方法。它可以将一个或多个源对象的属性复制到目标对象上。
  • 语法为 Object.assign(target, ...sources),其中 target 是目标对象,sources 是一个或多个源对象。
const source = { a: 1, b: { c: 2 } };
const shallowCopiedByAssign = Object.assign({}, source);

// 修改浅拷贝对象的基础数据类型属性
shallowCopiedByAssign.a = 10;
// 修改浅拷贝对象的引用数据类型属性
shallowCopiedByAssign.b.c = 20;

console.log(source.a); // 输出: 1
console.log(shallowCopiedByAssign.a); // 输出: 10
console.log(source.b.c); // 输出: 20
console.log(shallowCopiedByAssign.b.c); // 输出: 20
  • 上述代码创建了一个对象 source,然后使用 Object.assign() 方法对其进行浅拷贝,得到一个新对象 shallowCopiedByAssign
  • 由于是浅拷贝,对于 source 中的基础数据类型属性(如 a),shallowCopiedByAssign 会拥有一个独立的副本;
  • 而对于引用数据类型属性(如 b),shallowCopiedByAssign 和 source 中的 b 会指向同一个内存地址。这就意味着,修改 shallowCopiedByAssign.b 会影响到 source.b,反之亦然。
  1. 扩展运算符 ...
    扩展运算符 ... 能够将可迭代对象(如数组、字符串等)展开为单个元素,也能在对象上下文中把对象的可枚举属性复制到新对象中,实现对象的浅拷贝。
    • 一般来说,直接在对象字面量中定义的属性,或者通过Object.defineProperty()方法定义且没有将enumerable属性设置为false的属性,都是可枚举属性。
  • 简单来说,当用于对象时,它会创建一个新对象,新对象拥有原对象所有可枚举属性的副本。
const originalObject = { x: 3, y: { z: 4 } };
const shallowCopiedObject = {...originalObject };
  1. Object.create () 结合 Object.keys ()
  • Object.create() 方法用于创建一个新对象,它允许使用现有的对象来提供新创建对象的 __proto__,也就是新对象的原型会指向该现有对象。
  • 而 Object.keys() 方法会返回一个由给定对象的所有可枚举属性组成的数组。将这两个方法结合使用,能够实现对象的浅拷贝。
t source = { a: 1, b: { c: 2 } };
// 创建新对象,设置其原型与 source 相同
const shallowCopied = Object.create(Object.getPrototypeOf(source));
// 获取 source 可枚举属性名
const keys = Object.keys(source);

// 遍历属性名,复制属性值到新对象
for (let i = 0; i < keys.length; i++) {
    const key = keys[i];
    shallowCopied[key] = source[key];
}

shallowCopied.b.c = 3;
// 因浅拷贝,原对象对应属性值也改变
console.log(source.b.c); 

数组浅拷贝

  1. 扩展运算符 ...
    在创建新数组时使用扩展运算符,可以实现浅拷贝,它会复制数组的元素。
const originalArray = [1, 2, [3, 4]];
const shallowCopiedArray = [...originalArray];
  1. Array.prototype.slice()
    slice() 方法返回一个新的数组对象,这一对象由该方法的开始和结束(不包括结束)选择的原数组的一部分浅拷贝组成。如果不指定 begin 和 end,则会浅拷贝整个数组。
const arr = [1, 2, [3, 4]];
const shallowCopiedSlice = arr.slice();
  1. Array.prototype.concat()
    concat() 方法用于合并两个或多个数组,它会返回一个新的数组,该数组是由原数组和传入的数组或值合并而成。如果不传入任何参数,它会返回原数组的一个浅拷贝。
const originalArray = [1, 2, [3, 4]];
const shallowCopiedArray = originalArray.concat();
  1. Array.from()
    Array.from() 方法从一个类似数组或可迭代对象创建一个新的、浅拷贝的数组实例。
const originalArray = [1, 2, [3, 4]];
const shallowCopiedArray = Array.from(originalArray);

潜在问题

由于浅拷贝对于引用数据类型只是复制引用地址,当原对象和浅拷贝对象共享的引用类型属性被修改时,会影响到彼此。

在业务逻辑里,要是不小心修改了浅拷贝对象中引用类型属性的值,原对象中对应的属性值也会跟着改变,反之亦然。这或许会造成数据混乱,让程序的运行结果不符合预期。

深拷贝(Deep Copy)

定义与特点

  • 深拷贝会递归地复制对象的所有层级属性,包括嵌套的对象和数组等。
  • 这一过程会构建一个全新的对象,新对象的每一层属性均为独立副本,与原对象不存在任何引用关联。因此,对新对象属性的任何修改,都不会波及原对象,反之亦然,新旧对象在数据层面实现了彻底的隔离。

适用场景

当我们需要确保新对象和原对象在任何情况下都完全独立,互不干扰时,深拷贝是必须的。

  • 比如在一些涉及复杂数据结构且数据安全性要求较高的场景,如金融数据处理、复杂游戏场景数据管理等,深拷贝能保证数据的完整性和独立性。

实现方式

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

工作原理

此方法借助了 JavaScript 内建的 JSON.stringify() 和 JSON.parse() 函数。

  • JSON.stringify() :该函数会把 JavaScript 对象转化为 JSON 字符串。

    • JSON 字符串是一种基于 JavaScript 语法的文本格式,用于表示结构化数据。它以字符串的形式存储和传输数据:
      • JSON 字符串中的键必须是字符串,并且必须使用双引号括起来。
      • 值可以是对象,数组,字符串,数字,布尔值,null 数据类型。
      • 整个 JSON 字符串必须是一个有效的 JSON 数据结构,通常是一个对象或数组。
    • 在转换过程中,对象的结构和属性值都会被转换为符合 JSON 格式的字符串表示。·例如,对于对象 { a: 1, b: { c: 2 } }JSON.stringify() 会将其转换为字符串 "{"a":1,"b":{"c":2}}"
  • JSON.parse() :该函数能把 JSON 字符串解析为 JavaScript 对象。

    • 当传入 JSON.stringify() 生成的字符串时,它会创建一个新的 JavaScript 对象,这个新对象在结构和属性值上与原对象相同,但在内存中是独立的。
  • 这种方法先将对象转换为 JSON 字符串,再将 JSON 字符串解析为新的对象。

  • 由于 JSON 格式的限制,它无法处理函数、正则表达式、循环引用等特殊情况,只能处理包含基础数据类型和普通对象、数组的简单结构。

const original = { a: 1, b: { c: 2 } };
// 使用 JSON.stringify 将原始对象转换为 JSON 字符串,再用 JSON.parse 将字符串解析为新对象,实现深拷贝
const deepCopyByJSON = JSON.parse(JSON.stringify(original));
deepCopyByJSON.b.c = 3;

// 原始对象中嵌套对象的属性 c 的值,由于是深拷贝,原始对象不受影响,结果为 2
console.log(original.b.c); 
// 深拷贝对象中嵌套对象的属性 c 的值,已被修改为 3
console.log(deepCopyByJSON.b.c);    

  1. 递归实现 :通过编写递归函数,遍历对象的每一层属性,对于基础数据类型直接复制,对于引用数据类型则继续递归调用深拷贝函数,直到所有层级的属性都被复制,从而实现深拷贝。
function deepClone(obj) {
    if (typeof obj!== 'object' || obj === null) {
        return obj;
    }
    let clone = Array.isArray(obj)? [] : {};
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            clone[key] = deepClone(obj[key]);
        }
    }
    return clone;
}
  1. 第三方库(lodash.cloneDeep) :lodash 库中的 cloneDeep 方法是一个专门用于深拷贝的函数,它能够处理各种复杂的对象结构,包括函数、循环引用等,并且性能和兼容性都较好,是在实际开发中进行深拷贝的常用选择。首先需要引入 lodash 库,然后使用 _.cloneDeep() 方法。
import cloneDeep from 'lodash/cloneDeep';
import cloneDeep from 'lodash/cloneDeep';
const original = { a: 1, b: { c: 2 } };
const deepCopyByLodash = cloneDeep(original);

手写实现

手写浅拷贝

function shallowClone(obj) {
    if (typeof obj!== 'object' || obj === null) {
        return obj;
    }
    let clone = Array.isArray(obj)? [] : {};
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            clone[key] = obj[key];
        }
    }
    return clone;
}

手写深拷贝(递归版)

function deepClone(obj) {
    if (typeof obj!== 'object' || obj === null) {
        return obj;
    }
    let clone = Array.isArray(obj)? [] : {};
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            clone[key] = deepClone(obj[key]);
        }
    }
    return clone;
}

手写深拷贝(解决循环引用)

function deepCloneWithCircular(obj, map = new WeakMap()) {
    if (typeof obj!== 'object' || obj === null) {
        return obj;
    }
    if (map.has(obj)) {
        return map.get(obj);
    }
    let clone = Array.isArray(obj)? [] : {};
    map.set(obj, clone);
    for (let key in obj) {
        if (obj.hasOwnProperty(key)) {
            clone[key] = deepCloneWithCircular(obj[key], map);
        }
    }
    return clone;
}

核心逻辑是利用递归遍历对象属性进行复制,借助 WeakMap 避免循环引用导致的无限递归。

  • 若 WeakMap 里已有该对象,说明是循环引用,直接返回之前克隆好的对象。
  • 把原始对象和克隆对象存入 WeakMap,方便后续判断。

总结

如何选择拷贝方式?

  • 如果对象结构简单,且只需要复制第一层属性,或者希望新对象和原对象在某些引用类型属性上保持同步变化,浅拷贝是一个高效的选择。
  • 当对象结构复杂,包含多层嵌套,并且需要确保新对象和原对象完全独立,互不干扰时,深拷贝是必须的。
    • 在选择深拷贝方式时,如果对象结构简单,不包含特殊数据类型(如函数、循环引用、Symbol 等),JSON.parse(JSON.stringify()) 是一个简单快捷的方法;
    • 如果对象结构复杂,包含特殊数据类型,建议使用第三方库(如 lodash 的 cloneDeep)或者自己实现一个能处理特殊数据类型的递归深拷贝函数。

性能对比

  • 浅拷贝的性能通常优于深拷贝,因为浅拷贝只复制一层属性,而深拷贝需要递归遍历所有层级的属性。
  • 在处理大对象时,深拷贝可能会消耗大量的内存和时间。
  • 因此,在满足需求的前提下,应优先考虑浅拷贝。但如果必须保证数据的独立性,即使性能有所损耗,也需要使用深拷贝。

推荐方法

  • 在实际开发中,对于浅拷贝,建议优先使用扩展运算符,因为其语法简洁明了。
  • 对于深拷贝,如果项目中已经引入了 lodash 库,使用 _.cloneDeep() 是最方便可靠的选择。
  • 如果项目对体积敏感,不希望引入额外的库,可以根据实际需求,自己实现一个能处理特殊数据类型的深拷贝函数。