深入理解JavaScript中的深浅拷贝:从内存机制到面试精髓(上)

143 阅读10分钟

摘要

在JavaScript的日常开发和面试中,“深拷贝”与“浅拷贝”无疑是高频词汇。它们不仅是理解JavaScript内存管理和数据操作的关键,更是区分初级与高级前端工程师的重要标志。本文将以面试官的视角,从JavaScript的内存分配机制入手,深入剖析深拷贝与浅拷贝的本质区别、各种实现方式及其背后的原理,并通过丰富的代码示例和面试场景,帮助读者彻底掌握这一核心概念,从而在面试中游刃有余,并在实际开发中避免潜在的“坑”。

1. 从内存机制理解赋值、引用与拷贝

要理解深浅拷贝,首先需要对JavaScript的数据类型和内存分配有一个清晰的认识。

1.1 简单数据类型与复杂数据类型

JavaScript中的数据类型分为两大类:

  • 简单数据类型(基本数据类型)NumberStringBooleanUndefinedNullSymbol(ES6新增)、BigInt(ES2020新增)。

    • 存储方式:这些类型的值直接存储在栈内存中。当进行赋值操作时,是直接将值复制一份给新变量。
  • 复杂数据类型(引用数据类型)Object(包括ArrayFunctionDateRegExp等)。

    • 存储方式:这些类型的值存储在堆内存中,而变量本身存储的是一个指向堆内存中实际值的引用地址(指针),存储在栈内存中。当进行赋值操作时,复制的是这个引用地址,而不是堆内存中的实际值。

1.2 赋值操作的本质

  • 简单数据类型赋值

    let a = 10;
    let b = a; // b 复制了 a 的值,b 现在是 10
    b = 20;    // 修改 b 不会影响 a
    console.log(a); // 10
    console.log(b); // 20
    

    此时,ab在栈内存中各自拥有独立的1020

  • 复杂数据类型赋值(引用)

    let obj1 = { name: "Alice" };
    let obj2 = obj1; // obj2 复制了 obj1 的引用地址,两者指向同一个堆内存对象
    obj2.name = "Bob"; // 通过 obj2 修改了堆内存中的对象
    console.log(obj1.name); // "Bob" (obj1 也受到了影响)
    console.log(obj2.name); // "Bob"
    

    此时,obj1obj2在栈内存中存储的是同一个引用地址,它们共同指向堆内存中的同一个对象。因此,通过任何一个变量修改对象,都会影响到另一个变量。

    这正是readme.md中提到的“赋值和引用的概念”以及“简单数据类型和复杂数据类型内存分配不一样”的核心。

2. 浅拷贝(Shallow Copy):只拷贝一层

浅拷贝是指创建一个新对象,这个新对象拥有原始对象的所有属性值。如果属性值是基本数据类型,那么新对象和原始对象各自拥有独立的属性值;但如果属性值是引用数据类型,那么新对象和原始对象会共享同一个引用地址,即它们指向堆内存中的同一个对象。

面试官开场白:以Object.assign()为例

面试中,面试官常常会以Object.assign()作为切入点,考察你对浅拷贝的理解。

2.1 Object.assign()

Object.assign()方法用于将一个或多个源对象的所有可枚举属性复制到目标对象,并返回修改后的目标对象。它常用于对象的浅拷贝或属性合并。

语法Object.assign(target, ...sources)

示例分析(来自1.js

// 1.js
const target = { a: 1 };
const source = { b: 2 };
const result = Object.assign(target, source);
console.log(result); // { a: 1, b: 2 }
console.log(target); // { a: 1, b: 2 } (target 被修改了)
console.log(source); // { b: 2 } (source 未被修改)result.a = 11;
console.log(result); // { a: 11, b: 2 }
console.log(target); // { a: 11, b: 2 } (target 仍然和 result 保持一致)
console.log(source); // { b: 2 }

核心要点

  • 返回的是目标对象Object.assign()返回的是被修改后的target对象,而不是一个全新的对象。因此,resulttarget指向同一个对象。

  • 浅拷贝特性

    • 如果源对象属性是基本数据类型,则直接复制值。
    • 如果源对象属性是引用数据类型,则复制其引用地址。这意味着如果源对象中包含嵌套对象或数组,修改拷贝后的对象中的嵌套引用类型属性,会影响到原始对象。

浅拷贝的“安全性问题”

当浅拷贝的对象中包含引用类型属性时,修改拷贝后的对象中的这些引用类型属性,会影响到原始对象,这可能导致数据不一致或难以追踪的bug。这就是readme.md中提到的“浅拷贝有安全性问题”。

2.2 其他浅拷贝方法

除了Object.assign(),还有其他常见的浅拷贝方法:

  • 数组的Array.prototype.concat()Array.prototype.slice()

    const arr1 = [1, 2, { a: 3 }];
    const arr2 = arr1.concat(); // 浅拷贝
    const arr3 = arr1.slice();  // 浅拷贝
    ​
    arr2[2].a = 4;
    console.log(arr1[2].a); // 4 (arr1 也受到了影响)
    
  • 扩展运算符(Spread Operator ...

    const obj1 = { a: 1, b: { c: 2 } };
    const obj2 = { ...obj1 }; // 浅拷贝obj2.b.c = 3;
    console.log(obj1.b.c); // 3 (obj1 也受到了影响)
    ​
    const arr4 = [1, 2, { d: 4 }];
    const arr5 = [...arr4]; // 浅拷贝
    ​
    arr5[2].d = 5;
    console.log(arr4[2].d); // 5 (arr4 也受到了影响)
    

这些方法都只能实现浅拷贝,即只能拷贝对象或数组的第一层属性。当遇到嵌套的引用类型时,它们仍然会共享同一个引用。

3. 深拷贝(Deep Copy):彻底的独立

深拷贝是指创建一个新对象,这个新对象与原始对象完全独立,没有任何引用共享。无论原始对象有多少层嵌套的引用类型,深拷贝都会递归地复制所有层级的属性,确保新对象和原始对象互不影响。

3.1 JSON.parse(JSON.stringify()):最简单的深拷贝方法(但有局限性)

readme.md中提到了“JSON.parse(JSON.stringify()) 最简单的方法”,这确实是实现深拷贝的一种常见且简洁的方式。其原理是:

  1. JSON.stringify():将JavaScript对象转换为JSON字符串。
  2. JSON.parse():将JSON字符串解析为JavaScript对象。

通过这种方式,原始对象的所有属性都会被序列化成字符串,再反序列化成新的对象,从而切断了所有引用关系,实现了深拷贝。

示例

const obj = {
  a: 1,
  b: {
    c: 2
  }
};
​
const deepCopyObj = JSON.parse(JSON.stringify(obj));
deepCopyObj.b.c = 3;
​
console.log(obj.b.c);         // 2 (原始对象未受影响)
console.log(deepCopyObj.b.c); // 3

3.2 JSON.parse(JSON.stringify())的局限性

尽管JSON.parse(JSON.stringify())简单易用,但它存在明显的局限性,这也是面试中常考的“坑”:

  • 不能拷贝函数(FunctionJSON.stringify()在遇到函数时会将其忽略。

    const objWithFunc = { a: 1, fn: () => console.log("hello") };
    const copy = JSON.parse(JSON.stringify(objWithFunc));
    console.log(copy); // { a: 1 } (fn 丢失)
    
  • 不能拷贝undefinedJSON.stringify()在遇到undefined时会将其忽略。

    const objWithUndefined = { a: undefined, b: 2 };
    const copy = JSON.parse(JSON.stringify(objWithUndefined));
    console.log(copy); // { b: 2 } (a 丢失)
    
  • 不能拷贝Symbol类型的属性JSON.stringify()在遇到Symbol类型的键值对时会将其忽略。

    const sym = Symbol("foo");
    const objWithSymbol = { [sym]: "value", a: 1 };
    const copy = JSON.parse(JSON.stringify(objWithSymbol));
    console.log(copy); // { a: 1 } (Symbol 属性丢失)
    
  • 不能拷贝循环引用的对象:如果对象中存在循环引用(即对象A引用对象B,对象B又引用对象A),JSON.stringify()会报错。

    const objA = {};
    const objB = {};
    objA.b = objB;
    objB.a = objA; // 循环引用
    // JSON.stringify(objA); // TypeError: Converting circular structure to JSON
    
  • 不能拷贝RegExpDate对象JSON.stringify()会将其转换为字符串,而不是保留其原始类型。

    const objWithSpecial = { date: new Date(), reg: /abc/g };
    const copy = JSON.parse(JSON.stringify(objWithSpecial));
    console.log(copy.date); // 字符串形式的日期
    console.log(copy.reg);  // {} (空对象,RegExp丢失)
    
  • 不能拷贝不可枚举的属性JSON.stringify()只会序列化对象自身的可枚举属性。

这些局限性使得JSON.parse(JSON.stringify())只适用于那些不包含特殊类型(函数、undefinedSymbol、循环引用、RegExpDate等)的纯数据对象。当遇到这些情况时,就需要“手写实现高级深拷贝”,这正是面试官最喜欢考察的环节。

4. 手写深拷贝:递归与循环引用处理

手写深拷贝是面试中考察候选人对数据结构、递归、类型判断以及内存管理理解深度的重要题目。一个完善的深拷贝函数需要考虑以下几点:

  1. 基本数据类型:直接返回。

  2. 引用数据类型

    • 对象和数组:需要递归遍历其内部属性/元素。
    • 特殊对象:如DateRegExp等,需要特殊处理以保留其类型。
    • 函数、Symbolundefined:根据需求决定是否忽略或特殊处理。
  3. 循环引用:这是手写深拷贝的难点,需要一个机制来记录已经拷贝过的对象,避免无限递归。

4.1 基础版深拷贝(不处理循环引用)

function deepClone(obj) {
  // 1. 处理基本数据类型和null
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
​
  // 2. 处理Date对象
  if (obj instanceof Date) {
    return new Date(obj);
  }
​
  // 3. 处理RegExp对象
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }
​
  // 4. 处理数组或对象
  const newObj = Array.isArray(obj) ? [] : {};
​
  for (let key in obj) {
    // 确保是对象自身的属性,而不是原型链上的属性
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      newObj[key] = deepClone(obj[key]); // 递归调用
    }
  }
​
  return newObj;
}

这个基础版本可以处理大部分情况,但无法处理循环引用。当遇到循环引用时,deepClone函数会陷入无限递归,最终导致栈溢出。

4.2 进阶版深拷贝(处理循环引用)

为了处理循环引用,我们需要引入一个MapWeakMap来存储已经拷贝过的对象及其对应的副本。在每次递归拷贝之前,先检查当前对象是否已经在Map中存在,如果存在,则直接返回其对应的副本,从而避免重复拷贝和无限递归。

function deepCloneWithCircular(obj, hash = new WeakMap()) {
  // 1. 处理基本数据类型和null
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
​
  // 2. 处理循环引用:如果对象已存在于hash中,直接返回其副本
  if (hash.has(obj)) {
    return hash.get(obj);
  }
​
  // 3. 处理Date对象
  if (obj instanceof Date) {
    return new Date(obj);
  }
​
  // 4. 处理RegExp对象
  if (obj instanceof RegExp) {
    return new RegExp(obj);
  }
​
  // 5. 处理数组或对象
  const newObj = Array.isArray(obj) ? [] : {};
​
  // 在递归之前,将当前对象及其副本存入hash,防止循环引用
  hash.set(obj, newObj);
​
  for (let key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      newObj[key] = deepCloneWithCircular(obj[key], hash); // 递归调用,并传递hash
    }
  }
​
  return newObj;
}

WeakMap的优势

这里使用WeakMap而不是Map是因为WeakMap的键是弱引用。这意味着如果原始对象没有其他引用,垃圾回收机制可以回收它,而不会因为WeakMap中存在对它的引用而阻止回收,从而避免内存泄漏。

4.3 考虑更多特殊情况(可选)

一个更完善的深拷贝函数可能还需要考虑:

  • Symbol属性:如果需要拷贝Symbol属性,可以使用Object.getOwnPropertySymbols()
  • 不可枚举属性:如果需要拷贝不可枚举属性,可以使用Object.getOwnPropertyDescriptors()Object.getOwnPropertyNames()
  • 原型链:如果需要保留原型链,可以使用Object.getPrototypeOf()Object.setPrototypeOf()

这些通常在面试中作为加分项,具体实现会更加复杂。

5. 总结(上篇)

在JavaScript中,深浅拷贝是理解内存管理和数据操作的基石。浅拷贝只复制对象的第一层属性,而深拷贝则递归复制所有层级的属性,确保新旧对象完全独立。Object.assign()、扩展运算符等是常见的浅拷贝方法,而JSON.parse(JSON.stringify())虽然简单,但存在明显的局限性。当遇到函数、undefinedSymbol、循环引用等特殊情况时,我们需要手写深拷贝函数,并通过WeakMap等机制来处理循环引用问题。

理解这些概念和实现方式,不仅能帮助你应对面试中的挑战,更能让你在实际开发中写出更健壮、更安全的代码。在下一篇中,我们将结合更多实际代码示例,深入探讨深浅拷贝在不同业务场景下的应用,以及如何选择最合适的拷贝策略。