技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTJS

「JavaScript」带你彻底搞清楚深拷贝、浅拷贝和循环引用

所属馆藏
JavaScript
文件格式
Markdown
原始路径
JavaScript/62-「JavaScript」带你彻底搞清楚深拷贝、浅拷贝和循环引用
本文目录8 个章节
  1. 一、先理解值、引用和对象身份
  2. 二、什么是浅拷贝和深拷贝
  3. 三、深拷贝的含义和边界
  4. 四、JSON.parse(JSON.stringify()):方便但不是深拷贝通用方案
  5. 五、现代选择:structuredClone()
  6. 六、手写递归深拷贝:用于学习,不是生产级 polyfill
  7. 七、浅拷贝、深拷贝和结构化共享如何选择
  8. 八、总结

「JavaScript」带你彻底搞清楚深拷贝、浅拷贝和循环引用

本文保留原文从引用关系、浅拷贝、JSON.parse(JSON.stringify())、递归深拷贝到循环引用的学习路线,并修正“栈/堆地址”“数组示例未定义”“递归函数名错误”等容易误导或无法运行的内容。

一、先理解值、引用和对象身份

JavaScript 的规范层面区分原始值(primitive value)对象(object)。原始值包括 undefinednull、Boolean、Number、BigInt、String 和 Symbol;数组、函数、日期、正则表达式、Map、Set 等都是对象。

需要特别说明:规范没有要求原始值一定放在“栈”里、对象一定放在“堆”里。栈和堆是引擎可能采用的实现策略,闭包变量、逃逸对象、优化后的值表示都可能改变实际存储方式。讨论深浅拷贝时,最重要的是值的身份和引用关系,而不是假定某个变量一定对应一个栈地址。

对原始值赋值时,得到的是独立的值:

let a = 1
let b = a
b = 2

console.log(a) // 1
console.log(b) // 2

对对象赋值时,复制的是对象引用。两个变量可以指向同一个对象:

const first = { name: 'Ada' }
const second = first

second.name = 'Grace'
console.log(first.name) // Grace
console.log(first === second) // true

函数参数也遵循按值传递。对象参数的值是一个对象引用,因此函数可以通过这份引用修改对象属性,但重新给参数赋值不会改变调用者变量的绑定:

function change(user) {
  user.name = 'Grace'
  user = { name: 'Alan' }
}

const user = { name: 'Ada' }
change(user)
console.log(user) // { name: 'Grace' }

二、什么是浅拷贝和深拷贝

2.1 浅拷贝

浅拷贝会创建一个新的外层对象,并复制源对象的自有可枚举属性值;如果属性值是对象,复制的仍然是同一个嵌套对象引用。

const source = {
  name: 'Ada',
  profile: { language: 'JavaScript' }
}

const shallow = { ...source }
shallow.name = 'Grace'
shallow.profile.language = 'TypeScript'

console.log(source.name) // Ada,原始字符串被独立复制
console.log(source.profile.language) // TypeScript,嵌套对象仍共享
console.log(source.profile === shallow.profile) // true

浅拷贝不是“只复制基本值”。更准确的说法是:它只复制一层属性值,属性值本身可能是原始值,也可能是对象引用。

2.2 常见浅拷贝方式

Array.prototype.concat()

const arr = [1, 2, 3, [5, 6]]
const copy = arr.concat()

copy[0] = 99
copy[3][1] = 7

console.log(arr) // [1, 2, 3, [5, 7]]
console.log(copy) // [99, 2, 3, [5, 7]]

slice()Array.from() 和数组展开也属于类似的浅拷贝:

const original = [{ id: 1 }, { id: 2 }]
const a = original.slice()
const b = Array.from(original)
const c = [...original]

console.log(a !== original, b !== original, c !== original) // true true true
console.log(a[0] === original[0]) // true

Object.assign()

const obj1 = { x: 1, y: 2 }
const obj2 = Object.assign({}, obj1)

obj2.x = 3
console.log(obj1) // { x: 1, y: 2 }
console.log(obj2) // { x: 3, y: 2 }

嵌套对象仍然共享:

const obj1 = {
  x: 1,
  y: { m: 1 }
}
const obj2 = Object.assign({}, obj1)

obj2.y.m = 2
console.log(obj1.y.m) // 2
console.log(obj1.y === obj2.y) // true

Object.assign() 和对象展开只处理源对象的自有可枚举字符串键和 Symbol 键,并且会触发读取器、以普通赋值的方式写入目标对象。它们不是通用的对象克隆器,也不会复制原型、非枚举属性或完整属性描述符。

三、深拷贝的含义和边界

深拷贝通常指创建一组新的对象,使复制结果中的嵌套对象不再与源对象共享引用,同时尽量保留原有的数据关系。这里的“尽量”很重要:函数、DOM 节点、WeakMap、资源句柄和自定义类实例未必有通用的深拷贝语义。

如果对象中有两个属性指向同一个子对象,正确的深拷贝还应保持这种关系:

const shared = { value: 1 }
const source = { left: shared, right: shared }

const copy = structuredClone(source)
console.log(copy.left === copy.right) // true
console.log(copy.left === shared) // false

四、JSON.parse(JSON.stringify()):方便但不是深拷贝通用方案

对只包含 JSON 数据的简单对象,JSON 往返有时可以满足需求:

const obj1 = {
  x: 1,
  y: { m: 1 }
}
const obj2 = JSON.parse(JSON.stringify(obj1))

obj2.y.m = 2
console.log(obj1.y.m) // 1
console.log(obj2.y.m) // 2

但 JSON 方法只处理 JSON 数据模型,常见限制包括:

  • 对象属性值为 undefined、函数或 Symbol 时通常会被忽略;数组中的这些值通常变成 null
  • NaNInfinity-Infinity 会序列化为 null
  • Date 会通过 toJSON() 变成字符串,恢复后不是 Date 实例;
  • RegExpMapSetArrayBuffer 等对象不能按原类型恢复;
  • BigInt 不能直接 JSON 序列化,会抛出 TypeError
  • 循环引用会抛出 TypeError: Converting circular structure to JSON
  • 原型、非枚举属性、访问器和属性描述符不会按原样保留;
  • 序列化会读取属性,用户自定义的 toJSON 也可能改变结果。

因此,JSON 往返适合明确的 JSON 数据快照,不应被称为“万能深拷贝”。

五、现代选择:structuredClone()

在支持结构化克隆算法的浏览器和现代 Node.js 中,可以优先考虑全局 structuredClone()

const source = {
  date: new Date('2024-01-01T00:00:00Z'),
  pattern: /js/gi,
  map: new Map([['answer', 42]]),
  set: new Set([1, 2]),
  nested: { ok: true }
}
source.self = source

const copy = structuredClone(source)

console.log(copy !== source) // true
console.log(copy.self === copy) // true
console.log(copy.date instanceof Date) // true
console.log(copy.pattern instanceof RegExp) // true
console.log(copy.map instanceof Map) // true
console.log(copy.set instanceof Set) // true

structuredClone() 能处理循环引用、许多内置对象和部分可转移对象,但不是所有 JavaScript 值都可克隆。函数、WeakMap、WeakSet、部分宿主对象和不可克隆的 Symbol 值会导致 DataCloneError;自定义类实例也不应默认假设能恢复完整的原型和行为。需要传输大型 ArrayBuffer 时,还可以研究 transfer 选项,因为转移和复制是不同语义。

const buffer = new ArrayBuffer(8)
const moved = structuredClone({ buffer }, { transfer: [buffer] })

console.log(moved.buffer.byteLength) // 8
console.log(buffer.byteLength) // 0,原 buffer 的内容所有权已转移

如果要兼容不支持 structuredClone() 的环境,应根据数据类型选择专门的序列化方案或库,而不是盲目实现一个“覆盖所有对象”的函数。

浅拷贝与深拷贝关系示意图

图片来源:原文配图。已下载为本地图片;原图的许可和署名信息以来源站点为准。

六、手写递归深拷贝:用于学习,不是生产级 polyfill

6.1 最简单的递归版本

原文的递归思路是正确的,但示例中调用了未定义的 deepCopy,而且只处理普通对象、不能处理数组和循环引用。修正后的教学版如下:

function deepCopyPlain(value) {
  if (value === null || typeof value !== 'object') {
    return value
  }

  const result = Array.isArray(value) ? [] : {}

  for (const key of Object.keys(value)) {
    result[key] = deepCopyPlain(value[key])
  }

  return result
}

const source = {
  x: { m: 1 },
  y: undefined,
  z: function add(a, b) {
    return a + b
  },
  a: Symbol('foo')
}

const copy = deepCopyPlain(source)
copy.x.m = 2

console.log(source.x.m) // 1
console.log(copy.x.m) // 2
console.log(copy.z === source.z) // true,函数仍是同一个引用

这个版本只能处理普通对象、数组和可枚举字符串键,函数、日期、正则、Map、Set、原型、非枚举属性和循环引用都需要额外设计。

6.2 用 WeakMap 处理循环引用和重复引用

WeakMap 记录“源对象 → 副本”的映射。必须在递归子节点之前写入映射,这样父级循环引用才能立即返回已经创建但尚未填充完成的副本:

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) {
    return new RegExp(value.source, value.flags)
  }

  if (value instanceof Map) {
    const result = new Map()
    seen.set(value, result)
    for (const [key, item] of value) {
      result.set(deepClone(key, seen), deepClone(item, seen))
    }
    return result
  }

  if (value instanceof Set) {
    const result = new Set()
    seen.set(value, result)
    for (const item of value) {
      result.add(deepClone(item, seen))
    }
    return result
  }

  if (value instanceof ArrayBuffer) {
    return value.slice(0)
  }

  if (ArrayBuffer.isView(value)) {
    return new value.constructor(value)
  }

  const result = Array.isArray(value)
    ? []
    : Object.create(Object.getPrototypeOf(value))
  seen.set(value, result)

  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(result, key, descriptor)
  }

  return result
}

测试父级循环和同级共享引用:

const shared = { name: 'a' }
const source = {
  a: shared,
  b: { name: 'b' },
  c: { d: { e: shared } }
}
source.self = source

const copy = deepClone(source)

console.log(copy !== source) // true
console.log(copy.self === copy) // true
console.log(copy.a === copy.c.d.e) // true,重复引用关系被保留
console.log(copy.a === source.a) // false

这个实现仍然是教学代码。它没有覆盖所有带内部槽的内置对象、代理、宿主对象、私有字段和资源句柄;在应用中优先使用 structuredClone() 或经过测试的领域专用序列化方案。

6.3 原文父级引用算法的局限

原文先尝试沿着 parent 链向上检查父级引用,这可以处理“子对象重新指向父对象”的情况,但不能处理同级共享引用。例如下面的对象中,obj.aobj.c.d.e 指向同一个对象,它们并不在彼此的父级链上:

const obj = {
  a: { name: 'a' },
  b: { name: 'b' },
  c: {}
}
obj.c.d = { e: obj.a }

console.log(obj.c.d.e === obj.a) // true

只追踪父级链的 deepCopy2 会把这个对象复制两次,导致:

// 朴素父链复制可能得到 false
// copy.a !== copy.c.d.e

使用全局 WeakMap 映射才能同时处理父级循环和同级重复引用。原文 deepCopy3 的方向是正确的,但实现只创建普通对象、没有处理数组和内置对象;上面的 deepClone 是补充后的教学版本。

七、浅拷贝、深拷贝和结构化共享如何选择

  • 只更新顶层配置:对象展开或 Object.assign() 通常足够;
  • 复制 JSON 请求响应:优先明确数据契约,JSON 往返只适合 JSON 类型;
  • 需要日期、Map、Set、循环引用:考虑 structuredClone()
  • 需要保留类方法、数据库连接或 DOM 关系:不要随意深拷贝,设计显式的转换/重建函数;
  • 大型状态树:可以使用结构共享,只复制发生变化的路径;
  • 需要跨线程传输:区分 clone 和 transfer,并处理所有权变化;
  • 需要持久化:选择 JSON、structured clone、数据库序列化或领域协议,而不是把“深拷贝”当作持久化方案。

八、总结

  1. 对象赋值复制的是引用值,不是递归复制对象;
  2. 浅拷贝只创建新的外层容器,嵌套对象仍可能共享;
  3. JSON 往返只适合 JSON 数据,不能处理循环引用和许多内置类型;
  4. structuredClone() 是现代环境中更合适的通用选择,但仍有不可克隆类型和原型边界;
  5. WeakMap 可以把循环引用和重复引用映射到同一个副本;
  6. 深拷贝不一定是正确需求,明确数据边界、原型、资源所有权和序列化格式更重要。

参考资料:

原文参考:GitHub issue:深拷贝、浅拷贝和循环引用

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS