深拷贝:JavaScript 引用类型的完全复制之道

"能手写一个深拷贝吗?"

我立马想到的是 JSON.parse(JSON.stringify(obj))

但如果进一步追问:"如果对象有循环引用呢?如果有 Date、RegExp 呢?" 我才意识到,深拷贝远比我想象的复杂。

这篇文章是我重新梳理深拷贝的学习笔记,从最简单的递归开始,一步步处理各种边界情况。

引子:浅拷贝带来的 Bug

先来看一个真实场景下容易踩的坑:

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:用户信息编辑

const originalUser = {
  name: 'Alice',
  profile: {
    age: 25,
    city: 'Shanghai'
  }
};

// 使用展开运算符"复制"对象
const editedUser = { ...originalUser };

// 修改嵌套属性
editedUser.profile.city = 'Beijing';

console.log(originalUser.profile.city); // "Beijing" - 糟糕,原对象也被修改了!
console.log(editedUser.profile.city);   // "Beijing"

这个 Bug 的根源在于:展开运算符和 Object.assign 都只做浅拷贝

它们只拷贝对象的第一层属性。

对于嵌套的对象,拷贝的只是 引用

引用类型 vs 基本类型的内存模型

要理解这个问题,需要先理解 JavaScript 的内存模型:

javascript 复制代码
// 基本类型:直接存储值
let a = 10;
let b = a;  // 复制值
b = 20;
console.log(a); // 10 - a 不受影响

// 引用类型:存储的是内存地址
let obj1 = { count: 10 };
let obj2 = obj1; // 复制的是地址
obj2.count = 20;
console.log(obj1.count); // 20 - obj1 也被修改了,因为它们指向 **同一块内存**

浅拷贝只拷贝了第一层的引用,深拷贝则需要递归地复制所有嵌套层级,创建 全新的对象

深拷贝的核心挑战

实现深拷贝主要面临这几个挑战:

1. 递归思维:处理嵌套结构

深拷贝的核心是递归

  • 如果遇到对象,就递归地拷贝它的每个属性;
  • 如果遇到基本类型,直接复制。
graph TD A[开始拷贝对象] --> B{属性是基本类型?} B -->|是| C[直接复制值] B -->|否| D{属性是对象/数组?} D -->|是| E[递归拷贝该属性] D -->|否| F[处理特殊类型] E --> A C --> G[继续下一个属性] F --> G G --> H{还有属性?} H -->|是| B H -->|否| I[返回新对象]

2. 循环引用检测:避免爆栈

如果对象存在循环引用,朴素的递归会导致无限循环:

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:循环引用的对象

const obj = { name: 'Alice' };
obj.self = obj; // 循环引用自己

// 如果直接递归拷贝,会发生什么?
// deepClone(obj) -> deepClone(obj.self) -> deepClone(obj.self.self) -> ...
// 无限递归,最终栈溢出!

3. 类型判断:不同类型需要不同处理

JavaScript 的引用类型五花八门:Object、Array、Date、RegExp、Map、Set、Function... 每种类型的拷贝方式都不同。

4. 特殊值处理:null、undefined、Symbol

这些特殊值需要特别小心处理,容易成为 Bug 的源头。

从简单到完整的实现路径

让我们从最简单的版本开始,逐步完善。

版本 1:JSON 方法的局限性

最快速的深拷贝方式是使用 JSON:

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:JSON 深拷贝

const obj = {
  name: 'Alice',
  profile: {
    age: 25,
    hobbies: ['reading', 'coding']
  }
};

const cloned = JSON.parse(JSON.stringify(obj));

cloned.profile.age = 30;
console.log(obj.profile.age);    // 25 - 原对象未改变 ✅
console.log(cloned.profile.age); // 30

但这个方法有严重的局限性:

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:JSON 方法的各种问题

const obj = {
  date: new Date(),
  regex: /test/g,
  func: () => console.log('hello'),
  undef: undefined,
  symbol: Symbol('key'),
  nan: NaN,
  infinity: Infinity
};

obj.self = obj; // 循环引用

// ❌ 会抛出错误:TypeError: Converting circular structure to JSON
// const cloned = JSON.parse(JSON.stringify(obj));

// 即使没有循环引用,也会丢失很多信息:
const safeObj = {
  date: new Date(),
  regex: /test/g,
  func: () => console.log('hello'),
  undef: undefined,
  nan: NaN
};

const cloned = JSON.parse(JSON.stringify(safeObj));

console.log(cloned);
// {
//   date: "2024-03-14T10:30:00.000Z", // 变成了字符串!
//   regex: {},                        // 变成了空对象!
//   nan: null                         // NaN 变成了 null!
//   // func 和 undef 直接消失了!
// }

JSON 方法的问题总结

  • ❌ 无法处理循环引用(会报错)
  • ❌ 函数会丢失
  • ❌ undefined 会丢失
  • ❌ Symbol 会丢失
  • ❌ Date 变成字符串
  • ❌ RegExp 变成空对象
  • ❌ NaN/Infinity 变成 null

所以,JSON 方法只适用于简单的、纯数据的对象。

版本 2:基础递归实现

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:基础深拷贝,处理 object 和 array

function deepClone(target) {
  // 基本类型直接返回
  if (typeof target !== 'object' || target === null) {
    return target;
  }
  
  // 区分数组和对象
  const cloneTarget = Array.isArray(target) ? [] : {};
  
  // 递归拷贝每个属性
  for (let key in target) {
    if (target.hasOwnProperty(key)) { // 只拷贝自身属性,不拷贝原型链
      cloneTarget[key] = deepClone(target[key]);
    }
  }
  
  return cloneTarget;
}

// 测试
const obj = {
  name: 'Alice',
  age: 25,
  hobbies: ['reading', 'coding'],
  profile: {
    city: 'Shanghai',
    education: {
      degree: 'Bachelor',
      school: 'MIT'
    }
  }
};

const cloned = deepClone(obj);
cloned.profile.city = 'Beijing';

console.log(obj.profile.city);    // "Shanghai" - 原对象未改变 ✅
console.log(cloned.profile.city); // "Beijing"

这个版本已经能处理基本的嵌套对象和数组了,但还有两个致命问题:

  1. 无法处理循环引用
  2. 无法处理特殊类型(Date、RegExp 等)

版本 3:使用 WeakMap 解决循环引用

循环引用的核心思路是:记录已经拷贝过的对象,如果再次遇到,直接返回之前的拷贝结果。

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:处理循环引用

function deepClone(target, map = new WeakMap()) {
  // 基本类型直接返回
  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);
  
  // 递归拷贝每个属性
  for (let key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }
  
  return cloneTarget;
}

// 测试循环引用
const obj = { name: 'Alice' };
obj.self = obj;            // 指向自己
obj.nested = { parent: obj }; // 嵌套的循环引用

const cloned = deepClone(obj);

console.log(cloned.self === cloned);            // true ✅
console.log(cloned.nested.parent === cloned);   // true ✅
console.log(cloned !== obj);                    // true - 是新对象

为什么用 WeakMap 而不是 Map?

javascript 复制代码
// WeakMap vs Map 的区别

// Map:强引用,即使原对象被销毁,Map 中的引用仍然存在
const map = new Map();
let obj = { data: 'large object' };
map.set(obj, 'value');
obj = null; // obj 被销毁,但 map 中的引用仍然存在,造成内存泄漏

// WeakMap:弱引用,原对象销毁后,WeakMap 中的引用会自动清除
const weakMap = new WeakMap();
let obj2 = { data: 'large object' };
weakMap.set(obj2, 'value');
obj2 = null; // obj2 被销毁,weakMap 中的引用也会被垃圾回收

在深拷贝的场景中,map 只是临时用来检测循环引用的,拷贝完成后就不需要了。使用 WeakMap 可以让垃圾回收器自动清理,避免内存泄漏。

版本 4:处理特殊类型

现在我们来处理 Date、RegExp、Map、Set 等特殊类型:

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:处理各种特殊类型

function deepClone(target, map = new WeakMap()) {
  // 基本类型直接返回
  if (typeof target !== 'object' || target === null) {
    return target;
  }
  
  // 检查循环引用
  if (map.has(target)) {
    return map.get(target);
  }
  
  // 获取对象的具体类型
  const type = Object.prototype.toString.call(target);
  let cloneTarget;
  
  // 根据类型选择拷贝策略
  switch (type) {
    case '[object Array]':
      cloneTarget = [];
      break;
    case '[object Object]':
      cloneTarget = {};
      break;
    case '[object Date]':
      return new Date(target); // Date 直接返回,不需要递归
    case '[object RegExp]':
      // 拷贝 RegExp 需要保留 flags
      return new RegExp(target.source, target.flags);
    case '[object Map]':
      cloneTarget = new Map();
      map.set(target, cloneTarget);
      target.forEach((value, key) => {
        cloneTarget.set(key, deepClone(value, map));
      });
      return cloneTarget;
    case '[object Set]':
      cloneTarget = new Set();
      map.set(target, cloneTarget);
      target.forEach(value => {
        cloneTarget.add(deepClone(value, map));
      });
      return cloneTarget;
    default:
      // 其他类型(Function, Symbol 等)直接返回
      return target;
  }
  
  // 记录当前对象
  map.set(target, cloneTarget);
  
  // 递归拷贝属性
  for (let key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }
  
  return cloneTarget;
}

// 测试特殊类型
const obj = {
  date: new Date('2024-03-14'),
  regex: /test/gi,
  map: new Map([['key1', 'value1'], ['key2', { nested: true }]]),
  set: new Set([1, 2, { value: 3 }]),
  arr: [1, 2, 3]
};

const cloned = deepClone(obj);

console.log(cloned.date instanceof Date);           // true ✅
console.log(cloned.date.getTime() === obj.date.getTime()); // true ✅
console.log(cloned.regex.source === obj.regex.source);     // true ✅
console.log(cloned.regex.flags === obj.regex.flags);       // true ✅
console.log(cloned.map.get('key2') !== obj.map.get('key2')); // true - 深拷贝 ✅
console.log(cloned !== obj);                        // true - 新对象 ✅

类型检测的关键:Object.prototype.toString

为什么要用 Object.prototype.toString.call(target) 而不是 typeof

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:类型检测对比

const arr = [1, 2, 3];
const date = new Date();
const regex = /test/;

// typeof 无法区分对象类型
console.log(typeof arr);   // "object"
console.log(typeof date);  // "object"
console.log(typeof regex); // "object"

// Object.prototype.toString 可以精确区分
console.log(Object.prototype.toString.call(arr));   // "[object Array]"
console.log(Object.prototype.toString.call(date));  // "[object Date]"
console.log(Object.prototype.toString.call(regex)); // "[object RegExp]"
console.log(Object.prototype.toString.call(null));  // "[object Null]"

这是判断 JavaScript 类型最可靠的方式。

边界情况与陷阱

在实际使用中,还有一些容易忽略的边界情况。

1. Function 能深拷贝吗?

函数的拷贝比较特殊。一种观点是:函数不应该被拷贝,因为函数通常依赖外部作用域,拷贝后可能失去原有的上下文。

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:函数的拷贝问题

const obj = {
  count: 0,
  increment: function() {
    this.count++; // 依赖 this 上下文
  }
};

// 如果拷贝函数,this 指向会改变吗?
const cloned = deepClone(obj);
cloned.increment();
console.log(cloned.count); // 1 - this 指向 cloned,正常工作 ✅

在我们的实现中,函数直接返回原引用,这是一种常见的做法。如果真的需要拷贝函数,可以用 new Functioneval,但通常不推荐。

2. Symbol 作为 key 的处理

对象的 key 可以是 Symbol,而 for...inObject.keys 都无法遍历 Symbol 属性:

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:Symbol 属性的拷贝

function deepCloneWithSymbol(target, map = new WeakMap()) {
  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);
  
  // 拷贝普通属性
  for (let key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepCloneWithSymbol(target[key], map);
    }
  }
  
  // 拷贝 Symbol 属性
  const symbolKeys = Object.getOwnPropertySymbols(target);
  for (let key of symbolKeys) {
    cloneTarget[key] = deepCloneWithSymbol(target[key], map);
  }
  
  return cloneTarget;
}

// 测试
const sym = Symbol('key');
const obj = {
  normal: 'value',
  [sym]: 'symbol value'
};

const cloned = deepCloneWithSymbol(obj);
console.log(cloned[sym]); // "symbol value" ✅

3. 不可枚举属性的处理

默认情况下,for...in 只遍历可枚举属性。如果需要拷贝不可枚举属性,要用 Object.getOwnPropertyNames:

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:不可枚举属性

const obj = {};
Object.defineProperty(obj, 'hidden', {
  value: 'secret',
  enumerable: false // 不可枚举
});

console.log(obj.hidden); // "secret"

// for...in 无法遍历
for (let key in obj) {
  console.log(key); // 不会打印 "hidden"
}

// 使用 Object.getOwnPropertyNames
const allKeys = Object.getOwnPropertyNames(obj);
console.log(allKeys); // ["hidden"]

不过在大多数场景下,不可枚举属性通常是内部属性,不需要拷贝。

4. getter/setter 的处理

如果对象的属性定义了 getter/setter,直接拷贝会丢失这些访问器:

javascript 复制代码
// 环境:浏览器 / Node.js
// 场景:getter/setter 的拷贝

const obj = {
  _age: 25,
  get age() {
    console.log('Getting age');
    return this._age;
  },
  set age(value) {
    console.log('Setting age');
    this._age = value;
  }
};

// 普通拷贝会丢失 getter/setter
const cloned = deepClone(obj);
cloned.age = 30; // 不会触发 setter
console.log(cloned.age); // 不会触发 getter

// 正确的做法:使用 Object.getOwnPropertyDescriptors
const correctCloned = Object.create(
  Object.getPrototypeOf(obj),
  Object.getOwnPropertyDescriptors(obj)
);

这涉及到属性描述符(Property Descriptor)的概念,在深拷贝的复杂场景中需要考虑。

手写实现的关键点

面试时手写深拷贝,这些点是考察重点:

1. 递归终止条件

javascript 复制代码
// ✅ 正确:基本类型和 null 都要终止递归
if (typeof target !== 'object' || target === null) {
  return target;
}

// ❌ 错误:忘记检查 null
if (typeof target !== 'object') {
  return target;
}
// typeof null === 'object',会继续递归,导致报错!

这是很容易忽略的细节,因为 typeof null === 'object' 是 JavaScript 的一个历史遗留问题。

2. 循环引用的检测时机

javascript 复制代码
// ✅ 正确:在创建新对象前检查
if (map.has(target)) {
  return map.get(target);
}
const cloneTarget = Array.isArray(target) ? [] : {};
map.set(target, cloneTarget);

// ❌ 错误:在递归拷贝后才记录
const cloneTarget = Array.isArray(target) ? [] : {};
for (let key in target) {
  cloneTarget[key] = deepClone(target[key], map); // 如果这里遇到循环引用,已经晚了
}
map.set(target, cloneTarget);

必须在递归前就记录,否则遇到循环引用时,还是会无限递归。

3. WeakMap 的作用

  • WeakMap 的 key 必须是对象(符合我们的需求)
  • WeakMap 是弱引用,不会阻止垃圾回收
  • 拷贝完成后,map 会被自动清理,避免内存泄漏

4. 数组和对象的区分

javascript 复制代码
// ✅ 推荐:使用 Array.isArray
const cloneTarget = Array.isArray(target) ? [] : {};

// ⚠️ 可用但不够优雅:使用 instanceof
const cloneTarget = target instanceof Array ? [] : {};

// ❌ 不推荐:使用 constructor
const cloneTarget = target.constructor === Array ? [] : {};

Array.isArray 是最可靠的方式,即使在不同 iframe 环境下也能正常工作。

性能与工程实践

在实际项目中,深拷贝的性能也是需要考虑的。

lodash 的 cloneDeep 实现思路

lodash 的 cloneDeep 是工业级实现的参考,它的核心思路:

  1. 使用栈(stack)代替递归,避免爆栈
  2. 缓存已拷贝对象(类似我们的 WeakMap)
  3. 针对不同类型使用优化的拷贝策略
  4. 处理大量边界情况(原型链、属性描述符等)
javascript 复制代码
// 环境:Node.js / 浏览器(需要引入 lodash)
// 场景:使用 lodash 的 cloneDeep

import _ from 'lodash';

const obj = {
  date: new Date(),
  func: () => console.log('hello'),
  symbol: Symbol('key')
};

obj.self = obj;

const cloned = _.cloneDeep(obj);
console.log(cloned.self === cloned); // true ✅

什么时候不需要深拷贝(Immutable 思想)

在 React 等现代框架中,推荐使用不可变数据(Immutable Data):

javascript 复制代码
// 环境:React
// 场景:不可变更新,替代深拷贝

// ❌ 不推荐:深拷贝整个对象
const newState = deepClone(state);
newState.user.name = 'Bob';

// ✅ 推荐:只拷贝需要修改的部分
const newState = {
  ...state,
  user: {
    ...state.user,
    name: 'Bob'
  }
};

这种方式更高效,也更符合函数式编程的思想。深拷贝应该只在确实需要"完全独立的副本"时使用。

一些有关 deepClone 的追问

Q1: 深拷贝和浅拷贝的区别?

我的理解:

  • 浅拷贝只复制第一层属性,嵌套对象复制的是引用
  • 深拷贝递归复制所有层级,创建完全独立的副本
  • 浅拷贝: 展开运算符、Object.assign
  • 深拷贝: 递归实现、JSON 方法(有限制)、structuredClone

Q2: 如何检测循环引用?

使用 Map 或 WeakMap 记录已经拷贝过的对象。如果再次遇到,直接返回之前的拷贝结果,而不是继续递归。

Q3: 为什么不推荐用 JSON 方法做深拷贝?

JSON 方法的限制太多:

  • 无法处理循环引用(报错)
  • 函数、undefined、Symbol 会丢失
  • Date 变成字符串
  • RegExp 变成空对象
  • NaN/Infinity 变成 null

只适用于纯数据对象。

Q4: 在 React 状态管理中的最佳实践?

不推荐深拷贝整个 state,而是:

  • 使用展开运算符做浅拷贝
  • 只拷贝需要修改的部分
  • 保持数据不可变(Immutable)
  • 考虑使用 Immer 等库简化不可变更新

小结

深拷贝看似简单,实际上涉及递归、类型判断、循环引用检测、内存管理等多个知识点。手写深拷贝的核心是:

  1. 递归思维: 基本类型直接返回,对象类型递归拷贝
  2. 循环引用检测: 使用 WeakMap 记录已拷贝对象
  3. 类型判断 : 用 Object.prototype.toString 精确区分类型
  4. 特殊类型处理: Date、RegExp、Map、Set 需要特殊构造

面试时,除了能写出代码,更重要的是能解释清楚:

  • 为什么要用 WeakMap?(弱引用,避免内存泄漏)
  • 为什么要先记录再递归?(避免循环引用)
  • 如何区分数组和对象?(Array.isArray)
  • 什么情况下不需要深拷贝?(不可变数据更新)

这篇文章是我准备面试时的思考过程,可能有理解不到位的地方。实际项目中,我会使用 lodash 的 cloneDeep,而不是手写实现。但理解原理对于调试问题和优化性能仍然很重要。

参考资料

相关推荐
默默学前端2 小时前
JavaScript 中 call、apply、bind 的区别
开发语言·前端·javascript
宁雨桥2 小时前
前端设计模式面试题大全
前端·设计模式
Cg136269159742 小时前
JS函数表示
前端·html
℘团子এ2 小时前
vue3中,el-table表格固定列后出现表格线段折断的问题
javascript·vue.js·elementui
在屏幕前出油2 小时前
02. FastAPI——路由
服务器·前端·后端·python·pycharm·fastapi
勿芮介2 小时前
【大模型应用】在window/linux上卸载OpenClaw
java·服务器·前端
摸鱼仙人~2 小时前
前端面试手写核心 Cheat Sheet(终极精简版)
前端
馬致远3 小时前
Win7 配置 Vue脚手架
javascript·vue.js·ecmascript