Javascript语言特性:赋值,浅拷贝与深拷贝

赋值、浅拷贝、深拷贝之间有哪些区别?在实际的开发过程中有哪些应用?

前言

最近在 Leetcode 上用 JavaScript 做算法题,某天做一道题的时候,我发现自己的代码逻辑似乎没有问题,但是题目的输出结果总是有些奇怪,例如对于这一段代码:

/**
 * @param {number[]} nums1
 * @param {number} m
 * @param {number[]} nums2
 * @param {number} n
 * @return {void} Do not return anything, modify nums1 in-place instead.
 */
var merge = function(nums1, m, nums2, n) {
    if(m === 0) {
        nums1 = nums2
        return
    }
}

看起来似乎没什么问题,但是实际的输出结果确是这样的:

**输入**
nums1 = [0]
m = 0
nums2 = [1]
n = 1
**输出** [0]
**预期结果** [1]

看起来 nums1 = nums2 并没有将 nums2 的内容完全赋值到 nums1 的对应位置,因此在检测 nums1 对应地址的值的时候,输出的结果与预期结果不符。

这里就涉及到 JavaScript 的赋值规则了,假设有两个变量 nums1 和 nums2,如果 nums2 是一个基本数据类型(如数字、字符串、布尔值、null 或 undefined),那么执行语句 nums1 = nums2,nums1 将会得到 nums2 的一个副本,它们各自独立,改变其中一个不会影响另一个。而如果 nums2 是一个引用数据类型(如数组、对象、函数等),那么执行语句 nums1 = nums2,nums1 并不会得到 nums2 的一个副本,而是会得到 nums2 所指向的同一个内存地址的引用。这意味着 nums1 和 nums2 现在都指向内存中的同一个数组,此时如果通过 nums1 修改数组,会影响 nums2,而 nums1 原本的地址对应的数组如果没有其他引用的话,就会变得无法被访问。

示例

使用 JavaScript 的程序员们为了解决这个问题思考出了浅拷贝与深拷贝这两种复制变量的方式,这两种方法用于 1:1 地构建一个变量的精确副本,而不是使用赋值将两个变量引用到同一个地址,从而避免原变量与该变量的副本之间相互影响的情况发生。

什么是浅拷贝

我们先看这两个变量:

let x = {
    id: 1,
    name: 'test1',
    isObject: true
}
let y = {
    id: 2,
    name: 'test2',
    isObject: true,
    parent: {
        id: 1,
        name: 'test1',
        isObject: true
    },
    greet: function() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}

x 是一个所有属性的值均为基本数据类型的对象,而 y 是一个属性的值既包括基本数据类型又包括引用数据类型的对象。对于这两个变量,如果我们简单使用 x_copy = xy_copy = y 的话,就会与上文中的 nums1 = nums2 一样,x_copy 和 y_copy 仅仅指向对应的 x 和 y 的内存地址,而不是 x 和 y 的一份完全独立的副本。

如果我们想要得到一个完全独立的与 x 一模一样的副本 x_copy,我们可以使用浅拷贝。浅拷贝的一个实现如下:

let x_copy = {
    id: x.id,
    name: x.name,
    isObject: x.isObject
}

直观上来看,对于一个对象类型的变量,使用浅拷贝的方式新建一个该变量的副本的过程是这样的:首先新建了一个对象,接着将原对象的每一个属性和其对应的值逐一赋值到新建的对象上。以上述实现代码为例,x_copy 内的属性由于其对应的属性值均为基本数据类型的值,因此在赋值的时候是将 x 对应属性的值赋给 x_copy 的对应属性,而不是将其指向与 x 相同的引用地址。如果修改 x_copy 内的一个属性的值,x 中对应的属性的值完全不会发生变化,我们就得到了一个完全独立的与 x 一模一样的副本 x_copy。

对于不同的数据类型,浅拷贝的实现方式会有所不同,除了完全手工完成的实现方法外,针对数组和对象分别有更加简洁与现代化的实现方式:

对 Array 进行浅拷贝:

  1. 使用展开运算符(…):let newArr = [...oldArr];
  2. 使用 Array 的 slice 方法:let newArr = oldArr.slice();
  3. 使用 Array 的 from 方法:let newArr = Array.from(oldArr);

对 Object 进行浅拷贝:

  1. 使用展开运算符(…):let y_copy = { ...y };
  2. 使用 Object 的 assign 方法:let y_copy = Object.assign({}, y);

什么是深拷贝

对于上文中的变量 y,如果使用同样的方式进行浅拷贝,y_copy 内部的 parentgreet 的值其实是指向 y 的内存地址,而并非独立的副本。此时假设我们执行 y_copy.parent.id = 2 这条语句,y 中 parent 属性内的 id 的值就会发生变化。对于这种情况,我们需要使用深拷贝来获取一个完全独立的与 y 一模一样的副本。

深拷贝相对于浅拷贝最大的特点就是深拷贝会递归地复制所有嵌套的引用数据类型(对象、数组),直到所有数据都是独立的副本,而这就意味着深拷贝的实现比浅拷贝要复杂得多,如果完全依靠自己实现的话,我们需要手动编写递归函数遍历并复制所有属性。除了手写递归函数外,常见的深拷贝的实现方法有以下几种:

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

这种方法是实现深拷贝最常用和最简单的方法,将一个变量先转换为JSON字符串,再解析回去,下面是这种方法的一个实现:

let originalArray = [1, { a: 1 }, 3];
let deepCopyArray = JSON.parse(JSON.stringify(originalArray)); // 深拷贝

console.log(deepCopyArray); // 输出: [1, { a: 1 }, 3]

// 修改基本类型元素:互不影响
deepCopyArray[0] = 99;
console.log(originalArray);  // 输出: [1, { a: 1 }, 3]
console.log(deepCopyArray);  // 输出: [99, { a: 1 }, 3]

// 修改引用类型元素(通过深拷贝的数组):互不影响
deepCopyArray[1].a = 2;
console.log(originalArray);  // 输出: [1, { a: 1 }, 3] (原数组不变)
console.log(deepCopyArray);  // 输出: [99, { a: 2 }, 3]

这种方法虽然简单,但是存在一定的局限性:

  • 无法拷贝变量中的函数、undefined、Symbol 类型的数据
  • 变量的拷贝会丢失变量原有的原型链,例如,如果原始对象是 new MyClass() 的实例,拷贝后它将不再是 MyClass 的实例,而是普通的 {}
  • 如果变量中存在循环引用,会因为无限递归而抛出 TypeError: Converting circular structure to JSON 错误
  1. structuredClone():

structuredClone()函数是一个内置于 JavaScript 运行时的用于深拷贝的函数,这个函数可以用来进行深拷贝 JavaScript 变量,也支持循环引用。MDN 文档中给出的一个示例如下:

// 创建一个具有值和对自身的循环引用的对象。
const original = { name: "MDN" };
original.itself = original;

// 对它进行克隆
const clone = structuredClone(original);

console.assert(clone !== original); // 对象并不相同(标识不同)
console.assert(clone.name === "MDN"); // 它们具有相同的值
console.assert(clone.itself === clone); // 且保留了循环引用

除了复制 Object 以外,structuredClone() 还支持把原值中的可转移对象转移(而不是拷贝)到新对象上,这时需要使用 structuredClone(value, options) 这个语法,其中 options 这个参数是一个 Object,这个 Object 的属性是 transfer,它对应的值是一个数组,数组元素为 value 中的对象,存在在这个数组中的对象将被移动而不是克隆到返回的对象上,函数返回结果时,原始对象里的对应元素会被清除掉。例如下面的一个例子:

// 16MB = 1024 * 1024 * 16
const uInt8Array = Uint8Array.from({ length: 1024 * 1024 * 16 }, (v, i) => i);

const transferred = structuredClone(uInt8Array, {
  transfer: [uInt8Array.buffer],
});
console.log(uInt8Array.byteLength); // 0
  1. Lodash 的 _.cloneDeep() 方法:

使用 Lodash 的 _.cloneDeep() 方法是处理复杂深拷贝场景最健壮和推荐的方式,特别是在大型项目中。

对于上文中的对象 y:

let y = {
    id: 2,
    name: 'test2',
    isObject: true,
    parent: {
        id: 1,
        name: 'test1',
        isObject: true
    },
    greet: function() {
        console.log(`Hello, my name is ${this.name}.`);
    }
}

如果使用 Lodash 的 _.cloneDeep() 方法对它进行深拷贝,实现与验证的方式如下:

import _ from 'lodash';

let y_deep_copy_lodash = _.cloneDeep(y);

console.log('\n--- Using Lodash (_.cloneDeep()) ---');
console.log('Original y:', y);
console.log('Deep copy of y (Lodash method):', y_deep_copy_lodash);

// 验证基本类型属性的独立性
y_deep_copy_lodash.name = 'modified_test2_lodash';
console.log('\n--- After modifying y_deep_copy_lodash.name ---');
console.log('Original y.name:', y.name); // test2
console.log('Deep copy y_deep_copy_lodash.name:', y_deep_copy_lodash.name);

// 验证引用类型属性(parent)的独立性
y_deep_copy_lodash.parent.name = 'modified_parent_name_lodash';
console.log('\n--- After modifying y_deep_copy_lodash.parent.name ---');
console.log('Original y.parent.name:', y.parent.name); // test1 (原对象不变)
console.log('Deep copy y_deep_copy_lodash.parent.name:', y_deep_copy_lodash.parent.name);

// 验证函数属性(greet)的行为 (Lodash默认会拷贝函数引用,因为它认为函数是不可变的)
console.log('\n--- Checking greet function with Lodash ---');
console.log('Are y.greet and y_deep_copy_lodash.greet the same function?', y.greet === y_deep_copy_lodash.greet); // true
y_deep_copy_lodash.greet(); // Hello, my name is modified_test2_lodash.
y.greet(); // Hello, my name is test2.

总结

对于 JavaScript 中的引用数据类型的变量,如果我们需要它的一个复制,“直接新建一个变量并使用赋值来初始化这个变量”的方式可能会带来一些意想不到的错误,因此我们需要根据变量内部元素的复杂程度选择使用浅拷贝还是深拷贝来创建变量的复制。

最简单与现代化的浅拷贝方式是使用展开运算符(…),而最健壮与推荐的深拷贝方式是使用 Lodash 的 _.cloneDeep() 方法,在实际项目中,需要根据实际情况选取对应的方法。