同样一句赋值,改一个对象另一个跟着变、另一个却纹丝不动,差别就在拷贝方式。浅拷贝只新建外层容器,内层元素沿用原来的引用;深拷贝把每一层引用类型都重新分配内存,副本与原对象在堆上彻底分开。分辨两者的方法很简单:改副本内部的嵌套对象,看原对象会不会跟着变。
线上真出过这类事故。一个配置对象从缓存里取出来,用展开运算符复制了一份给某个租户单独改参数,改的是副本的嵌套字段,结果所有租户下个周期读到的阈值全变了。排查花了大半天,根因就一句:外层复制了,内层还在共享同一块堆内存。从那以后,凡是”复制一份再改”的地方,都会先问一句这个结构有几层。
三者对比:赋值、浅拷贝、深拷贝各自复制了什么
赋值不复制对象,浅拷贝复制第一层,深拷贝递归复制所有层级,这是三种操作的本质区别。把三者放在一起看,行为差异一目了然。
| 维度 | 直接赋值 | 浅拷贝 | 深拷贝 | 结论 |
|---|---|---|---|---|
| 是否创建新对象 | 否 | 是,仅外层 | 是,含所有子对象 | 只有后两者算拷贝 |
| 内层引用类型处理 | 共享同一引用 | 共享同一引用 | 重新分配内存 | 决定会不会互相污染 |
| 改外层基本类型 | 双方都变 | 只有副本变 | 只有副本变 | 浅拷贝已能隔离外层 |
| 改内层嵌套对象 | 双方都变 | 双方都变 | 只有副本变 | 深拷贝才能隔离内层 |
| 内存开销 | 无 | 小 | 与嵌套规模同增 | 隔离性用内存换 |
| 典型写法 | b = a |
{...a}、copy.copy |
structuredClone、deepcopy |
按深度需求选 |
表里有一行容易被忽略:浅拷贝在”改外层基本类型”这一行已经能隔离。也就是说,对象只有一层、字段全是字符串和数字时,浅拷贝和深拷贝的效果没有区别,此时用深拷贝只是白花了开销。
一个常见误解是认为浅拷贝”复制得少所以不安全”。准确的说法是:浅拷贝的语义就是只复制一层,共享内层是设计如此,不是缺陷。真正的问题是使用者以为复制了两层。
内存结构对比:栈里的地址和堆里的对象
基本类型的值直接存在栈上,引用类型在栈上只存一个地址、实际数据放在堆里,这就是深浅差异的物理来源。理解了这两层存储,所有”改了副本原对象也变”的现象都能自己推出来。
| 内存位置 | 直接赋值 | 浅拷贝 | 深拷贝 | 说明 |
|---|---|---|---|---|
| 栈上的变量 | 两个变量存同一个地址 | 两个变量存不同地址 | 两个变量存不同地址 | 外层已经分开 |
| 堆上的外层对象 | 只有一份 | 两份 | 两份 | 浅拷贝在此分叉 |
| 堆上的嵌套对象 | 只有一份 | 还是一份 | 两份 | 污染的来源 |
| 内层对象的引用计数 | 不变 | 增加 | 各自一份 | 决定内存回收时机 |
基本类型(字符串、数字、布尔、null、undefined 等)赋值时传的是值本身,改一个不会影响另一个。引用类型(对象、数组、函数、日期、正则)赋值时传的是引用地址,两个变量指向同一块堆内存,改内容等于改同一份数据。所谓”引用传递”,讲的正是后者。
这也解释了为什么浅拷贝后的修改结果会分两种。改外层字段属于”替换引用”,副本的栈地址本来就和原对象不同,互不影响;改内层嵌套对象属于”原地修改”,两边指向同一块堆内存,于是同时变化。列表的 push、append,字典嵌套赋值,都是原地修改这一类。
实现方式对比:六种写法的能力边界
六种常见写法里,能力差别集中在特殊类型与循环引用上,没有一种能覆盖全部场景。选之前先看数据结构里有哪些类型,再看是否需要处理自引用。
| 实现方式 | 复制深度 | 日期与 Map/Set | 循环引用 | 函数与原型链 | 适用判断 |
|---|---|---|---|---|---|
展开运算符 / Object.assign |
仅一层 | 不适用 | 不涉及 | 只复制可枚举自有属性 | 结构扁平时的默认选择 |
slice / concat / Array.from |
仅一层 | 不适用 | 不涉及 | 同上 | 数组场景 |
JSON.parse(JSON.stringify()) |
全部层级 | 日期变字符串,Map/Set 退化 | 直接抛错 | 全部丢失 | 纯 JSON 数据才用 |
structuredClone |
全部层级 | 支持日期、Map、Set、RegExp、Error | 支持 | 函数、DOM 节点不可克隆 | 现代环境下的通用选择 |
| 递归 + WeakMap 自研 | 全部层级 | 需自行分支处理 | 靠 WeakMap 缓存解决 | 可自行控制 | 需要定制克隆规则时 |
| 成熟库的 cloneDeep | 全部层级 | 覆盖较全 | 支持 | 支持函数 | 遗留环境或复杂类型 |
JSON 往返的失真清单
JSON 往返那条要特别小心,它的失真清单比多数人以为的长。undefined、函数、Symbol 会被直接丢掉,Date 会变成字符串,NaN 与 Infinity 会变成 null,正则变成空对象,遇到循环引用会抛异常。只要数据里出现过这些类型之一,就不能用这条路径。
structuredClone 的能力边界
structuredClone 支持循环引用,遇到对象引用自身的结构不会死循环,这是它相对 JSON 往返的直接优势。它同样有边界:函数、DOM 节点、属性描述符与原型链都不在克隆范围内,跨环境调用时还会受宿主实现限制。
下面这段对照代码可以直接跑,输出结果说明浅拷贝的副本改内层会穿透到原对象。
import copy
origin = {"limit": 100, "rule": {"threshold": 80}}
shallow = copy.copy(origin) # 只复制外层字典
deep = copy.deepcopy(origin) # 递归复制每一层
shallow["rule"]["threshold"] = 60
print(origin["rule"]["threshold"]) # 60,被穿透
deep["rule"]["threshold"] = 20
print(origin["rule"]["threshold"]) # 60,深拷贝互不影响
Python 的 deepcopy 内部维护了一份已复制对象的记录,用来处理自引用结构,这也是它比手写递归更稳的原因。自研版本要复刻这个行为,用 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);
const out = Array.isArray(value) ? [] : {};
seen.set(value, out); // 先登记再递归
for (const key of Reflect.ownKeys(value)) {
out[key] = deepClone(value[key], seen);
}
return out;
}
这段代码的关键顺序是”先登记再递归”,把新对象写进缓存之后再进入子层,环就断开了。反之,先递归后登记,遇到自引用会栈溢出。需要注意的是,手写实现很难覆盖全部内置类型,生产代码优先用宿主提供的克隆能力或成熟库。
性能与内存开销对比
浅拷贝的成本基本只在外层容器的分配上,深拷贝的成本随节点数量、嵌套深度与类型复杂度线性增长。数据规模越大,这个差距越明显。
| 开销项 | 浅拷贝 | 深拷贝 | 影响 |
|---|---|---|---|
| 分配次数 | 1 次容器分配 | 每个节点各分配一次 | 深拷贝的分配量随规模上升 |
| 遍历范围 | 只遍历第一层 | 遍历整棵可达对象图 | 大对象差距放大 |
| 内存峰值 | 与原对象基本持平 | 可能接近原对象的两倍 | 内存紧张时需评估 |
| 触发时机 | 高频更新也不明显 | 每次输入、每次渲染都可能卡顿 | 别放在高频路径上 |
在交互频繁的路径上做全量深拷贝,代价会被放大很多倍。表单每敲一个字符就深拷贝整个状态树,或者大列表每次筛选都全量克隆,都会让主线程停顿变得可感知。更省的做法是只复制要改的那条路径,把没动的分支继续共享。
结构共享:隔离性不一定要全量复制
这就是结构共享的思路:改动一层就复制那一层,其余引用原样复用,既有隔离性又不付出全量复制的成本。前端状态管理里的不可变更新写法就是这个套路,数组加一项写成 [...items, item],改嵌套字段则逐层展开,只复制路径上的节点。
选型清单:按数据结构挑拷贝方式
选择顺序按层数、特殊类型、调用频率依次判断,按这三步走基本不会选错。下面这组判断可以直接照做。
- 先数结构有几层,只有一层且字段是基本类型时用浅拷贝。
- 再查是否含函数、类实例、DOM 节点,含这些就排除 JSON 往返与 structuredClone。
- 接着查是否存在自引用结构,存在时选带缓存机制的实现。
- 核对调用频率,高频路径只复制改动路径,不做全量深拷贝。
- 数据结构是纯 JSON 且只要一份独立快照时,JSON 往返可以接受。
对应的场景取舍很清楚:配置快照、数据回滚点、要传给可能修改入参的第三方函数,这类要求完全隔离的地方用深拷贝;只读共享、扁平结构、只需要新容器承载不同顺序的地方,浅拷贝足够且更快。
还有一类对象不该深拷贝:文件句柄、网络套接字、线程对象、数据库连接。它们代表的是外部资源,复制出来的副本没有意义,部分实现会直接报错。这类对象要么按引用传递,要么单独设计资源管理方式。
常见问题(FAQ)
Q1:深拷贝为什么比浅拷贝慢?
深拷贝要遍历整棵对象图,为每个节点分配新内存;浅拷贝只分配一层容器。
Q2:什么时候必须用深拷贝?
需要完全隔离时,比如配置快照、回滚点,或把数据传给可能改入参的外部函数。
Q3:深拷贝会不会影响性能?
放在每次输入或每次渲染的高频路径上会明显卡顿,应改成只复制改动路径。