「JavaScript」带你彻底搞清楚深拷贝、浅拷贝和循环引用
本文保留原文从引用关系、浅拷贝、
JSON.parse(JSON.stringify())、递归深拷贝到循环引用的学习路线,并修正“栈/堆地址”“数组示例未定义”“递归函数名错误”等容易误导或无法运行的内容。
一、先理解值、引用和对象身份
JavaScript 的规范层面区分原始值(primitive value)和对象(object)。原始值包括 undefined、null、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; NaN、Infinity和-Infinity会序列化为null;Date会通过toJSON()变成字符串,恢复后不是Date实例;RegExp、Map、Set、ArrayBuffer等对象不能按原类型恢复;- 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.a 和 obj.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、数据库序列化或领域协议,而不是把“深拷贝”当作持久化方案。
八、总结
- 对象赋值复制的是引用值,不是递归复制对象;
- 浅拷贝只创建新的外层容器,嵌套对象仍可能共享;
- JSON 往返只适合 JSON 数据,不能处理循环引用和许多内置类型;
structuredClone()是现代环境中更合适的通用选择,但仍有不可克隆类型和原型边界;WeakMap可以把循环引用和重复引用映射到同一个副本;- 深拷贝不一定是正确需求,明确数据边界、原型、资源所有权和序列化格式更重要。
参考资料: