JavaScript 深拷贝与浅拷贝:从底层原理到面试实战,全面解析与高级实现

241 阅读10分钟

引言:为什么深浅拷贝是前端开发的核心知识?

在 JavaScript 的日常开发中,对象和数组的拷贝操作无处不在。无论是配置合并、状态管理、数据缓存,还是组件通信,我们几乎每天都在与“拷贝”打交道。然而,看似简单的 = 赋值、Object.assign()JSON.parse(JSON.stringify()),背后却隐藏着 JavaScript 引用机制的深刻原理。

更关键的是,在前端面试中,深拷贝与浅拷贝是必考内容。它不仅考察你对语言特性的理解,还测试你对内存管理、递归、类型判断、循环引用等核心编程能力的掌握程度。

本文将从 Object.assign() 出发,系统性地讲解深浅拷贝的底层原理、API 使用细节、常见陷阱、高级实现方案,并最终模拟一场真实的面试问答,带你从“会用”走向“精通”。


一、从 Object.assign() 开始:浅拷贝的起点

1.1 什么是 Object.assign()

Object.assign() 是 ES6 引入的一个方法,用于将一个或多个源对象(source objects)的所有可枚举(enumerable)属性复制到目标对象(target object),并返回修改后的目标对象

语法:

Object.assign(target, ...sources)
  • target:目标对象(会被修改)
  • ...sources:一个或多个源对象
  • 返回值:被修改后的目标对象

示例:

const target = { a: 1 };
const source = { b: 2 };
const result = Object.assign(target, source);

console.log(result); // { a: 1, b: 2 }
console.log(target === result); // true

关键点:Object.assign() 返回的是目标对象本身,而不是一个新对象!

这意味着:targetresult 指向同一个内存地址Object.assign() 是一个浅拷贝(Shallow Copy),它只复制对象的第一层属性。


1.2 浅拷贝的本质:值 vs 引用

要理解深浅拷贝,必须先理解 JavaScript 的数据类型与内存分配机制

1.2.1 基本数据类型(值类型)

  • 包括:numberstringbooleannullundefinedsymbolbigint
  • 存储在**栈内存(stack)**中
  • 赋值时是值传递,互不影响
let a = 1;
let b = a;
b = 2;
console.log(a); // 1

1.2.2 复杂数据类型(引用类型)

  • 包括:objectarrayfunction
  • 存储在**堆内存(heap)**中
  • 变量存储的是指向堆内存的地址(引用)
  • 赋值时是引用传递,多个变量可能指向同一个对象
let obj1 = { name: 'zhangsan', age: 18 };
let obj2 = obj1; // obj2 指向 obj1 的内存地址
obj2.age = 20;
console.log(obj1.age); // 20 —— obj1 也被修改了!

🔥 这就是“引用式赋值”的陷阱:没有真正“拷贝”,只是共享了同一个对象。


1.3 Object.assign() 的浅拷贝行为

我们通过一个经典例子来验证 Object.assign() 的浅拷贝特性:

const target = { a: 1 };
const source = {
  b: {
    name: 'zhangsan',
    age: 18,
    hobby: ['吃饭', '睡觉', '打豆豆']
  },
  c: 1
};

Object.assign(target, source);

// 修改嵌套对象
target.b.age = 20;
target.b.hobby.push('学习');
target.c = 2;

console.log(source.b.age);     // 20
console.log(source.b.hobby);   // ['吃饭', '睡觉', '打豆豆', '学习']
console.log(source.c);         // 1

📌 结论:

  • source.b 是一个对象,Object.assign() 只复制了它的引用,所以 target.bsource.b 指向同一个对象。
  • 修改 target.b.age 会影响 source.b.age
  • c 是基本类型,复制的是值,互不影响。

1.4 Object.assign() 的使用场景

场景 1:配置对象合并(最常见)

function createUser(options) {
  const defaults = {
    name: 'zhangsan',
    age: 18,
    isAdmin: false
  };
  // 合并默认配置和用户传参
  const config = Object.assign({}, defaults, options);
  console.log(config);
}

createUser({ name: 'lisi', age: 20 });
// 输出: { name: 'lisi', age: 20, isAdmin: false }

技巧:目标对象设为 {} 空对象,避免污染原对象。

场景 2:环境配置优先级

const baseConfig = { api: '/api', timeout: 500 };
const envConfig = { timeout: 1000, debug: true };

const finalConfig = Object.assign({}, baseConfig, envConfig);
console.log(finalConfig);
// 输出: { api: '/api', timeout: 1000, debug: true }

后面的源对象会覆盖前面的同名属性。


1.5 Object.assign() 的边界情况

1.5.1 传入 nullundefined

const target = { a: 1 };
Object.assign(target, null);
Object.assign(target, undefined);
console.log(target); // { a: 1 } —— 不会报错,但也不会拷贝任何属性

1.5.2 单个参数

const obj = { name: '张三' };
Object.assign(obj); // 相当于 Object.assign(obj, {})
console.log(obj); // { name: '张三' } —— 无变化

1.5.3 Symbol 作为键

const s = Symbol('id');
const source = { [s]: 123, a: 1 };
const target = [];

Object.assign(target, source);
console.log(target); // [1] —— Symbol 键的属性被忽略!

⚠️ 注意:Object.assign() 会拷贝 Symbol 键的属性,但目标如果是数组,Symbol 键不会被设置为数组索引。


二、深拷贝的常用方法:JSON.parse(JSON.stringify())

虽然 Object.assign() 是浅拷贝,但有一个“取巧”的方法可以实现深拷贝:

const newObj = JSON.parse(JSON.stringify(source));

2.1 原理:序列化 + 反序列化

  • JSON.stringify():将对象序列化为 JSON 字符串
  • JSON.parse():将 JSON 字符串反序列化为新对象
  • 由于序列化过程会“扁平化”对象结构,新对象与原对象完全独立

示例:

const source = {
  b: {
    name: 'zhangsan',
    age: 18,
    hobby: ['吃饭', '睡觉', '打豆豆']
  },
  c: 1
};

const newObj = JSON.parse(JSON.stringify(source));
newObj.b.age = 20;
newObj.b.hobby.push('学习');
newObj.c = 2;

console.log(source.b.age);     // 18
console.log(source.b.hobby);   // ['吃饭', '睡觉', '打豆豆']
console.log(source.c);         // 1

成功实现深拷贝!


2.2 JSON.parse(JSON.stringify()) 的致命缺陷

尽管简单有效,但这种方法有严重限制,不能用于所有场景。

缺陷 1:无法处理函数

const obj = { fn: function() { console.log('hello'); } };
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.fn); // undefined

函数不是合法的 JSON 值,会被忽略。

缺陷 2:无法处理 Symbol

const s = Symbol('id');
const obj = { [s]: 'symbol value' };
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy[s]); // undefined

Symbol 不是合法的 JSON 值。

缺陷 3:undefined 会被忽略

const obj = { a: undefined, b: 1 };
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy); // { b: 1 } —— a 属性消失

undefined 不是合法的 JSON 值。

缺陷 4:循环引用会报错

const obj = { name: 'obj' };
obj.self = obj; // 循环引用
JSON.stringify(obj); // TypeError: Converting circular structure to JSON

JSON.stringify() 无法序列化循环引用的对象。

缺陷 5:Date 对象会变成字符串

const obj = { date: new Date() };
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.date); // "2025-08-14T15:30:00.000Z" —— 字符串,不是 Date 对象

⚠️ 类型丢失!

缺陷 6:RegExpErrorMapSet 等特殊对象无法正确拷贝

const obj = { reg: /abc/, map: new Map() };
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.reg); // {} —— 正则表达式变空对象
console.log(copy.map); // {} —— Map 变空对象

特殊对象被错误地序列化。


2.3 总结:JSON.parse(JSON.stringify()) 的适用场景

场景是否适用
纯数据对象(只有基本类型和嵌套对象/数组)✅ 推荐
包含函数、Symbol、undefined❌ 不适用
包含 Date、RegExp、Map、Set⚠️ 类型丢失,慎用
循环引用对象❌ 会报错

结论:仅适用于简单的、纯数据的 JSON 结构。


三、手写深拷贝:从基础到高级

JSON.parse(JSON.stringify()) 无法满足需求时,我们必须手写深拷贝函数

3.1 基础版本:递归拷贝

function clone(source) {
  if (typeof source !== 'object' || source === null) {
    return source; // 基本类型或 null 直接返回
  }

  const cloneTarget = Array.isArray(source) ? [] : {};

  for (let key in source) {
    if (source.hasOwnProperty(key)) {
      cloneTarget[key] = clone(source[key]); // 递归拷贝
    }
  }

  return cloneTarget;
}

测试:

const target = {
  field1: 1,
  field2: undefined,
  field3: 'hxt',
  field4: {
    child: 'child',
    child2: { child2: 'child2' }
  },
  field5: [2, 4, 8]
};

const obj = clone(target);
obj.field4.child = 'child2';
console.log(target.field4.child); // 'child' —— 未受影响,深拷贝成功

基础功能实现!


3.2 高级版本:解决循环引用(WeakMap 缓存)

如果对象存在循环引用,上述递归版本会栈溢出(Stack Overflow)

问题示例:

const target = { a: 1 };
target.self = target; // 循环引用
clone(target); // Maximum call stack size exceeded

解决方案:使用 WeakMap 缓存已拷贝的对象

function deepClone(target, map = new WeakMap()) {
  // 处理基本类型和 null
  if (typeof target !== 'object' || target === null) {
    return target;
  }

  // 处理循环引用
  if (map.has(target)) {
    return map.get(target);
  }

  // 初始化新对象
  const cloneTarget = Array.isArray(target) ? [] : {};

  // 缓存当前对象,避免重复拷贝
  map.set(target, cloneTarget);

  // 遍历所有可枚举属性(包括 Symbol 键)
  const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
  for (let key of keys) {
    const descriptor = Object.getOwnPropertyDescriptor(target, key);
    if (descriptor.enumerable || key === Symbol.for('non-enumerable')) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }

  return cloneTarget;
}

测试循环引用:

const obj = { name: 'obj' };
obj.self = obj;
const copy = deepClone(obj);
console.log(copy.self === copy); // true —— 循环引用被正确处理

成功解决循环引用问题!


3.3 完整版本:支持所有内置对象

真正的深拷贝应支持 DateRegExpMapSetFunction 等。

function deepClone(target, map = new WeakMap()) {
  // 1. 基本类型和 null
  if (typeof target !== 'object' || target === null) {
    return target;
  }

  // 2. 处理循环引用
  if (map.has(target)) {
    return map.get(target);
  }

  // 3. 处理特殊对象
  const constructors = {
    '[object Date]': (t) => new Date(t),
    '[object RegExp]': (t) => new RegExp(t.source, t.flags),
    '[object Map]': (t) => {
      const mapCopy = new Map();
      for (let [key, value] of t) {
        mapCopy.set(deepClone(key, map), deepClone(value, map));
      }
      return mapCopy;
    },
    '[object Set]': (t) => {
      const setCopy = new Set();
      for (let value of t) {
        setCopy.add(deepClone(value, map));
      }
      return setCopy;
    },
    '[object Promise]': (t) => {
      // Promise 通常不拷贝,返回原对象或新建
      return t;
    },
    '[object Function]': (t) => {
      // 函数:返回原函数(不可拷贝)或克隆函数体(复杂)
      return t;
    }
  };

  const tag = Object.prototype.toString.call(target);
  if (constructors[tag]) {
    const copy = constructors[tag](target);
    map.set(target, copy);
    return copy;
  }

  // 4. 普通对象和数组
  const cloneTarget = Array.isArray(target) ? [] : {};
  map.set(target, cloneTarget);

  const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
  for (let key of keys) {
    const descriptor = Object.getOwnPropertyDescriptor(target, key);
    if (descriptor.enumerable) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }

  return cloneTarget;
}

功能总结:

  • ✅ 支持基本类型
  • ✅ 支持对象、数组
  • ✅ 支持 DateRegExpMapSet
  • ✅ 支持 Symbol
  • ✅ 支持循环引用(WeakMap
  • ✅ 支持函数(返回原函数)
  • ✅ 处理 null

四、其他浅拷贝方法

除了 Object.assign(),还有以下常用浅拷贝方法:

方法说明
Array.prototype.slice()返回数组浅拷贝
Array.prototype.concat()合并数组,常用于拷贝
扩展运算符 ...ES6 语法,简洁易读

示例:

const arr = [1, 2, { name: 'zhangsan' }];
const arr1 = arr.slice();        // 浅拷贝
const arr2 = arr.concat();       // 浅拷贝
const arr3 = [...arr];           // 浅拷贝

arr1[2].name = 'lisi';
console.log(arr[2].name); // 'lisi' —— 引用共享

都只拷贝第一层。


五、Map 与 WeakMap:ES6 的新数据结构

5.1 Map:键可以是任意类型

const target = new Map();
const obj = { a: 1 };

target.set(obj, 'value');
console.log(target.get(obj)); // 'value'

obj = null; // 手动释放引用
// 但 Map 仍持有 obj 的引用,不会被垃圾回收

5.2 WeakMap:弱引用,可被垃圾回收

const target2 = new WeakMap();
const obj2 = { name: 'zhangsan' };

target2.set(obj2, 'secret');
obj2 = null; // 对象可被垃圾回收
console.log(target2.get(obj2)); // undefined

🔥 WeakMap 常用于缓存、私有属性,避免内存泄漏。


六、面试实战

请解释 JavaScript 中的深拷贝和浅拷贝,并手写一个完整的深拷贝函数。


1. 基本概念

  • 浅拷贝(Shallow Copy):只复制对象的第一层属性。如果属性是基本类型,复制值;如果是引用类型,复制的是引用地址。因此,修改嵌套对象会影响原对象。
  • 深拷贝(Deep Copy):递归复制对象的所有层级,新对象与原对象完全独立,互不影响。

2. 浅拷贝方法

  • Object.assign(target, source):将源对象的可枚举属性复制到目标对象。注意:返回的是目标对象本身,不是新对象。
  • 数组:slice()concat()、扩展运算符 ...
  • 特点:只复制第一层,嵌套对象共享引用。

3. 深拷贝方法

  • JSON.parse(JSON.stringify(obj)):简单但有严重限制:

    • 无法处理函数、Symbolundefined
    • Date 变字符串
    • 循环引用会报错
    • 不支持 MapSet
    • 仅适用于纯数据对象。
  • 手写深拷贝:更灵活,可处理各种边界情况。

4. 手写深拷贝实现

function deepClone(target, map = new WeakMap()) {
  // 1. 基本类型和 null
  if (typeof target !== 'object' || target === null) {
    return target;
  }

  // 2. 循环引用检测
  if (map.has(target)) {
    return map.get(target);
  }

  // 3. 处理内置对象
  const tag = Object.prototype.toString.call(target);
  const constructors = {
    '[object Date]': () => new Date(target),
    '[object RegExp]': () => new RegExp(target.source, target.flags),
    '[object Map]': () => {
      const mapCopy = new Map();
      for (let [k, v] of target) {
        mapCopy.set(deepClone(k, map), deepClone(v, map));
      }
      return mapCopy;
    },
    '[object Set]': () => {
      const setCopy = new Set();
      for (let v of target) {
        setCopy.add(deepClone(v, map));
      }
      return setCopy;
    },
    '[object Function]': () => target // 函数通常不拷贝
  };

  if (constructors[tag]) {
    const copy = constructors[tag]();
    map.set(target, copy);
    return copy;
  }

  // 4. 普通对象/数组
  const cloneTarget = Array.isArray(target) ? [] : {};
  map.set(target, cloneTarget);

  // 5. 拷贝所有属性(包括 Symbol)
  const keys = [...Object.keys(target), ...Object.getOwnPropertySymbols(target)];
  for (let key of keys) {
    if (Object.getOwnPropertyDescriptor(target, key).enumerable) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }

  return cloneTarget;
}

5. 关键点总结

  • 使用 WeakMap 避免循环引用导致的栈溢出。
  • 正确处理 DateRegExpMapSet 等特殊对象。
  • 支持 Symbol 作为键。
  • 保持属性的可枚举性。
  • 函数通常不拷贝,直接返回原函数。

6. 应用场景

  • 状态管理(如 Redux 中的不可变更新)
  • 配置备份
  • 数据缓存
  • 表单重置

结语

深浅拷贝不仅是 JavaScript 的基础概念,更是理解语言内存模型、引用机制的关键。掌握 Object.assign()JSON.parse(JSON.stringify()) 的局限性,并能手写一个健壮的深拷贝函数,是每个前端开发者必备的技能。

在实际开发中,建议:

  • 简单场景用 JSON.stringify
  • 复杂场景用 Lodash 的 _.cloneDeep
  • 面试时手写 deepClone 展示基本功

希望本文能帮助你彻底掌握深浅拷贝,从容应对面试与实战!