如何手写深拷贝?需要注意哪些边界?
简化版
深拷贝要让新对象不再共享可变的嵌套引用。基础题可递归数组和普通对象;进阶要处理循环引用、重复引用、原型、属性描述符、Date、RegExp、Map、Set、ArrayBuffer 等。WeakMap 应在递归子属性之前登记源对象到副本的映射。
详细版
核心结构如下:
function clone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') return value
if (seen.has(value)) return seen.get(value)
const result = Array.isArray(value)
? []
: Object.create(Object.getPrototypeOf(value))
seen.set(value, result)
for (const key of Reflect.ownKeys(value)) {
result[key] = clone(value[key], seen)
}
return result
}
这能处理数组、普通对象、Symbol 键、循环和共享引用,但会触发 getter,也没有保留只读属性、不可枚举属性的描述符,更不适用于 Map、私有字段、DOM 节点或函数闭包。工程中优先评估 structuredClone,并先定义允许复制的类型合同。
完整版教学
一、深拷贝首先是一份类型合同
“复制所有东西”不是可实现的明确需求。不同方案支持范围完全不同:
| 类型 | JSON 往返 | structuredClone | 本文教学实现 |
|---|---|---|---|
| 循环引用 | 报错 | 支持 | 支持 |
Date、Map、Set | 语义丢失 | 支持 | 支持 |
| 函数 | 丢失或忽略 | 抛 DataCloneError | 按合同共享引用 |
| DOM 节点 | 不可靠 | 抛 DataCloneError | 不支持 |
| 属性描述符、访问器 | 不保留 | 不保留 | 尽量原样复制描述符 |
复制前应先问:目标是结构化数据、类实例、配置对象,还是包含行为的运行时对象?答案决定实现。
深拷贝的正确性不只看
source.child !== copy.child,还要看循环是否闭合、同一源引用是否仍映射为同一副本,以及类型语义有没有丢失。
二、WeakMap 同时解决循环和别名
考虑一张对象图:
const shared = { score: 100 }
const source = { left: shared, right: shared }
source.self = source
正确副本应满足:
copy !== source
copy.self === copy
copy.left === copy.right
copy.left !== shared
若只用 WeakSet 判断“访问过”,虽然能停止循环,却拿不到之前创建的副本,无法恢复 left === right。WeakMap<源对象, 副本> 同时记录身份映射,而且必须在递归属性前 set,否则处理 self 时映射还不存在。
三、覆盖常见内建类型的实现
下面是面试可讲清楚的增强版,策略是:函数保持原引用,弱集合、Promise 和 DOM 节点拒绝复制。
function deepClone(value, seen = new WeakMap()) {
if (value === null || typeof value !== 'object') return value
if (seen.has(value)) return seen.get(value)
if (value instanceof Date) return new Date(value.getTime())
if (value instanceof RegExp) {
const copy = new RegExp(value.source, value.flags)
copy.lastIndex = value.lastIndex
return copy
}
if (value instanceof ArrayBuffer) {
const copy = value.slice(0)
seen.set(value, copy)
return copy
}
if (ArrayBuffer.isView(value)) {
const buffer = deepClone(value.buffer, seen)
const copy = value instanceof DataView
? new DataView(buffer, value.byteOffset, value.byteLength)
: new value.constructor(buffer, value.byteOffset, value.length)
seen.set(value, copy)
return copy
}
if (value instanceof WeakMap || value instanceof WeakSet || value instanceof Promise) {
throw new TypeError('unsupported object type')
}
if (value instanceof Map) {
const copy = new Map()
seen.set(value, copy)
for (const [key, item] of value) {
copy.set(deepClone(key, seen), deepClone(item, seen))
}
return copy
}
if (value instanceof Set) {
const copy = new Set()
seen.set(value, copy)
for (const item of value) copy.add(deepClone(item, seen))
return copy
}
const copy = Array.isArray(value)
? []
: Object.create(Object.getPrototypeOf(value))
seen.set(value, copy)
for (const key of Reflect.ownKeys(value)) {
const descriptor = Object.getOwnPropertyDescriptor(value, key)
if ('value' in descriptor) descriptor.value = deepClone(descriptor.value, seen)
Object.defineProperty(copy, key, descriptor)
}
return copy
}
这段代码展示机制,不代表能复制任意宿主对象。类型判断跨 iframe 时也可能因构造器来自不同 realm 而失效。
四、为什么要复制描述符而不是直接赋值
for...in 只遍历可枚举字符串键,还会走到原型链;Object.keys 会漏掉 Symbol 和不可枚举自有属性。Reflect.ownKeys 能取得全部自有字符串键和 Symbol 键。
直接读取 value[key] 会执行 getter,可能产生网络请求、计数变化甚至抛错。读取属性描述符后:
- 数据属性递归复制
descriptor.value。 - getter/setter 函数本身按引用保留,不主动执行。
writable、enumerable、configurable等标志得以保留。
不过访问器闭包中的外部状态仍是共享的,这不是“复制函数”可以解决的问题。
五、类实例、私有字段和函数边界
Object.create(Object.getPrototypeOf(value)) 能让副本沿用类的公开原型方法,但不能复制类的私有字段内部槽:
class Wallet {
#amount = 10
getAmount() { return this.#amount }
}
仅复制原型和可见属性得到的对象,调用 getAmount() 会因缺少私有品牌而抛错。可靠做法是由类提供 clone()、序列化协议或工厂方法。
函数包含可执行代码、闭包环境和内部槽,无法通过遍历属性制造独立副本。本文对函数作为属性时保留同一引用;如果业务要求隔离函数状态,必须重新设计数据模型,而不是使用 eval 或字符串化函数。
六、structuredClone 的能力与限制
现代运行环境优先考虑:
const copy = structuredClone(source)
它支持循环引用、Map、Set、多种 TypedArray,并可通过 transfer 转移 ArrayBuffer 所有权;转移后原缓冲区会被分离。它不复制函数和 DOM 节点,也不会保留属性描述符、getter/setter 或 RegExp.lastIndex。
对于纯结构化数据,平台实现通常更可靠。自定义深拷贝适合合同很窄、确实需要保留原型或描述符的场景,并应配套类型白名单和测试,而不是无限追加 instanceof 分支。
七、常见误区与追问
- 误区:
JSON.parse(JSON.stringify(x))就是通用深拷贝。 它不支持循环引用,会改变Date、非有限数字等语义,也会忽略函数、Symbol 值和undefined属性。 - 误区:WeakMap 只用于防止死递归。 它还要保持共享引用的拓扑关系,让同一源对象始终映射到同一副本。
- 误区:沿用类实例原型就复制了实例。 私有字段和其他内部槽不会被普通属性遍历复制。
- 追问:为什么先写
seen.set再递归? 自引用可能在第一个子属性就回到源对象,必须提前让映射可查。 - 追问:Map 的 key 要不要拷贝? 本文同时拷贝 key 和 value;也可保留 key 身份,但必须按业务合同决定。
- 追问:getter 应该执行还是保留? 本文保留访问器描述符且不执行,避免拷贝过程产生副作用。
- 追问:什么时候直接用
structuredClone? 当数据属于其支持的结构化类型、无需保留函数和属性描述符时,应优先使用平台能力。
八、加强记忆
- 定合同:先列支持和拒绝的类型,不承诺复制一切。
- 保身份:WeakMap 保存“源对象到副本”,同时处理循环和共享引用。
- 先登记:创建副本后立刻写入 WeakMap,再递归孩子。
- 保属性:
Reflect.ownKeys配合描述符,避免漏键和误触 getter。 - 认边界:函数闭包、私有字段、DOM 和宿主内部槽不能靠通用递归复制。
- 会选型:纯结构化数据优先
structuredClone,自定义实现必须有明确白名单。