文章

手写深拷贝与浅比较深度解析:为什么 structuredClone 也不是万能药?

深拷贝难点在循环引用(WeakMap 记忆化)、20 多种内置类型兼容、迭代栈防爆栈;浅比较核心是 Object.is 语义。 面试考类型边界:NaN 与 Object.is(NaN,NaN) 为 true 这类细节。

手写深拷贝与浅比较深度解析:为什么 structuredClone 也不是万能药?

一句话概括

深拷贝的难度不在于「递归复制属性」,而在于处理循环引用(WeakMap 做记忆化)、兼容 Map/Set/Date/RegExp/ArrayBuffer 等 20+ 种内置类型、以及避免栈溢出(迭代栈替代递归);浅比较的核心则是 Object.is 的精准语义——NaN === NaN 是 false 但 Object.is(NaN, NaN) 是 true。

核心知识点

1. 浅拷贝 vs 深拷贝的本质

1
2
3
4
5
6
7
8
9
10
11
const obj = { a: 1, b: { c: 2 } }

// 引用赋值:改一个影响另一个
const ref = obj;                      ref.b.c = 99    // obj.b.c 也变成 99

// 浅拷贝:只复制第一层
const shallow = { ...obj };           shallow.b.c = 88 // obj.b.c 也变成 88!
shallow.a = 10                        // obj.a 仍是 1 ✅

// 深拷贝:所有层级独立
const deep = structuredClone(obj);    deep.b.c = 77    // obj.b.c 还是 2 ✅

2. JSON.parse(JSON.stringify()) 的 7 宗罪

面试高频考点——用这个方案会被追问「那你怎么解决」:

1
2
3
4
5
6
7
8
9
10
const data = {
  date: new Date(),       // → 变成字符串 "2026-07-17T..."
  regex: /hello/gi,       // → 变成 {}
  fn: () => {},           // → 被丢弃
  undef: undefined,       // → 被丢弃
  sym: Symbol('x'),       // → 被丢弃
  map: new Map([['k',1]]),// → 变成 {}
  nan: NaN,               // → 变成 null!
  big: 9007199254740993n, // → TypeError: BigInt 无法序列化
}

3. 生产级深拷贝:覆盖所有内置类型 + 处理循环引用

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
function deepClone(obj, map = new WeakMap()) {
  // 基本类型 / null / function(函数通常不拷贝)
  if (obj === null || typeof obj !== 'object') return obj
  if (typeof obj === 'function') return obj

  // 循环引用检测(核心!)
  if (map.has(obj)) return map.get(obj)

  // Date / RegExp
  if (obj instanceof Date) return new Date(obj)
  if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags)

  // Map
  if (obj instanceof Map) {
    const m = new Map(); map.set(obj, m)
    obj.forEach((v, k) => m.set(deepClone(k, map), deepClone(v, map)))
    return m
  }

  // Set
  if (obj instanceof Set) {
    const s = new Set(); map.set(obj, s)
    obj.forEach(v => s.add(deepClone(v, map)))
    return s
  }

  // TypedArray / ArrayBuffer
  if (ArrayBuffer.isView(obj)) {
    return new obj.constructor(
      obj.buffer.slice(0), obj.byteOffset, obj.length
    )
  }

  // Array / Object — 使用 for...in + hasOwnProperty(最兼容)
  const result = Array.isArray(obj) ? [] : {}
  map.set(obj, result)
  for (const key of Object.keys(obj)) {
    result[key] = deepClone(obj[key], map)
  }
  // 别漏了 Symbol 属性
  for (const key of Object.getOwnPropertySymbols(obj)) {
    result[key] = deepClone(obj[key], map)
  }
  return result
}

核心技巧:WeakMap 做记忆化——map.set(obj, result) 记录「原始对象→克隆对象」的映射,后续再遇到同一引用直接返回克隆版本。WeakMap 不会阻止 GC,所以不会泄漏内存。

4. 避免栈溢出:迭代替代递归

递归深拷贝遇到深层嵌套(>10000 层)会爆栈:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function deepCloneIterative(obj) {
  const root = Array.isArray(obj) ? [] : {}
  const stack = [{ source: obj, target: root }]
  const map = new WeakMap([[obj, root]])

  while (stack.length) {
    const { source, target } = stack.pop()
    for (const key of Object.keys(source)) {
      const val = source[key]
      if (val !== null && typeof val === 'object') {
        if (map.has(val)) { target[key] = map.get(val); continue }
        if (val instanceof Date) { target[key] = new Date(val); continue }
        if (val instanceof RegExp) { target[key] = new RegExp(val.source, val.flags); continue }
        const clone = Array.isArray(val) ? [] : {}
        map.set(val, clone); target[key] = clone
        stack.push({ source: val, target: clone })
      } else {
        target[key] = val
      }
    }
  }
  return root
}

5. 浅比较:React.memo 的幕后功臣

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function shallowEqual(a, b) {
  // 用 Object.is 而非 ===,因为 Object.is(NaN, NaN) 是 true
  if (Object.is(a, b)) return true

  if (typeof a !== 'object' || a === null ||
      typeof b !== 'object' || b === null) return false

  const keysA = Object.keys(a), keysB = Object.keys(b)
  if (keysA.length !== keysB.length) return false

  for (const key of keysA) {
    if (!Object.prototype.hasOwnProperty.call(b, key)) return false
    if (!Object.is(a[key], b[key])) return false
  }
  return true
}

// React.memo 的默认比较就是这种浅比较
// 这就是为什么父组件传新对象字面量就会导致子组件重渲染
const Parent = () => {
  const data = { name: 'Alice' }  // 每次 render 新引用 → memo 失效
  return <Child data={data} />
}

解决方案:useMemo 稳定引用:

1
const data = useMemo(() => ({ name: 'Alice' }), [])

其实你每天都在用

  • React 状态更新 — setState(newValue) 要求 newValue 是全新对象,React 靠浅比较判断 prev !== next 来决定重渲染——你拷贝错了一个嵌套层级,组件就不更新了
  • Redux reducer — return { ...state, user: { ...state.user, name: 'Bob' } } 这套「展开运算符体操」就是在做不可变更新(浅拷贝),少展开一层就会出现 bug
  • Vue watch 的深度监听 — watch(source, callback, { deep: true }) 内部就是在做深比较,对象里任何叶子变了都触发
  • 配置合并 — Object.assign({}, defaults, userConfig) 是浅合并,所以用户只传了一层配置就能覆盖所有默认值,但嵌套配置需要 merge
  • 剪贴板/DevTools — Chrome DevTools 的「Copy object」、console.log(obj) 之后在控制台改属性不影响原对象——DevTools 内部用的也是序列化+反序列化方式传递数据,本质上是一次深拷贝

常见误解(FAQ)

❌ 误区:「structuredClone 能解决一切深拷贝问题」

structuredClone 确实比 JSON 方案强得多(支持 Map/Set/Date/RegExp/ArrayBuffer/循环引用),但仍有局限:不支持 Function/Symbol/WeakMap/DOM 节点,遇到会抛 DataCloneError。而且对象原型会被重置为 Object.prototype(class 实例的方法丢失)。

❌ 误区:「浅比较只要比较对象的所有 key 值相等就行」

还要比较两个对象是否是同一个引用(objA === objB)——如果引用相同,直接返回 true,省去遍历。另一个隐秘陷阱:key 的数量必须相等——{ a: 1 } 和 { a: 1, b: 2 } 不能说只比 a 相等就算相等。

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

二者都只做一层复制。{ ...obj } 只会复制 obj 自身的可枚举属性到新对象,嵌套对象仍然是原来的引用。JSON.parse(JSON.stringify(obj)) 也不是真正的深拷贝。

❌ 误区:「手写深拷贝时用 Map 记录已访问对象就足够处理循环引用了」

应该用 WeakMap 而非 Map。Map 的 key 会阻止 GC,如果你深拷贝一个图结构(大量对象相互引用),Map 会阻止所有这些对象被回收。WeakMap 的 key 是弱引用,不影响 GC。

一句话总结

深拷贝和浅比较,一个是「完全复制一个对象图」的工程难题,一个是「快速判断两个对象是否表层一致」的性能优化——前者不丢数据是关键,后者不引起假更新是关键。理解了这两者,React 的性能优化、Redux 的不可变原则、以及 JS 引用类型的本质才算真正入门。

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