深入理解 JavaScript 的浅拷贝与深拷贝
在 JavaScript 中执行 b = a 赋值操作时,基本数据类型会生成一份相互独立的值;但对象类型仅仅是新增一个变量指向同一块堆内存。修改 b.foo 的同时 a.foo 的值也会同步变化,这并不是赋值逻辑异常,而是对象引用被多个变量共享。
浅拷贝只会复制对象第一层属性,嵌套对象依旧维持引用共享;深拷贝会递归处理全部嵌套结构,完整生成一套相互独立的数据副本。
能够分清赋值、浅拷贝、深拷贝三者差异,才能在状态修改、函数传参、数据缓存的业务场景中,规避「修改 A 对象却意外改动 B 对象」这类隐蔽 bug。
对象引用共享的底层根源,取决于数据存储位置与变量实际保存的内容,先理清内存模型,才能真正理解拷贝行为。
堆与栈的概念辨析
堆、栈存在两套完全不同的释义:一套属于数据结构范畴,一套是 JavaScript 引擎内存分区的实现概念。ECMAScript 标准没有强制规定数据必须存放于栈或者堆,下文内存模型为主流 JavaScript 引擎的实现方案,用于解释变量存储逻辑。
作为数据结构
- 栈(Stack):运算受限的线性表,仅允许在栈顶一端完成增删操作。元素入栈称为压栈(Push),栈顶元素移除称为出栈(Pop)。遵循先进后出(First In Last Out,FILO)规则,也常称作后进先出(Last In First Out,LIFO)。
- 堆(Heap):树形结构,属于特殊完全二叉树,具备堆序性。根节点为最大值称为大顶堆,根节点为最小值称为小顶堆,堆也常被叫做优先队列(Priority Queue)。堆内部左右子节点之间没有强制大小约束。
计算机内存中的堆与栈
这里是引擎管理内存的两种区域,和上面的数据结构概念区分开。
堆(Heap)
在 C、C++ 中,堆内存由开发者手动分配、释放。JavaScript 不提供 malloc、free 接口,对象内存由 JavaScript 引擎分配,依靠垃圾回收器(GC)自动回收。
- 函数局部变量失去引用后,会被 GC 回收;闭包会延长对象引用,推迟回收时机。
- 全局变量在页面销毁前持续存在,业务开发尽量减少全局变量;不需要的数据手动置为
null,解除引用帮助 GC 回收内存。 - 堆存储引用类型对象:
Object、Array、Date、RegExp、Function等。栈中变量存储的不是对象本体,而是指向堆内存的指针,读取对象时通过指针寻址拿到堆内实体。对象占用内存大小各不相同,由引擎动态分配。 - 堆内对象内容是可变的。
- 对象之间进行比较,对比的是内存引用地址,而不是逐个比对内部属性。
JavaScript 依靠标记-清除算法(搭配分代优化)实现垃圾回收。开发者无法直接调用 GC,只能通过解除对象引用触发回收。
栈(Stack)
栈内存由引擎随执行上下文自动分配、自动释放,存放函数参数、局部绑定,操作逻辑和栈数据结构保持一致,栈内存访问速度更快。
- 栈保存基本类型的值,教学文档中常将
undefined、boolean、number、string、bigint、symbol、null视作栈存储。实际引擎实现中,长字符串、部分BigInt、Symbol会落到堆内存,变量保存的是值句柄,不存在对象那样的共享可变特性。 - 基本类型占用内存大小固定,按值存储,可以直接访问。
- 基本类型的值不可变,字符串下标赋值不会修改原始字符串。
var str = "abc"
str[1] = 'ddd' // 非严格模式下赋值无效;严格模式抛出 TypeError
console.log(str) // 输出 abc
- 基本类型之间做比较,是真实值的对比。
为什么内存要划分堆、栈? 主要出于内存分配开销、垃圾回收效率考量。函数创建执行上下文,上下文内绑定随函数销毁释放(闭包除外),基础值放在栈内存销毁效率更高。
函数内部通过
new创建的对象本体存放在堆,栈仅保存引用指针。对象不会随着函数执行结束销毁,只要存在变量引用就会常驻内存;全部引用断开之后,GC 才会回收堆内存。
回到拷贝逻辑:b = a,如果 a 是对象,赋值只会复制指针引用。两个变量指向堆中同一个对象,修改属性就会互相影响。想要两份完全独立的数据,就需要手动创建新对象复制属性;只复制第一层就是浅拷贝,递归复制全部嵌套就是深拷贝。
赋值、浅拷贝、深拷贝的区别
赋值操作 =
- 基本类型赋值:复制原始值,生成两份独立数据,修改其中一个不会影响另一个。
- 引用类型赋值:复制内存引用地址,并不会创建新对象。新旧变量指向堆中同一个实体,修改任意一方的对象属性,另一方同步变化。
let objA = {
a: 'abc',
b: '123'
}
let objB = objA
objB.a = 'xyz'
console.log(objA) // {a: "xyz", b: "123"}
浅拷贝与深拷贝
浅拷贝、深拷贝仅针对引用类型。二者都会生成全新对象,分配独立存储空间,把源对象属性复制到新对象,拷贝完成后外层对象引用相互独立。
- 浅拷贝:仅复制对象第一层自有属性,不递归嵌套结构。第一层为基本类型,副本互不干扰;第一层如果是对象或数组,依旧共享堆内存引用,修改嵌套属性会互相影响。
- 深拷贝:复制第一层同时递归遍历所有嵌套对象,完整生成独立副本(函数、DOM 节点等特殊类型除外)。
浅拷贝的各类实现
测试用例,覆盖嵌套对象、Symbol 键、访问器、不可枚举属性,用来校验每种实现的能力边界:
let objA = {
a: 'abc',
b: '123',
c: {
d: 'ddd',
e: 'eee',
},
[Symbol()]: {
f: 'fff',
g: 'ggg'
},
get m () { return 1 }
}
Object.defineProperty(objA, 'h', {
value: 4,
enumerable: false
})
for...in 循环实现
function shallowCopy1 (obj) {
if (typeof obj !== 'object') {
return
}
let newObj = obj.constructor === Array ? [] : {}
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
newObj[key] = obj[key]
}
}
return newObj
}
shallowCopy1(objA)
// 结果:a、b、c、m
问题点:
typeof null === 'object',传入null会读取null.constructor,直接抛出报错;- 函数的
typeof返回'function',会在入口被直接return;数组优先推荐使用Array.isArray判断; - 无法拷贝
Symbol属性、不可枚举属性; - 遇到访问器属性,会执行
getter获取返回值,拷贝得到普通值,丢失访问器本身。
| 方法 | 自身属性 | 继承属性 | 可枚举 | 不可枚举 | Symbol 属性 |
|---|---|---|---|---|---|
| for...in | ✓ | ✓ | ✓ | X | X |
| Object.keys() | ✓ | X | ✓ | X | X |
| Object.getOwnPropertyNames() | ✓ | X | ✓ | ✓ | X |
| Object.getOwnPropertySymbols() | ✓ | X | ✓ | ✓ | ✓ |
| Reflect.ownKeys() | ✓ | X | ✓ | ✓ | ✓ |
for...in:遍历自身和继承的可枚举属性,排除Symbol;Object.keys():仅自身可枚举字符串键,排除Symbol;Object.getOwnPropertyNames():自身全部字符串键,包含不可枚举,排除Symbol;Object.getOwnPropertySymbols():自身全部Symbol键,无视可枚举状态;Reflect.ownKeys():对象自身全部键,字符串、Symbol、可枚举、不可枚举全部获取。
改用 Reflect.ownKeys 遍历全部自身键:
function shallowCopy2 (obj) {
if (typeof obj !== 'object') {
return
}
let newObj = obj.constructor === Array ? [] : {}
Reflect.ownKeys(obj).forEach(key => {
newObj[key] = obj[key]
})
return newObj
}
shallowCopy2(objA)
// 结果:a、b、c、h、m、Symbol()
Reflect.ownKeys 返回的本身就只有自身属性,hasOwnProperty 判断可以省略。
该版本可以拷贝 Symbol、不可枚举属性,但属性描述符依旧丢失:访问器会执行 getter 拿到返回值;不可枚举属性复制到新对象后,变成可写、可枚举、可配置。
Object.create 与属性描述符完整拷贝
借助 Object.getPrototypeOf 获取原型,Object.getOwnPropertyDescriptors 获取全部属性描述符,Object.create 创建对象。
function shallowCopy3 (obj) {
return Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj)
)
}
shallowCopy3(objA)
该方案完整保留原型、Symbol 属性、不可枚举属性、访问器描述符。
注意:数组经过该方式创建,Array.isArray() 返回 false,Object.prototype.toString.call 返回 [object Object],Vue 2 的类型校验会出现异常。嵌套对象依旧保留引用共享,属于浅拷贝。
Object.assign 与对象扩展运算符
Object.assign()、对象扩展运算符 { ...obj } 行为保持一致:只会复制源对象自身可枚举属性,支持可枚举 Symbol,忽略继承属性、不可枚举属性;访问器会执行 getter,拷贝得到计算后的普通值。
Object.assign({}, objA)
{ ...objA }
输出会丢失不可枚举属性 h,访问器 m 被解析为数字 1。
数组的浅拷贝
数组常用浅拷贝方式,仅复制第一层,数组内部对象元素依旧共享引用:
let arrA = [1, 2, 3]
// concat
let arrB = arrA.concat()
// slice
let arrB = arrA.slice()
// 扩展运算符
let arrB = [...arrA]
// Array.from
let arrB = Array.from(arrA)
深拷贝实现方案
测试用例,覆盖绝大多数 JavaScript 数据类型、Symbol、不可枚举属性、访问器:
let objB = {
num: 0,
str: '',
boolean: true,
unf: undefined,
nul: null,
obj: {
name: '我是一个对象',
id: 1
},
arr: [0, 1, 2],
func: function() {
console.log('我是一个函数')
},
date: new Date(),
reg: new RegExp('我是一个正则', 'ig'),
err: new Error('我是一个错误'),
[Symbol()]: {
f: 'fff',
g: 'ggg'
}
}
Object.defineProperties(objB, {
h: {
value: 4,
enumerable: false,
writable: false,
configurable: false
},
m: {
get() {
console.log('调用了get')
return [1,2,3]
},
set(val) {
console.log('调用了set')
}
}
})
JSON.parse(JSON.stringify())
原理:对象序列化 JSON 字符串,再反向解析为 JavaScript 对象,嵌套结构全部重建。
仅支持 JSON 标准可序列化的数据:普通对象、数组、字符串、有限数字、布尔值、null。
存在大量缺陷:
- 对象属性为
undefined、function、Symbol,直接丢失;数组内部出现以上类型转为null; NaN、Infinity转为null;BigInt直接抛出异常;Date对象调用自身toJSON,输出 ISO 字符串,解析完得到字符串而非Date实例;RegExp、Error序列化后变成空对象{};Map、Set会变成{};- 不可枚举属性全部丢失;
- 对象循环引用直接抛出报错;
- 同源多引用对象,拷贝后拆分成多份独立对象。
适用场景:只拷贝接口返回纯净 JSON 数据,业务简单场景快速使用。
structuredClone() 结构化克隆
浏览器、Node.js 17 起原生提供 structuredClone(),使用结构化克隆算法。
const cloned = structuredClone(obj)
支持类型:Date、RegExp、Error、Map、Set、ArrayBuffer、TypedArray 视图、Blob、File,原生支持循环引用,同源多引用对象拷贝后维持引用关系。
边界限制:
- 函数、DOM 节点、
Window、WeakMap、WeakSet直接抛出DataCloneError; Symbol作为属性值会抛错;Symbol键会被丢弃;- 原型链自定义方法丢失,拷贝出来是普通对象;
- 访问器、属性描述符不保留,读取属性实际值再克隆。
手写递归迭代基础版本
原理:基础类型直接返回;引用类型递归复制每一层属性。
function deepCopy1 (obj) {
if (!obj || typeof obj !== 'object') {
return obj
}
let newObj = obj.constructor === Array ? [] : {}
Reflect.ownKeys(obj).forEach(key => {
newObj[key] = typeof obj[key] === 'object' ? deepCopy1(obj[key]) : obj[key]
})
return newObj
}
缺陷:
Date、RegExp、Error实例无法正确还原;- 属性描述符、访问器丢失;
- 对象循环引用直接递归栈溢出;
- 同一个对象多处引用,拷贝后变成多份独立对象。
函数不会进入递归分支,直接复用原始函数引用。
完善特殊类型拷贝
针对 Date、RegExp 做构造器实例重建。
注意:使用描述符创建对象后,直接赋值会触发 getter,覆盖属性描述符。想要完整保留访问器,需要递归处理描述符内部的 value,而不是直接对键赋值。完整逻辑参考 lodash 的 baseClone 内部源码。
function deepCopy2 (obj) {
if (!obj || typeof obj !== 'object') {
return obj
}
let newObj
let Constructor = obj.constructor
switch(Constructor){
case RegExp:
newObj = new Constructor(obj)
break
case Date:
newObj = new Constructor(obj.getTime())
break
default:
newObj = Object.create(
Object.getPrototypeOf(obj),
Object.getOwnPropertyDescriptors(obj)
)
break
}
Reflect.ownKeys(obj).forEach(key => {
newObj[key] = typeof obj[key] === 'object' ? deepCopy2(obj[key]) : obj[key]
})
return newObj
}
解决循环引用与重复引用
对象循环引用会无限递归,触发栈溢出。解决方案:使用 WeakMap 缓存已经拷贝完成的对象,每次拷贝优先查询缓存,已经拷贝过直接返回缓存副本。该方案同时可以解决同一对象多处被引用,拷贝后分裂成多个实例的问题。
function deepCopy3 (obj, hash = new WeakMap()) {
if (!obj || typeof obj !== 'object') {
return obj
}
if (hash.has(obj)) {
return hash.get(obj)
}
let newObj
let Constructor = obj.constructor
switch(Constructor){
case RegExp:
newObj = new Constructor(obj)
break
case Date:
newObj = new Constructor(obj.getTime())
break
default:
newObj = new Constructor()
newObj = Object.create(
Object.getPrototypeOf(newObj),
Object.getOwnPropertyDescriptors(obj)
)
break
}
hash.set(obj, newObj)
Reflect.ownKeys(obj).forEach(key => {
newObj[key] = typeof obj[key] === 'object' ? deepCopy3(obj[key], hash) : obj[key]
})
return newObj
}
与 deepCopy2 一样,后面的赋值仍会触发 getter、冲掉描述符。
解决递归深度过大栈溢出
递归拷贝对象层级过深,会触发调用栈溢出。广度不会影响栈。 递归无法使用尾递归优化,可以改用迭代配合栈容器手动模拟递归流程,消除函数调用栈。
function deepCopy5(obj, root = {}) {
const stackList = [
{
parent: root,
key: null,
data: obj,
}
]
while(stackList.length) {
const node = stackList.pop()
const parent = node.parent
const key = node.key
const data = node.data
let res = parent
if (key !== null) {
res = parent[key] = {}
}
for(let k in data) {
if (data.hasOwnProperty(k)) {
if (typeof data[k] === 'object') {
stackList.push({
parent: res,
key: k,
data: data[k],
})
} else {
res[k] = data[k]
}
}
}
}
return root
}
该示例仅演示消除递归栈思路,没有处理特殊对象、循环引用、null。业务中超深嵌套对象很少见。
Vue 2 适配版深拷贝
Vue 2 响应式数据会挂载不可枚举的 __ob__(Observer 实例)。如果直接拷贝属性描述符,会把响应式观察者一并复制,副本依旧和源对象绑定。同时 Object.create 生成的伪数组,Array.isArray 返回 false,触发 props 类型校验失败。Vue 3 改用 Proxy,没有这一套 __ob__。
放弃拷贝不可枚举属性、属性描述符:
function deepCopy (obj, hash = new WeakMap()) {
if (!obj || typeof obj !== 'object') {
return obj
}
if (hash.has(obj)) {
return hash.get(obj)
}
let newObj
let Constructor = obj.constructor
switch(Constructor){
case RegExp:
newObj = new Constructor(obj)
break
case Date:
newObj = new Constructor(obj.getTime())
break
default:
newObj = new Constructor()
break
}
hash.set(obj, newObj)
for (let key in obj) {
if (obj.hasOwnProperty(key)) {
newObj[key] = typeof obj[key] === 'object' ? deepCopy(obj[key], hash) : obj[key]
}
}
return newObj
}
总结
核心本质:赋值、浅拷贝、深拷贝的区别,就是新对象的创建层级,以及引用断开到哪一层。
开发时需要清楚每种拷贝方法会丢失哪些特性,根据业务数据结构选择最合适的实现。
- 普通可结构化数据(不含函数、访问器):优先
structuredClone(); - 需要拷贝函数、复杂自定义实例、完整兼容性:使用 lodash 的
_.cloneDeep; - Vue 2 项目:使用上面适配版本,规避响应式
__ob__与数组类型校验问题; - 仅接口纯净 JSON 对象:可以简单使用
JSON.parse(JSON.stringify()),清楚它的全部缺陷。