手写深拷贝:从 5 行到 50 行,一步步搞清楚每个坑

0 阅读4分钟

最近复习面试题,发现深拷贝几乎是必考题。

看起来一句 JSON.parse(JSON.stringify(obj)) 就能搞定,但面试官往往会追问:

JSON.parse(JSON.stringify(obj)) 存在什么问题? 怎么解决?

一、先搞清楚:什么是深拷贝

浅拷贝:只拷贝对象的第一层,嵌套对象是引用共享。

const obj = { a: 1, b: { c: 2 } };
const shallow = { ...obj };
shallow.b.c = 999;
console.log(obj.b.c); // 999 —— 原对象被改了

深拷贝:递归拷贝所有层级,新旧对象完全独立。

const deep = deepClone(obj);
deep.b.c = 999;
console.log(obj.b.c); // 2 —— 原对象没变

二、最简单的版本(5 行)

用 JSON 序列化再反序列化:

function deepClone(obj) {
  return JSON.parse(JSON.stringify(obj));
}

能干活,但有 5 个坑:

  1. undefined 会丢失
  2. 函数会丢失
  3. Symbol 会丢失
  4. Date 会变成字符串
  5. 循环引用会报错

验证一下:

const obj = {
  a: undefined,
  b: () => {},
  c: Symbol('key'),
  d: new Date(),
  e: /abc/
};
console.log(JSON.parse(JSON.stringify(obj)));
// { d: '2026-09-15T...', e: {} }
// a、b、c 全没了,d 变成字符串,e 变成空对象

三、进阶版:递归实现

自己写递归,处理基本类型 + 对象 + 数组:

function deepClone(target) {
  // 基本类型直接返回
  if (target === null || typeof target !== 'object') {
    return target;
  }
  
  // 数组和对象分开处理
  const cloneTarget = Array.isArray(target) ? [] : {};
  
  for (const key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepClone(target[key]);
    }
  }
  
  return cloneTarget;
}

改进点:

  • ✅ 支持 undefined、函数、Symbol 值
  • ✅ 支持嵌套对象/数组
  • 循环引用会栈溢出
  • ❌ Date、RegExp、Map、Set 还是没处理

四、处理循环引用:用 WeakMap

先看什么是循环引用:

const obj = { a: 1 };
obj.self = obj; // obj 引用了自己
deepClone(obj); // 栈溢出 RangeError

解法:用一个 WeakMap 记录已经拷贝过的对象,遇到重复的直接返回。

function deepClone(target, map = new WeakMap()) {
  if (target === null || typeof target !== 'object') {
    return target;
  }
  
  // 如果已经拷贝过,直接返回
  if (map.has(target)) {
    return map.get(target);
  }
  
  const cloneTarget = Array.isArray(target) ? [] : {};
  
  // 先记录,再递归(顺序很重要)
  map.set(target, cloneTarget);
  
  for (const key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }
  
  return cloneTarget;
}

为什么用 WeakMap 而不是 Map?

  • WeakMap 的 key 是弱引用,不阻止 GC 回收
  • 拷贝结束后自动释放内存,避免内存泄漏

顺序为什么关键?

一定要先 map.set,再递归。如果先递归,遇到循环引用时 map 还没记录,就又会栈溢出。

五、处理特殊类型:Date、RegExp、Map、Set

上面的版本对 Date 等对象的处理是错的:

deepClone(new Date());
// {} —— 变成空对象了

原因:for...in 遍历不到 Date 的内部属性,得根据类型单独处理

function deepClone(target, map = new WeakMap()) {
  // 基本类型
  if (target === null || typeof target !== 'object') {
    return target;
  }
  
  // 循环引用
  if (map.has(target)) {
    return map.get(target);
  }
  
  // Date
  if (target instanceof Date) {
    return new Date(target.getTime());
  }
  
  // RegExp
  if (target instanceof RegExp) {
    return new RegExp(target.source, target.flags);
  }
  
  // Map
  if (target instanceof Map) {
    const result = new Map();
    map.set(target, result);
    target.forEach((value, key) => {
      result.set(key, deepClone(value, map));
    });
    return result;
  }
  
  // Set
  if (target instanceof Set) {
    const result = new Set();
    map.set(target, result);
    target.forEach(value => {
      result.add(deepClone(value, map));
    });
    return result;
  }
  
  // 普通对象/数组
  const cloneTarget = Array.isArray(target) ? [] : {};
  map.set(target, cloneTarget);
  
  for (const key in target) {
    if (target.hasOwnProperty(key)) {
      cloneTarget[key] = deepClone(target[key], map);
    }
  }
  
  return cloneTarget;
}

六、处理 Symbol key

for...in 只能遍历字符串 key,遍历不到 Symbol key:

const key = Symbol('key');
const obj = { [key]: 1 };
for (const k in obj) console.log(k); // 什么都不打印

解决:用 Reflect.ownKeys() 遍历(能拿到字符串 key + Symbol key):

Reflect.ownKeys(target).forEach(key => {
  cloneTarget[key] = deepClone(target[key], map);
});

七、终极完整版

把上面所有优化合并:

function deepClone(target, map = new WeakMap()) {
  if (target === null || typeof target !== 'object') return target;
  if (map.has(target)) return map.get(target);
  if (target instanceof Date) return new Date(target.getTime());
  if (target instanceof RegExp) return new RegExp(target.source, target.flags);
  
  if (target instanceof Map) {
    const result = new Map();
    map.set(target, result);
    target.forEach((v, k) => result.set(k, deepClone(v, map)));
    return result;
  }
  
  if (target instanceof Set) {
    const result = new Set();
    map.set(target, result);
    target.forEach(v => result.add(deepClone(v, map)));
    return result;
  }
  
  const cloneTarget = Array.isArray(target) ? [] : {};
  map.set(target, cloneTarget);
  
  Reflect.ownKeys(target).forEach(key => {
    cloneTarget[key] = deepClone(target[key], map);
  });
  
  return cloneTarget;
}

测试一下:

const obj = {
  a: undefined,
  b: () => 'fn',
  c: new Date(),
  d: /abc/i,
  e: new Map([['k', 'v']]),
  f: new Set([1, 2]),
  [Symbol('key')]: 'symbol',
};
obj.self = obj; // 循环引用

const cloned = deepClone(obj);
console.log(cloned.self === cloned); // true —— 循环引用保留
console.log(cloned.c instanceof Date); // true
console.log(cloned.d.source); // 'abc'
console.log(cloned.e.get('k')); // 'v'

八、如果不想手写,还有 2 个选择

方案 1 · Lodash 的 _.cloneDeep

import _ from 'lodash';
const cloned = _.cloneDeep(obj);

方案 2 · 浏览器原生 structuredClone

const cloned = structuredClone(obj);

structuredClone 支持大部分复杂类型,包括循环引用

不支持:

  • 函数(会抛错)
  • Symbol(会抛错)
  • DOM 节点
  • 原型链

面试八股题里如果面试官问"有原生 API 吗",答 structuredClone 是加分项。

总结

深拷贝这道题的层次:

  1. JSON 大法:5 行,能干活但有很多坑
  2. 递归实现:处理普通对象/数组
  3. 循环引用:用 WeakMap 记录
  4. 特殊类型:Date/RegExp/Map/Set 单独处理
  5. Symbol key:用 Reflect.ownKeys 遍历

面试怎么答:

  • 面试官问"写个深拷贝"→ 从简单版本开始
  • 追问"有什么问题"→ 指出 5 个坑
  • 继续追问 → 逐步给出上面的版本
  • 不用一上来就写终极版本,展示"思考过程"更重要

如果觉得有帮助,点个赞或关注,后续会继续更新面试题深度解析系列。