文章

手写完整深拷贝函数深度解析

从零手写支持多种数据类型、循环引用、Symbol的完整深拷贝函数,深入理解引用类型复制原理

手写完整深拷贝函数深度解析

一句话概括

深拷贝的本质是递归地复制对象的所有层级——难点不在递归本身,而在循环引用、Symbol 属性、特殊内置类型这三大隐形杀手,面试时谁先说出来谁加分。

核心知识点

1. 递归骨架:类型判断 + 逐层复制

一切深拷贝都从这 6 行开始。核心逻辑只有两条:基本类型直接返回,引用类型先建容器再递归。

1
2
3
4
5
6
7
8
function deepClone(obj) {
  if (obj === null || typeof obj !== 'object') return obj;
  const clone = Array.isArray(obj) ? [] : {};
  for (const key of Reflect.ownKeys(obj)) {
    clone[key] = deepClone(obj[key]);
  }
  return clone;
}

这里已经埋了两个细节:(1) 用 Reflect.ownKeys 而非 for...in,因为后者拿不到 Symbol 键;(2) 用 typeof 判对象会漏掉 null,所以 obj === null 必须写在前面。

2. 循环引用:WeakMap 是正确答案

如果一个对象的属性指向它自己,上面的递归会直接爆栈。

1
2
3
const a = {};
a.self = a;
// deepClone(a);  →  RangeError: Maximum call stack size exceeded

解法:用 WeakMap 建立 原对象 → 克隆对象 的映射,碰到已经拷贝过的直接返回缓存。

1
2
3
4
5
6
7
8
9
10
11
12
function deepClone(obj, cache = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (cache.has(obj)) return cache.get(obj);  // 命中缓存,直接返回

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

  for (const key of Reflect.ownKeys(obj)) {
    clone[key] = deepClone(obj[key], cache);
  }
  return clone;
}

面试追问「为什么 WeakMap 而不是 Map?」——WeakMap 的 key 是弱引用,原对象被销毁后缓存自动清掉,不会内存泄漏。Map 的强引用会阻止 GC,拷贝大量对象时隐患很大。

3. 特殊类型:每种都是独立分支

Date、RegExp、Map、Set 不是普通对象,不能按 {} 套路来。

1
2
3
4
5
6
7
8
9
10
const handlers = {
  Date:    obj => new Date(obj),
  RegExp:  obj => new RegExp(obj.source, obj.flags),
  Map:     (obj, clone) => obj.forEach((v, k) => clone.set(deepClone(k, cache), deepClone(v, cache))),
  Set:     (obj, clone) => obj.forEach(v  => clone.add(deepClone(v, cache))),
  Error:   obj => new obj.constructor(obj.message),
  ArrayBuffer: obj => obj.slice(0),
};

const typeTag = Object.prototype.toString.call(obj);  // '[object Date]' 等

关键认知:typeof 对 Date / RegExp / Error 都返回 'object',必须靠 Object.prototype.toString 区分。Map 和 Set 走 forEach 遍历,key 和 value 都要递归,因为 key 本身也可能是对象。

4. 原型链:别把 constructor 丢了

Object.create() 做浅层继承,再加属性拷贝:

1
2
const proto = Object.getPrototypeOf(obj);
const clone = Array.isArray(obj) ? [] : Object.create(proto);

这样 clone instanceof Person 仍然为 true,原型上的方法不会丢——面试官经常会问 new Person() 生成的实例拷完之后还能不能调原型方法。

5. 面试速成版(30 行,覆盖 90% 考点)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function deepClone(obj, cache = new WeakMap()) {
  if (obj === null || typeof obj !== 'object') return obj;
  if (cache.has(obj)) return cache.get(obj);

  const type = Object.prototype.toString.call(obj);

  if (type === '[object Date]')   return new Date(obj);
  if (type === '[object RegExp]') return new RegExp(obj.source, obj.flags);

  let clone;
  if (type === '[object Map]') {
    clone = new Map(); cache.set(obj, clone);
    obj.forEach((v, k) => clone.set(deepClone(k, cache), deepClone(v, cache)));
    return clone;
  }
  if (type === '[object Set]') {
    clone = new Set(); cache.set(obj, clone);
    obj.forEach(v => clone.add(deepClone(v, cache)));
    return clone;
  }

  clone = Array.isArray(obj) ? [] : Object.create(Object.getPrototypeOf(obj));
  cache.set(obj, clone);
  for (const key of Reflect.ownKeys(obj)) clone[key] = deepClone(obj[key], cache);
  return clone;
}

其实你每天都在用

React setState / Redux reducer:每次更新 state 都要返回新对象,本质上就是深拷贝 + 修改。如果直接 state.a.b = 1,React 的浅比较检测不到变化,UI 不更新——这就是为什么 immutable 更新模式这么重要。

表单草稿 & 撤销:编辑器保存一份快照 const snapshot = deepClone(formData),用户点「撤销」时把快照写回去。如果用浅拷贝,改草稿的同时快照也被污染了,撤销等于没撤。

配置合并const finalConfig = deepClone(defaults); Object.assign(finalConfig, userConfig) —— 先深拷贝默认配置,再覆盖用户传入项。如果默认配置里有嵌套对象(比如 theme.colors),浅拷贝会导致所有实例共享同一个嵌套对象,一个地方改全局都变。

前端缓存 / 快照对比:需要对比两次数据的差异时,必须保存深拷贝的旧值。很多新人直接 const old = current,发现 diff 永远是空——因为 old 和 current 是同一个引用。

Vuex / Pinia 状态重置resetState(state) { Object.assign(state, deepClone(initialState)) }。如果 initialState 里有嵌套对象,浅拷贝重置只是把引用指回去,后续修改还是会影响「初始状态」。

常见误解

❌ 误区:「JSON.parse(JSON.stringify()) 就是深拷贝」

这是面试里最容易被追问打倒的说法。JSON 序列化会丢掉 undefinedSymbolFunctionBigInt(直接报错),Date 变成字符串,RegExp 变成 {},Map/Set 变成空对象,循环引用直接抛异常。它只适用于纯数据 JSON,面试里说出这句话基本等于自爆短板。

❌ 误区:「展开运算符 {...obj} 是深拷贝」

展开运算符只拷贝第一层。{...{ a: { b: 1 } }} 里的嵌套对象 a 仍然是同一个引用,改了克隆的 a.b 原对象也跟着变。同理 Object.assign、数组的 [...arr]slice() 全部是浅拷贝。

❌ 误区:「structuredClone 能完美替代手写深拷贝」

structuredClone 是浏览器原生 API,确实支持循环引用和大部分内置类型,但它不支持 Function、Symbol、DOM 节点,遇到这些直接报 DataCloneError。而且 Node.js 17 之前没有,面试让你手写你说调 API 肯定不过。可以提一句「生产环境优先用 structuredClone,但面试考的是原理」。

❌ 误区:「Symbol 是唯一的,所以没法深拷贝」

Symbol 确实不可复刻——Symbol('a') !== Symbol('a')——但深拷贝要复制的不是 Symbol 本身,而是用 Symbol 作为键的属性值。只要用 Reflect.ownKeys(而非 for...in)遍历,Symbol-keyed 属性就能正常拷贝。同一个 Symbol 引用在克隆对象中保持不变,这是正确行为。

一句话总结

深拷贝的面试含金量不在代码量,在于你是否能讲清「循环引用为什么用 WeakMap」「Symbol 为什么用 Reflect.ownKeys」「特殊类型为什么不能一把梭」——这三个问题掰开揉碎了,面试官就知道你不是背答案的。

本文由作者按照 CC BY 4.0 进行授权