JavaScript 深浅拷贝:JSON 陷阱、循环引用与四种正确姿势
# JavaScript 深浅拷贝:JSON 陷阱、循环引用与四种正确姿势
> 摘要:从赋值、浅拷、深拷三者的内存关系图入手,分析 JSON.parse(JSON.stringify()) 流行的原因与四类数据丢失场景,给出基础版、循环引用版、WeakMap 缓存版手写实现,并与 structuredClone、lodash 做横向对比。
> 标签:javascript、前端
## 一、赋值、浅拷、深拷的区别
三个操作在内存里的关系完全不同:
```js
const origin = { a: 1, nested: { b: 2 } };
const assign = origin; // 同一个对象,改 assign.nested.b 会影响 origin
const shallow = { ...origin }; // 第一层新对象,nested 仍指向原对象
const deep = JSON.parse(JSON.stringify(origin)); // 完全独立
```
判断标准只有一条:**修改拷贝结果后,原对象会不会变**。会变的是赋值或浅拷,不会变的是深拷。日常出 bug 最多的地方就是"以为自己是深拷,其实只拷了第一层"——数组里套对象、对象里套数组,然后 `push` 或改字段改到了原数据。
## 二、浅拷贝的四种方式
1. **展开运算符**:`const copy = { ...obj }`(数组用 `[...arr]`),最常用
2. **Object.assign**:`Object.assign({}, obj)`,会把原型链上的可枚举属性也拷过来
3. **slice**:`arr.slice()` 或 `arr.concat()`,仅数组
4. **Object.create + 遍历**:少见,用于带原型的场景
注意浅拷贝的共同短板:`nested` 这一层还是共享引用。
## 三、JSON.parse(JSON.stringify()) 为什么是个坑
这大概是流传最广的"深拷贝一行流",但它会静默丢数据,四类对象全军覆没:
1. **函数和 undefined**:遇到直接跳过,属性凭空消失
2. **Symbol 作为 key**:同样被忽略
3. **Date**:变成 ISO 字符串,`.getTime()` 直接报错
4. **RegExp、Map、Set**:分别变成 `{}`、`{}`、`{}`
另外遇到**循环引用**会直接抛 `TypeError: Converting circular structure to JSON`。所以它只适合"纯 JSON 数据"(后端刚返回的、确定没有上述类型)的快速深拷,生产代码不推荐。
## 四、手写深拷贝
### 基础版(递归)
```js
function deepClone(target) {
if (typeof target !== 'object' || target === null) return target;
if (Array.isArray(target)) {
return target.map((item) => deepClone(item));
}
const result = {};
for (const key in target) {
if (target.hasOwnProperty(key)) {
result[key] = deepClone(target[key]);
}
}
return result;
}
```
### 循环引用版(WeakMap 缓存)
```js
function deepClone(target, cache = new WeakMap()) {
if (typeof target !== 'object' || target === null) return target;
// 已经拷贝过的对象直接返回缓存,打破循环
if (cache.has(target)) return cache.get(target);
const result = Array.isArray(target) ? [] : {};
cache.set(target, result);
for (const key in target) {
if (target.hasOwnProperty(key)) {
result[key] = deepClone(target[key], cache);
}
}
return result;
}
```
WeakMap 是关键:既处理了循环引用,又不会内存泄漏(键是弱引用)。用 `Reflect.ownKeys` 代替 `for...in` 还能带上 Symbol 属性。
## 五、structuredClone 与 lodash
浏览器原生方案(Chrome 98+、Node 17+):
```js
const copy = structuredClone(origin);
```
支持 Date、RegExp、Map、Set、Blob、ArrayBuffer,也处理循环引用,性能是手写递归的数倍。不支持的是函数和 DOM 节点。**能用原生就用原生**,手写版留作兼容降级或面试。
lodash 的 `cloneDeep` 是老项目常见选择,兼容性最好,代价是打包体积。
## 六、方案对比
| 方案 | 循环引用 | Date/Map/Set | 函数/Symbol | 兼容性 |
| --- | --- | --- | --- | --- |
| JSON 双步 | 报错 | 丢失 | 丢失 | 全兼容 |
| 手写基础版 | 死循环 | 丢失 | 丢失 | 全兼容 |
| 手写 WeakMap 版 | 支持 | 丢失 | 视实现 | 全兼容 |
| structuredClone | 支持 | 保留 | 丢失 | 现代浏览器 |
| lodash cloneDeep | 支持 | 保留 | 保留 | 全兼容 |
## 七、三个实践建议
1. **能不可变更新就别深拷**:React/Vue 里改嵌套对象,优先用展开逐层更新(`{ ...state, user: { ...state.user, name: 'x' } }`),比深拷更高效也更安全。
2. **框架有自带方案就用**:Vue 的 `reactive`、Immer 的 `produce`,从根上避免手动拷贝。
3. **大数据量慎用递归**:十万节点以上的深对象,递归有栈溢出风险,优先 structuredClone 或考虑结构化设计拍平数据。
## 八、总结
JSON 深拷能用但有四个明确的坑;日常首选 structuredClone,需要兼容老环境时用 WeakMap 缓存版手写;更重要的是建立"赋值≠浅拷≠深拷"的条件反射,先判断数据结构和修改深度,再选方案。
- 点赞
- 收藏
- 关注作者
评论(0)