手写完整深拷贝函数深度解析
从零手写支持多种数据类型、循环引用、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 序列化会丢掉 undefined、Symbol、Function、BigInt(直接报错),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」「特殊类型为什么不能一把梭」——这三个问题掰开揉碎了,面试官就知道你不是背答案的。