深拷贝 vs 浅拷贝:前端面试必考的"孪生陷阱"全解析

92 阅读8分钟

序言:揭秘深浅拷贝,征服前端面试高频考点!

在前端开发的征途中,深拷贝与浅拷贝就像一对形影不离的“孪生兄弟”,也是面试官们最爱考察的经典题目之一。它们看似简单,却暗藏玄机,是理解 JavaScript 对象操作、内存管理和数据完整性的关键。混淆了它们,轻则代码出现隐蔽 Bug,重则引发数据安全风险。今天,就让我们一起深入剖析深浅拷贝的机制,掌握不同场景下的最佳实践,让你在面试中游刃有余,轻松过关

一、浅拷贝利器:Object.assign() 详解

Object.assign() 是 JavaScript 中实现浅拷贝最常用的方法之一,以其简洁的语法和强大的合并能力深受开发者喜爱。

1. 核心用法:属性复制与合并

Object.assign(target, ...sources) 的核心功能是将一个或多个源对象(sources)的所有可枚举自有属性复制到目标对象(target)上,并返回修改后的目标对象。它遵循“后来居上”原则:同名属性会被后续源对象覆盖

const target = { a: 1, b: 2 };
const source = { c: 3, d: 4 };

const result = Object.assign(target, source); // 将 source 的属性复制到 target

console.log(target); // { a: 1, b: 2, c: 3, d: 4 } (target 被修改)
console.log(source); // { c: 3, d: 4 } (source 不变)
console.log(result === target); // true (返回的就是被修改的 target 本身)

关键特性:Object.assign() 会直接修改传入的第一个参数(target)!  若想避免修改原对象,最佳实践是使用一个空对象 {} 作为目标const newObj = Object.assign({}, obj1, obj2);

2. 实战:属性冲突与覆盖

处理多个源对象且属性名冲突时,后传入对象的属性值将覆盖前面对象的属性值:

const configDefaults = { theme: 'light', fontSize: 14 };
const userSettings = { theme: 'dark', showNotifications: true };
const adminOverrides = { fontSize: 16 };

const finalConfig = Object.assign({}, configDefaults, userSettings, adminOverrides);
console.log(finalConfig); // { theme: 'dark', fontSize: 16, showNotifications: true }

3. 拷贝 Symbol 类型属性

Object.assign() 能够正确地复制 Symbol 类型的属性键:

const uniqueId = Symbol('userId');
const user = {
  [uniqueId]: 12345,
  name: 'Alice'
};

const userCopy = Object.assign({}, user);
console.log(userCopy); // { name: 'Alice', [Symbol(userId)]: 12345 } (Symbol 属性被成功复制)

4. 特殊处理:基本类型与 null/undefined
当源参数是基本类型(StringNumberBoolean)时,Object.assign() 会将其临时包装成对应的包装对象后再复制其可枚举的自有属性(主要是字符串的索引属性)。null 和 undefined 作为源参数则会被完全忽略

const str = 'hello';
const num = 42;
const sym = Symbol('test');
const bool = true;

const result = Object.assign({}, str, null, num, undefined, bool, sym);
// 基本类型被包装,null/undefined 被跳过。
// 只有字符串包装对象有可枚举的自有属性(索引 0, 1, 2...)
console.log(result); // { 0: 'h', 1: 'e', 2: 'l', 3: 'l', 4: 'o' }

二、Object.assign() 的“阿喀琉斯之踵”:浅拷贝的局限性

虽然 Object.assign() 使用便捷,但其本质是浅拷贝(Shallow Copy) 。它只复制对象属性的第一层。如果属性值本身是对象(包括数组)或函数,那么拷贝的仅仅是该属性值在内存中的引用(地址) ,而非创建该值的一个新副本。

const original = {
  id: 1,
  profile: { // 嵌套对象
    name: '小明',
    hobbies: ['篮球', '羽毛球'] // 嵌套数组
  }
};

// 浅拷贝 original
const shallowCopy = Object.assign({}, original);

// 修改浅拷贝对象的第一层属性 (id) - 不影响原对象
shallowCopy.id = 2;
console.log(original.id); // 1 (原对象未受影响)

// 修改浅拷贝对象的嵌套对象属性 (profile.name) - 影响原对象!
shallowCopy.profile.name = '小红';
console.log(original.profile.name); // '小红' (原对象也被修改了!)

// 修改浅拷贝对象的嵌套数组 (profile.hobbies) - 同样影响原对象!
shallowCopy.profile.hobbies.push('画画');
console.log(original.profile.hobbies); // ['篮球', '羽毛球', '画画'] (原对象也被修改了!)

问题根源:  shallowCopy.profile 和 original.profile 指向内存中的同一个对象。对 shallowCopy.profile 的任何修改(如修改 name 或向 hobbies 数组 push),都会直接反映到 original.profile 上。

结论:  当对象结构层级较深(存在嵌套对象/数组) 时,使用 Object.assign() 进行浅拷贝存在数据意外共享的风险,可能导致难以追踪的 Bug 或敏感数据泄露。此时,我们需要寻求深拷贝(Deep Copy)  的解决方案。

三、深拷贝利器:JSON.parse(JSON.stringify())

对于需要完全隔离源对象和拷贝对象,特别是处理嵌套结构的场景,JSON.parse(JSON.stringify()) 是一种非常常用的深拷贝方法。

const original = {
  id: 1,
  profile: {
    name: '小明',
    hobbies: ['篮球', '羽毛球']
  }
};

// 使用 JSON 方法进行深拷贝
const deepCopy = JSON.parse(JSON.stringify(original));

// 修改深拷贝对象的所有层级
deepCopy.id = 2;
deepCopy.profile.name = '小军';
deepCopy.profile.hobbies.push('画画');

console.log(deepCopy);
// 输出: { id: 2, profile: { name: '小军', hobbies: ['篮球', '羽毛球', '画画'] } }

console.log(original);
// 输出: { id: 1, profile: { name: '小明', hobbies: ['篮球', '羽毛球'] } } (原对象纹丝不动!)

核心原理:

  1. JSON.stringify(source): 将 JavaScript 对象或值序列化(Serialization)  成一个 JSON 格式的字符串。这个过程会递归遍历对象的所有层级。
  2. JSON.parse(jsonString): 将 JSON 格式的字符串解析(Parsing)  成一个全新的 JavaScript 对象。新对象与原对象在内存中完全独立,没有任何引用关系。

重大局限:
JSON.parse(JSON.stringify()) 虽然能有效解决嵌套对象的深拷贝问题,但它不是万能的,存在以下无法拷贝或会丢失的情况:

  • 函数(Function) : 在序列化过程中会被完全忽略
  • undefined: 作为属性值时,在序列化过程中会被忽略
  • Symbol: 作为属性键或值,在序列化过程中都会被忽略
  • 循环引用(Circular References) : 如果对象 A 的属性引用了对象 B,而对象 B 的属性又引用了对象 A,形成循环引用。JSON.stringify() 会直接报错Uncaught TypeError: Converting circular structure to JSON)。
  • 特殊对象: 如 Date 对象会被转换成 ISO 格式字符串(解析后不再是 Date 实例,而是字符串),RegExpMapSetBlob 等类型通常会被序列化为空对象 {} 或直接丢失
  • 原型链(Prototype Chain) : 新对象的原型指向 Object.prototype,原对象的自定义原型链信息会丢失。

结论:  JSON.parse(JSON.stringify()) 是处理纯数据对象(不包含函数、Symbol、undefined、循环引用和特殊内置对象)  深拷贝的快速有效方案。一旦涉及上述无法处理的类型或结构,就需要寻求其他深拷贝方法(如递归拷贝、使用第三方库 lodash 的 _.cloneDeep、或现代浏览器中的 structuredClone())。

四、拷贝江湖:不同场景下的兵器谱

掌握了深浅拷贝的核心原理和代表方法,我们就能根据实际场景,灵活选用最合适的“兵器”:

  1. 基础数据类型 (Primitive Values):= 赋值足矣

    • StringNumberBooleannullundefinedSymbolBigInt 都是存储在栈内存中的基础类型。

    • 使用 = 赋值时,会直接复制值本身。新变量和原变量完全独立

    let price = 100;
    let discountedPrice = price; // 复制值 100
    discountedPrice = 80;      // 修改新变量
    console.log(price);        // 100 (原变量不变)
    console.log(discountedPrice); // 80
    
  2. 浅层对象/数组:便捷的浅拷贝方法

    • 当对象/数组只有一层结构(没有嵌套对象/数组) ,或你明确只需要拷贝第一层不介意共享内层引用时,可以使用以下浅拷贝方法:

      • Object.assign({}, obj) : 复制对象的可枚举自有属性。

      • Array.prototype.slice() : 复制数组的一部分(默认复制整个数组)。

      • Array.prototype.concat([]) : 合并数组(与空数组合并即复制)。

      • 展开运算符 [...arr] / { ...obj }: ES6 提供的简洁语法。

    // 简单数组 - 浅拷贝有效
    const fruits = ['apple', 'banana', 'cherry'];
    const fruitsCopy = fruits.slice(); // 或 [...fruits], fruits.concat()
    fruitsCopy[1] = 'orange';
    console.log(fruits);    // ['apple', 'banana', 'cherry'] (原数组不变)
    console.log(fruitsCopy); // ['apple', 'orange', 'cherry']
    
    // 嵌套数组 - 浅拷贝的局限性 (第二层仍是引用)
    const matrix = [[1, 2], [3, 4], [5, [6, 7]]];
    const matrixCopy = matrix.slice(); // 浅拷贝
    matrixCopy[0][0] = 100;          // 修改第一层数组里的嵌套数组元素
    matrixCopy[2][1][0] = 600;       // 修改更深层的元素
    console.log(matrix);    // [[100, 2], [3, 4], [5, [600, 7]]] (原数组被修改了!)
    console.log(matrixCopy); // [[100, 2], [3, 4], [5, [600, 7]]]
    
  3. 纯数据对象的深拷贝:JSON.parse(JSON.stringify())

    • 如前所述,适用于不包含函数、Symbol、undefined、循环引用和特殊内置对象的纯数据对象。优点是原生支持、简单直接。缺点是局限性明显。

五、总结与通关钥匙

深拷贝与浅拷贝的区分,核心在于是否创建了嵌套对象/数组的新副本,从而断开原对象与拷贝对象在内层数据上的引用连接。

  • 浅拷贝 (Object.assignsliceconcatspread) : 只复制第一层属性。嵌套对象/数组是共享的引用。操作简单高效,适用于简单结构或明确共享内层的场景。警惕数据意外共享风险!
  • 深拷贝 (JSON.parse(JSON.stringify)_.cloneDeepstructuredClone) : 递归复制所有层级属性。嵌套对象/数组也是全新的副本。源对象与拷贝对象完全隔离。适用于需要完全独立副本的场景。注意不同方法的局限性(函数、Symbol、循环引用等)

面试通关钥匙:

  1. 清晰定义:  能准确解释深拷贝与浅拷贝的根本区别(内存引用关系)。

  2. 掌握代表方法:

    • 浅拷贝:熟练说出 Object.assign, 数组的 slice/concat/展开运算符 及其行为。
    • 深拷贝:重点掌握 JSON.parse(JSON.stringify()) 的原理、优势(纯数据深拷贝)  和 重大局限(函数、Symbol、循环引用等) 。了解递归实现或库方案(如 lodash.cloneDeep)作为补充。知道现代 structuredClone() API(兼容性需注意)。
  3. 理解适用场景:  能根据数据结构(是否有嵌套?)和需求(是否需要完全隔离?)选择合适的拷贝方式。

  4. 警惕引用陷阱:  能识别浅拷贝导致的数据共享问题,并解释原因。

  5. 动手能力:  面试官可能会要求手写一个简单的深拷贝函数(处理基本类型、对象、数组,考虑循环引用是加分项)。

理解了这些核心要点,你就能在深浅拷贝的“拷”问中从容应对,轻松拿下这道面试高频题!加油,未来的前端大牛!