← 返回题目列表

如何手写深拷贝?需要注意哪些边界?

高频 困难 第 16 / 27 题 更新于 2026/07/28
手写代码深拷贝递归循环引用

简化版

深拷贝要让新对象不再共享可变的嵌套引用。基础题可递归数组和普通对象;进阶要处理循环引用、重复引用、原型、属性描述符、DateRegExpMapSetArrayBuffer 等。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本文教学实现
循环引用报错支持支持
DateMapSet语义丢失支持支持
函数丢失或忽略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 === rightWeakMap<源对象, 副本> 同时记录身份映射,而且必须在递归属性前 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 函数本身按引用保留,不主动执行。
  • writableenumerableconfigurable 等标志得以保留。

不过访问器闭包中的外部状态仍是共享的,这不是“复制函数”可以解决的问题。

五、类实例、私有字段和函数边界

Object.create(Object.getPrototypeOf(value)) 能让副本沿用类的公开原型方法,但不能复制类的私有字段内部槽:

class Wallet {
  #amount = 10
  getAmount() { return this.#amount }
}

仅复制原型和可见属性得到的对象,调用 getAmount() 会因缺少私有品牌而抛错。可靠做法是由类提供 clone()、序列化协议或工厂方法。

函数包含可执行代码、闭包环境和内部槽,无法通过遍历属性制造独立副本。本文对函数作为属性时保留同一引用;如果业务要求隔离函数状态,必须重新设计数据模型,而不是使用 eval 或字符串化函数。

六、structuredClone 的能力与限制

现代运行环境优先考虑:

const copy = structuredClone(source)

它支持循环引用、MapSet、多种 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 当数据属于其支持的结构化类型、无需保留函数和属性描述符时,应优先使用平台能力。

八、加强记忆

  1. 定合同:先列支持和拒绝的类型,不承诺复制一切。
  2. 保身份:WeakMap 保存“源对象到副本”,同时处理循环和共享引用。
  3. 先登记:创建副本后立刻写入 WeakMap,再递归孩子。
  4. 保属性Reflect.ownKeys 配合描述符,避免漏键和误触 getter。
  5. 认边界:函数闭包、私有字段、DOM 和宿主内部槽不能靠通用递归复制。
  6. 会选型:纯结构化数据优先 structuredClone,自定义实现必须有明确白名单。