返回首页

JavaScript 深拷贝:从浅拷贝的坑到手写一个健壮版本

扩展运算符只能拷一层,JSON.parse(JSON.stringify) 会丢失一堆类型,structuredClone 又有它的边界。这篇把浅拷贝、深拷贝的各种方案串起来,最后手写一个能处理循环引用和特殊类型的深拷贝。

26 约 5 分钟 · 6212 字 前端

"深拷贝怎么写"是 JS 面试的常客,但它不只是面试题。真实项目里,只要你改过一份"看起来是新对象、结果原对象也跟着变"的数据,就踩过浅拷贝的坑。

这篇从问题出发,把各种方案的边界讲清楚,最后手写一个够用的深拷贝。

浅拷贝的问题

先明确:JS 里对象是按引用存储的。直接赋值只是复制了引用,两个变量指向同一块内存。

const a = { user: { name: 'Tom' } }
const b = a
b.user.name = 'Jerry'
console.log(a.user.name) // 'Jerry' —— a 也被改了

浅拷贝(shallow copy)比直接赋值进了一步:它复制了第一层,但嵌套的对象仍然是共享引用。

const a = { count: 1, user: { name: 'Tom' } }
const b = { ...a }

b.count = 2
console.log(a.count) // 1   —— 第一层是独立的

b.user.name = 'Jerry'
console.log(a.user.name) // 'Jerry' —— 第二层还是共享的

常见的浅拷贝方式:

const b1 = { ...a }            // 扩展运算符
const b2 = Object.assign({}, a) // Object.assign
const arr2 = [...arr]          // 数组扩展
const arr3 = arr.slice()       // slice

只要数据是一层的扁平结构,浅拷贝完全够用,而且最快。问题出在嵌套数据上。

JSON 大法:能用,但坑很多

最常被随手用的深拷贝:

const clone = JSON.parse(JSON.stringify(obj))

它确实能拷贝任意层级,但代价是会悄悄丢失或改变一堆东西:

const obj = {
  date: new Date(),
  reg: /abc/g,
  fn: () => 1,
  undef: undefined,
  inf: Infinity,
  nan: NaN,
  sym: Symbol('s'),
  big: 10n,
}

console.log(JSON.parse(JSON.stringify(obj)))
// {
//   date: '2026-06-20T...'  → 变成了字符串
//   reg: {}                 → 正则变成空对象
//   inf: null, nan: null    → Infinity / NaN 变 null
//   fn / undef / sym 这三个 key 直接消失了
// }
// big: 10n 会直接抛错 TypeError: Do not know how to serialize a BigInt

总结它的"丢失清单":

  • Date → 变字符串
  • RegExp / Map / Set → 变空对象 {}
  • function / undefined / Symbol → 整个键被丢掉
  • Infinity / NaN → 变 null
  • BigInt → 直接抛错
  • 循环引用 → 直接抛错 Converting circular structure to JSON

所以这个写法只适合:纯粹由 string / number / boolean / null / 普通对象 / 数组组成的数据(比如大多数接口返回的 JSON)。一旦数据里有 Date、Map 之类,就别用它。

structuredClone:现代标准方案

浏览器和 Node 17+ 现在内置了 structuredClone,这是目前最省事的深拷贝:

const clone = structuredClone(obj)

它基于结构化克隆算法,能正确处理大量类型:

  • DateRegExpMapSetArrayBufferTypedArrayBlobFile
  • 循环引用也能正确处理

但它也有自己的边界:

  • 函数会抛错 DataCloneError(无法克隆)
  • Symbol 会抛错
  • DOM 节点会抛错
  • 原型链会丢失:克隆类实例后,结果是普通对象,不再是原来的类实例
  • 不会拷贝 getter/setter、不可枚举属性
structuredClone({ fn: () => 1 }) // ❌ DataCloneError

class Point { constructor(x) { this.x = x } hi() {} }
const p = structuredClone(new Point(1))
console.log(p instanceof Point) // false —— 变成普通对象了

结论:如果数据里没有函数、且不依赖类实例的方法,优先用 structuredClone,简单又可靠。需要拷函数、保留原型,才考虑手写。

手写一个深拷贝

面试要手写,实战里偶尔也要(比如要拷贝函数引用、或想完全掌控行为)。我们一步步加强。

第一版:基础递归

function deepClone(value) {
  if (value === null || typeof value !== 'object') {
    return value // 基本类型 + 函数,直接返回
  }

  const result = Array.isArray(value) ? [] : {}
  for (const key in value) {
    if (Object.prototype.hasOwnProperty.call(value, key)) {
      result[key] = deepClone(value[key])
    }
  }
  return result
}

这版能处理普通对象和数组的嵌套,但有两个明显问题:遇到循环引用会爆栈,而且 Date、Map、Set、RegExp 都拷不对。

第二版:处理循环引用

循环引用是指对象间接或直接引用了自己:

const a = {}
a.self = a // 循环引用

解决办法:用一个 WeakMap 记录"已经拷贝过的对象 → 它的副本"。再次遇到同一个引用时,直接返回已有副本。用 WeakMap 是因为它的键是弱引用,不会阻止垃圾回收。

function deepClone(value, cache = new WeakMap()) {
  if (value === null || typeof value !== 'object') {
    return value
  }

  // 命中缓存,说明是循环引用,直接返回之前的副本
  if (cache.has(value)) {
    return cache.get(value)
  }

  const result = Array.isArray(value) ? [] : {}
  cache.set(value, result) // 先登记,再递归,关键顺序

  for (const key in value) {
    if (Object.prototype.hasOwnProperty.call(value, key)) {
      result[key] = deepClone(value[key], cache)
    }
  }
  return result
}

注意 cache.set(value, result) 必须在递归之前,否则循环引用还是会无限递归。

第三版:处理特殊类型

把 Date、RegExp、Map、Set、Symbol 键都补上。这一版基本可以应付绝大多数实战场景:

function deepClone(value, cache = new WeakMap()) {
  // 基本类型 + 函数:直接返回(函数一般共享引用即可)
  if (value === null || typeof value !== 'object') {
    return value
  }

  // 循环引用
  if (cache.has(value)) return cache.get(value)

  // Date
  if (value instanceof Date) return new Date(value.getTime())

  // RegExp(保留源和标志,lastIndex)
  if (value instanceof RegExp) {
    const re = new RegExp(value.source, value.flags)
    re.lastIndex = value.lastIndex
    return re
  }

  // Map
  if (value instanceof Map) {
    const result = new Map()
    cache.set(value, result)
    value.forEach((v, k) => result.set(deepClone(k, cache), deepClone(v, cache)))
    return result
  }

  // Set
  if (value instanceof Set) {
    const result = new Set()
    cache.set(value, result)
    value.forEach((v) => result.add(deepClone(v, cache)))
    return result
  }

  // 数组 / 普通对象;用 getPrototypeOf 让结果尽量保留原型
  const result = Array.isArray(value)
    ? []
    : Object.create(Object.getPrototypeOf(value))
  cache.set(value, result)

  // Reflect.ownKeys 能同时拿到 string 键和 Symbol 键
  for (const key of Reflect.ownKeys(value)) {
    result[key] = deepClone(value[key], cache)
  }

  return result
}

相比前两版的改进:

  • instanceof 分别处理了 Date / RegExp / Map / Set
  • Reflect.ownKeys 替代 for...in,能拷到 Symbol 键
  • Object.create(Object.getPrototypeOf(value)) 尽量保留原型(类实例的方法挂在原型上,这样不会丢)

这已经覆盖了日常 99% 的需求。真要做到工业级(getter/setter、不可枚举属性、各种 TypedArray),代码会膨胀很多,那时直接上 lodash 的 cloneDeep 更实际。

几种方案怎么选

方案 能拷嵌套 函数 Date/Map/Set 循环引用 速度 适用
扩展运算符 / Object.assign 仅一层 最快 扁平数据
JSON.parse(JSON.stringify) 丢失 丢失/出错 报错 纯 JSON 数据
structuredClone 报错 支持 支持 较快 无函数、不依赖类方法
手写 deepClone 可保留引用 可支持 可支持 看实现 需完全掌控
lodash cloneDeep 处理 支持 支持 工业级、省心

实战决策:

  1. 数据是扁平的 → 浅拷贝({ ...obj }),最快。
  2. 纯 JSON、要快糙猛JSON.parse(JSON.stringify()),但确认没有特殊类型。
  3. 现代环境、无函数structuredClone,首选。
  4. 要拷函数 / 保留类实例 / 完全可控 → 手写或 lodash。

总结

深拷贝没有银弹,关键是先看清你的数据长什么样:

  • 一层就好 → 别上深拷贝,浅拷贝足够。
  • 全是 JSON 基本类型 → JSON 大法或 structuredClone
  • 有 Date/Map/Set 等 → structuredClone 或手写。
  • 有函数、要保留原型 → 手写或 lodash。

手写版的三个关键点也值得记牢:用 WeakMap 解决循环引用、set 要在递归前、用 Reflect.ownKeys 拷 Symbol 键。把这几点想明白,这道"面试题"就再也难不住你了。