跳到主要内容
预计阅读 39 分钟

Javascript篇 | 深浅拷贝

前言

深拷贝和浅拷贝是前端面试的经典题目,几乎每个前端工程师都被问过”手写一个深拷贝”。

这道题看起来简单,但实际上水很深:

  • 你以为 JSON.parse(JSON.stringify(obj)) 就够了?那 undefined、函数、Date、正则怎么办?
  • 你以为递归遍历就行了?那循环引用怎么处理?
  • 你知道 structuredClone 吗?它能解决所有问题吗?

面试官通过这道题,不仅考你对 JavaScript 数据类型的理解,还考你的代码功底和对边界情况的处理能力。今天我们就从基本类型说起,一步步实现一个生产级别的深拷贝。

诊断自测

在开始之前,试着回答以下问题:

1. 以下代码输出什么?

const a = { x: 1, y: { z: 2 } };
const b = { ...a };

b.x = 10;
b.y.z = 20;

console.log(a.x);
console.log(a.y.z);

2. JSON.parse(JSON.stringify(obj)) 有哪些局限?至少说出 3 个。

3. 以下对象能被正确深拷贝吗?

const obj = { a: 1 };
obj.self = obj; // 循环引用
点击查看答案

第1题: a.x 输出 1a.y.z 输出 20。展开运算符是浅拷贝,b.x 是基本类型的独立副本,修改不影响 a.x。但 b.ya.y 指向同一个对象,修改 b.y.z 等于修改 a.y.z

第2题: JSON 方法的局限包括:

  • 丢失 undefined、函数、Symbol
  • Date 对象变成字符串
  • 正则变成空对象 {}
  • NaNInfinity 变成 null
  • 无法处理循环引用(直接报错)
  • 丢失原型链
  • 不支持 MapSetArrayBuffer

第3题:JSON.parse(JSON.stringify(obj)) 会报 TypeError: Converting circular structure to JSON。需要用 WeakMap 记录已拷贝的对象来处理循环引用。structuredClone(obj) 可以正确处理。

如果三道题都答对了,说明你基础不错!继续阅读可以查漏补缺。

基本类型 vs 引用类型

这是理解深浅拷贝的前提。

基本类型(Primitive)

7 种基本类型:stringnumberbooleannullundefinedsymbolbigint

基本类型存储在栈内存中,赋值时直接复制值:

let a = 42;
let b = a;   // 复制值

b = 100;
console.log(a); // 42 —— 不受影响

引用类型(Reference)

引用类型包括:ObjectArrayFunctionDateRegExpMapSet

引用类型存储在堆内存中,变量保存的是指向堆内存的引用(指针)

let obj1 = { name: 'Alice' };
let obj2 = obj1;  // 复制的是引用,不是对象本身

obj2.name = 'Bob';
console.log(obj1.name); // 'Bob' —— obj1 也被修改了!

💡 核心:基本类型赋值是”复制值”,引用类型赋值是”复制指针”。理解了这点,深浅拷贝就没什么难的了。

浅拷贝

浅拷贝:创建新对象,只复制第一层属性。如果属性是基本类型,复制值;如果属性是引用类型,复制引用。

Object.assign

const original = {
  name: 'Alice',
  age: 25,
  address: {
    city: 'Beijing'
  }
};

const copy = Object.assign({}, original);

copy.name = 'Bob';          // 不影响 original
copy.address.city = 'Shanghai'; // 影响 original!

console.log(original.name);          // 'Alice'
console.log(original.address.city);  // 'Shanghai' ← 被修改了

展开运算符

const original = { a: 1, b: { c: 2 } };
const copy = { ...original };

// 效果和 Object.assign 一样,都是浅拷贝
copy.b.c = 99;
console.log(original.b.c); // 99

数组的浅拷贝

const arr = [1, 2, { x: 3 }];

// 以下方法都是浅拷贝
const copy1 = [...arr];
const copy2 = arr.slice();
const copy3 = Array.from(arr);
const copy4 = arr.concat();

copy1[2].x = 99;
console.log(arr[2].x); // 99 —— 共享引用

浅拷贝够用吗?

如果对象只有一层(没有嵌套对象),浅拷贝完全足够。 实际上大部分场景浅拷贝就够了,不需要动不动就深拷贝。

// ✅ 这种场景浅拷贝就够了
const config = { theme: 'dark', fontSize: 14, lang: 'zh' };
const newConfig = { ...config, theme: 'light' };

// ⚠️ 这种场景需要深拷贝
const state = {
  user: { name: 'Alice', preferences: { theme: 'dark' } },
  posts: [{ id: 1, title: 'Hello' }]
};

深拷贝

深拷贝:创建新对象,递归复制所有层级的属性,新对象和原对象完全独立,互不影响。

方法一:JSON.parse(JSON.stringify())

最简单的深拷贝方式,日常开发中用得很多:

const original = {
  name: 'Alice',
  address: {
    city: 'Beijing',
    district: { name: 'Haidian' }
  },
  hobbies: ['reading', 'coding']
};

const copy = JSON.parse(JSON.stringify(original));

copy.address.city = 'Shanghai';
copy.address.district.name = 'Pudong';
copy.hobbies.push('gaming');

console.log(original.address.city);          // 'Beijing' ✅
console.log(original.address.district.name); // 'Haidian' ✅
console.log(original.hobbies);               // ['reading', 'coding'] ✅

但它有很多致命局限

const problematic = {
  fn: function() {},           // ❌ 丢失
  undef: undefined,            // ❌ 丢失
  sym: Symbol('test'),         // ❌ 丢失
  date: new Date(),            // ❌ 变成字符串
  regex: /hello/gi,            // ❌ 变成空对象 {}
  nan: NaN,                    // ❌ 变成 null
  infinity: Infinity,          // ❌ 变成 null
  map: new Map([[1, 2]]),      // ❌ 变成空对象 {}
  set: new Set([1, 2, 3]),     // ❌ 变成空对象 {}
};

const copy = JSON.parse(JSON.stringify(problematic));
console.log(copy);
// {
//   date: "2024-01-01T00:00:00.000Z",  (字符串)
//   regex: {},
//   nan: null,
//   infinity: null,
//   map: {},
//   set: {}
// }
// fn, undef, sym 直接消失了

循环引用直接报错:

const obj = { a: 1 };
obj.self = obj;

JSON.parse(JSON.stringify(obj));
// TypeError: Converting circular structure to JSON

适用场景: 数据是纯 JSON 结构(只有字符串、数字、布尔、null、数组、普通对象),没有循环引用。

方法二:手写深拷贝

这是面试的重头戏。我们从简单到复杂,一步步实现。

最基础版:递归拷贝

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

  // 数组和普通对象
  const copy = Array.isArray(obj) ? [] : {};

  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      copy[key] = deepClone(obj[key]); // 递归
    }
  }

  return copy;
}

这个版本能处理普通对象和数组的嵌套,但还有很多问题。

进阶版:处理循环引用

用 WeakMap 记录已经拷贝过的对象:

function deepClone(obj, map = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }

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

  const copy = Array.isArray(obj) ? [] : {};
  map.set(obj, copy); // 先记录,再递归

  for (const key in obj) {
    if (obj.hasOwnProperty(key)) {
      copy[key] = deepClone(obj[key], map);
    }
  }

  return copy;
}

// 测试循环引用
const obj = { a: 1, b: { c: 2 } };
obj.self = obj;
obj.b.parent = obj;

const copy = deepClone(obj);
console.log(copy.self === copy);      // true ✅
console.log(copy.b.parent === copy);  // true ✅
console.log(copy !== obj);            // true ✅

为什么用 WeakMap 而不是 Map?因为 WeakMap 的 key 是弱引用,不会阻止垃圾回收。深拷贝完成后,如果原对象不再被引用,WeakMap 中对应的条目会被自动清理。

完整版:处理特殊对象类型

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

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

  // Date
  if (obj instanceof Date) {
    return new Date(obj.getTime());
  }

  // RegExp
  if (obj instanceof RegExp) {
    return new RegExp(obj.source, obj.flags);
  }

  // Map
  if (obj instanceof Map) {
    const copy = new Map();
    map.set(obj, copy);
    obj.forEach((value, key) => {
      copy.set(deepClone(key, map), deepClone(value, map));
    });
    return copy;
  }

  // Set
  if (obj instanceof Set) {
    const copy = new Set();
    map.set(obj, copy);
    obj.forEach(value => {
      copy.add(deepClone(value, map));
    });
    return copy;
  }

  // 普通对象和数组
  const copy = Array.isArray(obj)
    ? []
    : Object.create(Object.getPrototypeOf(obj)); // 保留原型链

  map.set(obj, copy);

  // 拷贝自身所有属性(包括 Symbol 属性)
  const keys = [
    ...Object.keys(obj),
    ...Object.getOwnPropertySymbols(obj)
  ];

  for (const key of keys) {
    copy[key] = deepClone(obj[key], map);
  }

  return copy;
}

测试:

const sym = Symbol('test');

const original = {
  num: 42,
  str: 'hello',
  bool: true,
  nil: null,
  undef: undefined,
  arr: [1, [2, 3], { nested: true }],
  date: new Date('2024-01-01'),
  regex: /hello/gi,
  map: new Map([['key', { deep: true }]]),
  set: new Set([1, 2, { three: 3 }]),
  [sym]: 'symbol value',
};
original.self = original;

const copy = deepClone(original);

// 验证
console.log(copy.date instanceof Date);     // true
console.log(copy.date !== original.date);    // true(独立副本)
console.log(copy.regex instanceof RegExp);   // true
console.log(copy.map.get('key').deep);       // true
console.log(copy.set.size);                  // 3
console.log(copy[sym]);                      // 'symbol value'
console.log(copy.self === copy);             // true(循环引用正确)

方法三:structuredClone(推荐)

structuredClone 是浏览器和 Node.js 内置的深拷贝 API,2022 年开始各大环境都已支持:

const original = {
  date: new Date(),
  regex: /hello/gi,
  map: new Map([['a', 1]]),
  set: new Set([1, 2, 3]),
  arrayBuffer: new ArrayBuffer(8),
  nested: { deep: { value: 42 } }
};
original.self = original; // 循环引用

const copy = structuredClone(original);

console.log(copy.date instanceof Date);   // true ✅
console.log(copy.map instanceof Map);     // true ✅
console.log(copy.self === copy);          // true ✅(循环引用正确)

structuredClone 的优势:

  • 原生实现,性能好
  • 支持循环引用
  • 支持 Date、RegExp、Map、Set、ArrayBuffer、Blob 等
  • 支持嵌套对象的深度拷贝

structuredClone 的局限:

// ❌ 不支持函数
structuredClone({ fn: () => {} });
// DOMException: Failed to execute 'structuredClone'

// ❌ 不支持 DOM 节点
structuredClone(document.body);

// ❌ 不支持 Symbol 属性
const sym = Symbol('key');
const obj = { [sym]: 'value' };
const copy = structuredClone(obj);
console.log(copy[sym]); // undefined

// ❌ 不保留原型链
class Dog {
  constructor(name) { this.name = name; }
  bark() { return 'woof'; }
}
const dog = new Dog('Buddy');
const copy2 = structuredClone(dog);
console.log(copy2 instanceof Dog); // false
console.log(copy2.bark);           // undefined

三种方法对比

特性JSON 方法手写深拷贝structuredClone
循环引用❌ 报错✅ WeakMap✅ 原生支持
Date❌ 变字符串
RegExp❌ 变 {}
Map/Set❌ 变 {}
函数❌ 丢失✅ 保留引用❌ 报错
undefined❌ 丢失
Symbol 属性❌ 丢失
原型链❌ 丢失
性能中等取决于实现最快
兼容性全兼容全兼容现代环境

选择建议:

  • 纯 JSON 数据 → JSON.parse(JSON.stringify())
  • 需要处理特殊类型 → structuredClone
  • 需要保留函数/原型链/Symbol → 手写或 lodash _.cloneDeep

常见误区

误区1:展开运算符是深拷贝

const obj = { a: 1, b: { c: 2 } };
const copy = { ...obj };

copy.b.c = 99;
console.log(obj.b.c); // 99 —— 浅拷贝!第二层共享引用

展开运算符只拷贝第一层,和 Object.assign 一样是浅拷贝。

误区2:JSON 方法能处理所有情况

上面已经详细讲过了。最常见的坑是 Date 对象反序列化后变成字符串,导致后续 getTime() 等方法调用失败。

const obj = { createdAt: new Date() };
const copy = JSON.parse(JSON.stringify(obj));

console.log(typeof copy.createdAt);      // 'string' ← 不是 Date 了
console.log(copy.createdAt.getTime());   // TypeError!

误区3:深拷贝能复制函数

函数无法被真正”深拷贝”。 手写深拷贝和 lodash 的 _.cloneDeep 都只是保留函数的引用:

const obj = {
  greet: function() { console.log('hello'); }
};

const copy = deepClone(obj);
console.log(copy.greet === obj.greet); // true —— 同一个函数引用

这在大多数场景下是合理的——函数本身是无状态的(不考虑闭包),复制引用就够了。

误区4:手写深拷贝只需要递归

纯递归的深拷贝有栈溢出的风险:

// 极深嵌套的对象
let obj = {};
let current = obj;
for (let i = 0; i < 100000; i++) {
  current.child = {};
  current = current.child;
}

deepClone(obj); // RangeError: Maximum call stack size exceeded

如果需要处理极深嵌套,可以用迭代 + 栈来代替递归。但面试中一般不要求这个,知道有这个问题即可。

小结

深浅拷贝是 JavaScript 基础中的基础,但要把它完全掌握并不容易。

核心要点

  1. 基本类型赋值是值拷贝,引用类型赋值是引用拷贝
  2. 浅拷贝:只复制第一层属性,嵌套对象仍然共享引用
  3. 深拷贝:递归复制所有层级,完全独立
  4. JSON 方法:简单好用但局限多,只适合纯 JSON 数据
  5. 手写深拷贝关键点:WeakMap 处理循环引用、特殊对象类型处理、Symbol 属性
  6. structuredClone:现代 API 首选,但不支持函数和 Symbol

本章思维导图

深浅拷贝
  • 基础概念
    • 基本类型:栈内存,值拷贝
    • 引用类型:堆内存,引用拷贝
  • 浅拷贝
    • Object.assign
    • 展开运算符 { ...obj }
    • Array.from / slice / concat
    • 只复制第一层
  • 深拷贝
    • JSON.parse(JSON.stringify())
      • 优点:简单
      • 缺点:丢失函数/undefined/Symbol/Date/RegExp
    • 手写深拷贝
      • 递归遍历
      • WeakMap 处理循环引用
      • 特殊类型:Date/RegExp/Map/Set
      • Symbol 属性
      • 原型链保留
    • structuredClone
      • 原生 API,性能最好
      • 支持循环引用和特殊类型
      • 不支持函数/Symbol/原型链
  • 选择建议
    • 纯 JSON → JSON 方法
    • 特殊类型 → structuredClone
    • 函数/原型链 → 手写或 lodash

练习挑战

挑战一:基础(⭐)

以下代码输出什么?解释原因。

const arr = [1, [2, 3], { a: 4 }];
const copy = [...arr];

copy[0] = 10;
copy[1][0] = 20;
copy[2].a = 40;

console.log(arr[0]);     // A
console.log(arr[1][0]);  // B
console.log(arr[2].a);   // C
答案与解析
  • A: 1copy[0] 是基本类型,修改不影响原数组。
  • B: 20copy[1]arr[1] 指向同一个数组,修改会互相影响。
  • C: 40copy[2]arr[2] 指向同一个对象,修改会互相影响。

展开运算符是浅拷贝。第一层的基本类型是值拷贝(独立),但第一层的引用类型只是拷贝了引用(共享)。

挑战二:进阶(⭐⭐)

手写一个深拷贝函数,至少支持以下类型:普通对象、数组、Date、RegExp、循环引用。

const obj = {
  arr: [1, { nested: true }],
  date: new Date('2024-06-01'),
  regex: /test/gi,
};
obj.self = obj;

const copy = deepClone(obj);
// copy 应该是 obj 的完全独立副本
答案与解析
function deepClone(obj, map = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (map.has(obj)) return map.get(obj);

  if (obj instanceof Date) return new Date(obj.getTime());
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);

  const copy = Array.isArray(obj) ? [] : {};
  map.set(obj, copy);

  for (const key of Object.keys(obj)) {
    copy[key] = deepClone(obj[key], map);
  }

  return copy;
}

面试中写出这个版本就足够了。关键点:

  1. 基本类型直接返回
  2. WeakMap 检测循环引用
  3. Date 和 RegExp 用构造函数创建新实例
  4. 递归处理普通对象和数组

挑战三:综合(⭐⭐⭐)

实现一个 deepEqual 函数,用于判断两个值是否”深度相等”:

deepEqual(1, 1);                                    // true
deepEqual({ a: 1 }, { a: 1 });                      // true
deepEqual({ a: { b: 2 } }, { a: { b: 2 } });        // true
deepEqual([1, [2]], [1, [2]]);                       // true
deepEqual({ a: 1, b: 2 }, { b: 2, a: 1 });          // true(键顺序无关)
deepEqual(new Date('2024-01-01'), new Date('2024-01-01')); // true
deepEqual(NaN, NaN);                                 // true
deepEqual({ a: 1 }, { a: 1, b: undefined });         // false
答案与解析
function deepEqual(a, b) {
  // 严格相等(包括同一引用)
  if (a === b) return true;

  // NaN 特殊处理
  if (Number.isNaN(a) && Number.isNaN(b)) return true;

  // 类型不同或有 null
  if (typeof a !== typeof b) return false;
  if (a === null || b === null) return false;

  // 非对象类型到这里说明不相等
  if (typeof a !== 'object') return false;

  // Date 比较
  if (a instanceof Date && b instanceof Date) {
    return a.getTime() === b.getTime();
  }

  // RegExp 比较
  if (a instanceof RegExp && b instanceof RegExp) {
    return a.source === b.source && a.flags === b.flags;
  }

  // 确保同一种对象类型
  if (a.constructor !== b.constructor) return false;

  // 数组和对象
  const keysA = Object.keys(a);
  const keysB = Object.keys(b);

  if (keysA.length !== keysB.length) return false;

  return keysA.every(key =>
    Object.prototype.hasOwnProperty.call(b, key) &&
    deepEqual(a[key], b[key])
  );
}

关键点:

  • NaN !== NaN,需要特殊处理
  • Date 用 getTime() 比较
  • 先比较键的数量,再递归比较每个值
  • 键顺序无关,只要所有键都存在且值相等

自我检测

读完本章后,确认你能回答以下问题:

  • 能解释基本类型和引用类型在赋值时的区别(值拷贝 vs 引用拷贝)
  • 能列举至少 3 种浅拷贝的方法
  • 能说出 JSON.parse(JSON.stringify()) 的至少 5 个局限
  • 能手写深拷贝,处理普通对象、数组、循环引用
  • 能扩展手写深拷贝,处理 Date、RegExp、Map、Set
  • 知道 structuredClone 的优势和局限
  • 能根据场景选择合适的拷贝方案
  • 理解 WeakMap 在深拷贝中的作用(弱引用、防止内存泄漏)
  • 知道函数无法被真正深拷贝,只能保留引用

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90