JavaScript 深拷贝与浅拷贝
JavaScript 深拷贝与浅拷贝
Introduction(引言)
在 JavaScript 中,数据分为基本类型(Number、String、Boolean、null、undefined、Symbol、BigInt)和引用类型(Object、Array、Function、Date、RegExp、Map、Set 等)。基本类型的赋值和比较是值传递,天然不存在拷贝问题;而引用类型赋值给另一个变量时,赋的是引用地址而非实际数据,这导致多个变量可能指向同一个对象,一个变量修改数据会影响其他变量——这就是"共享引用"问题。
深拷贝和浅拷贝就是为了解决这个问题而生的。浅拷贝创建一个新对象,但只复制第一层的键值;如果属性值是引用类型,则复制的是地址,导致新旧对象共享底层数据。深拷贝则递归地复制所有层级,无论嵌套多深,新旧对象完全独立。理解二者的区别、应用场景、以及各自的实现方式,是每个 JavaScript 开发者必须掌握的基本功。
一、基础概念
1. 什么是浅拷贝?
浅拷贝(Shallow Copy)只复制对象的第一层结构。对于基本类型的属性,复制的是值;对于引用类型的属性,复制的是引用地址。因此,如果原对象中有一个引用类型的嵌套对象,改变新对象中嵌套对象的属性,会同时影响到原对象。
2. 什么是深拷贝?
深拷贝(Deep Copy)递归地复制对象的所有层级,包括嵌套的引用类型。新旧对象在内存中完全独立,修改任意一方的任意层级都不会影响另一方。
3. 共享引用 vs 完全独立
// 共享引用(直接赋值)
const a = { name: '小明', hobby: { sports: '篮球' } };
const b = a;
b.name = '小红';
b.hobby.sports = '足球';
console.log(a.name); // '小红'(被修改)
console.log(a.hobby.sports); // '足球'(被修改)
// 浅拷贝
const c = { name: '小明', hobby: { sports: '篮球' } };
const d = Object.assign({}, c);
d.name = '小红'; // c 不受影响
d.hobby.sports = '足球'; // c 也被改了(共享引用)
console.log(c.hobby.sports); // '足球'
// 深拷贝
const e = { name: '小明', hobby: { sports: '篮球' } };
const f = JSON.parse(JSON.stringify(e));
f.name = '小红';
f.hobby.sports = '足球';
console.log(e.hobby.sports); // '篮球'(完全独立)
二、浅拷贝的方法
1. Object.assign(目标对象, 源对象)
const original = { a: 1, b: { c: 2 } };
const shallow = Object.assign({}, original);
shallow.b.c = 999;
console.log(original.b.c); // 999(被影响)
特点:ES6 标准方法,语法简洁,兼容性良好(IE9+)。目标对象为空时可以实现"浅拷贝",但如果有多个源对象则依次合并覆盖。
2. 扩展运算符(Spread Operator)
const original = { a: 1, b: { c: 2 } };
const shallow = { ...original };
shallow.b.c = 999;
console.log(original.b.c); // 999(被影响)
特点:ES6+ 语法,代码简洁,是目前最推荐的浅拷贝写法。数组也可以用 [...arr] 进行浅拷贝。
3. Array.prototype.slice() / Array.prototype.concat()
const arr = [1, 2, { a: 3 }];
const copy = arr.slice(); // 浅拷贝
const copy2 = [].concat(arr); // 浅拷贝
copy[2].a = 999;
console.log(arr[2].a); // 999(被影响)
特点:对于纯基本类型的数组,这两种方法可以安全地创建新数组;对于包含引用类型的数组,则只能做浅拷贝。
4. 手写一个浅拷贝函数
function shallowCopy(obj) {
if (obj === null || typeof obj !== 'object') return obj;
const result = Array.isArray(obj) ? [] : {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
result[key] = obj[key];
}
}
return result;
}
三、深拷贝的方法
1. JSON.parse(JSON.stringify())
const original = { name: '小明', age: 25, hobby: { sports: '篮球' }, skills: ['JS', 'Python'] };
const deep = JSON.parse(JSON.stringify(original));
deep.hobby.sports = '足球';
console.log(original.hobby.sports); // '篮球'(完全独立)
优点:实现极其简单,无需任何依赖。缺点:
- 无法拷贝 undefined、Symbol、函数等类型(会被忽略或转为 null)
- 无法拷贝 Date(会被转为字符串而非 Date 对象)
- 无法拷贝 RegExp、Error 等特殊对象
- 无法处理循环引用(会抛出错误)
- BigInt 类型会丢失
适用场景:普通纯数据对象(不含函数、特殊引用类型、不存在循环引用)。
2. structuredClone(浏览器原生 API)
const original = { name: '小明', date: new Date(), hobby: { sports: '篮球' } };
const deep = structuredClone(original);
deep.hobby.sports = '足球';
deep.date.setFullYear(2025);
console.log(original.hobby.sports); // '篮球'
console.log(original.date.getFullYear()); // 当前年份(不变)
优点:原生支持,弥补了 JSON 方法的多个缺陷——可以拷贝 Date、RegExp、Map、Set、TypedArray、Blob 等复杂类型,支持循环引用(不会报错)。缺点:不能拷贝函数,某些旧浏览器/Node.js 版本不支持。
3. 第三方库:Lodash 的 _.cloneDeep
import { cloneDeep } from 'lodash-es';
const original = { name: '小明', date: new Date(), fn: () => 'hello' };
const deep = cloneDeep(original);
deep.name = '小红';
deep.fn = () => 'world';
console.log(original.name); // '小明'
console.log(original.fn()); // 'hello'
优点:兼容所有类型,包括函数、循环引用、Symbol 键等,是生产环境最可靠的深拷贝方案。缺点:需要引入依赖包(约 24KB gzipped),对于简单项目可能过重。
4. 递归手写深拷贝(完整版)
function deepClone(obj, hash = new WeakMap()) {
// 处理基本类型和 null
if (obj === null || typeof obj !== 'object') return obj;
// 处理日期
if (obj instanceof Date) return new Date(obj.getTime());
// 处理正则
if (obj instanceof RegExp) return new RegExp(obj.source, obj.flags);
// 处理循环引用
if (hash.has(obj)) return hash.get(obj);
// 处理数组和普通对象
const clone = Array.isArray(obj) ? [] : {};
hash.set(obj, clone);
// 遍历所有键(包括 Symbol)
Reflect.ownKeys(obj).forEach(key => {
clone[key] = deepClone(obj[key], hash);
});
return clone;
}
// 测试
const original = {
name: '小明',
date: new Date(),
regex: /test/gi,
hobby: { sports: '篮球' },
skills: ['JS', 'Python'],
[Symbol('id')]: 123
};
const copy = deepClone(original);
copy.hobby.sports = '足球';
console.log(original.hobby.sports); // '篮球'
console.log(copy.date instanceof Date); // true
console.log(copy[Symbol('id')]); // 123
四、代码示例
示例一:电商场景——购物车商品复制
// 模拟从接口获取购物车商品列表,每个商品需要独立副本用于本地编辑
const cartFromAPI = [
{ productId: 'P001', name: 'iPhone 15', specs: { color: '蓝色', storage: '256GB' }, qty: 1 },
{ productId: 'P002', name: 'AirPods Pro', specs: { color: '白色' }, qty: 2 },
];
// 使用 spread 做浅拷贝(第一层独立,但 specs 仍是共享引用)
const cartLocal1 = cartFromAPI.map(item => ({ ...item }));
cartLocal1[0].specs.storage = '512GB'; // ⚠️ 也会影响原始数据
console.log(cartFromAPI[0].specs.storage); // '512GB'
// 正确做法:深拷贝嵌套对象
const cartLocal2 = cartFromAPI.map(item => ({
...item,
specs: { ...item.specs } // specs 这一层独立了
}));
cartLocal2[0].specs.storage = '512GB';
console.log(cartFromAPI[0].specs.storage); // '256GB'(正确)
示例二:表单数据暂存(撤销功能)
class FormManager {
constructor(initialData) {
this.current = initialData;
this.history = [JSON.parse(JSON.stringify(initialData))];
}
update(field, value) {
this.current[field] = value;
}
saveSnapshot() {
this.history.push(JSON.parse(JSON.stringify(this.current)));
}
undo() {
if (this.history.length > 1) {
this.history.pop(); // 移除当前
this.current = JSON.parse(JSON.stringify(this.history[this.history.length - 1]));
}
}
}
const form = new FormManager({ username: '小明', profile: { age: 18, city: '北京' } });
form.update('username', '小红');
form.saveSnapshot();
form.current.profile.city = '上海'; // 修改嵌套属性后保存快照
form.saveSnapshot();
console.log(form.history.length); // 3
form.undo();
console.log(form.current.profile.city); // '上海'(撤销到第二个快照)
示例三:处理循环引用
// JSON 方法会报错:Converting circular structure to JSON
const circular = { name: 'test' };
circular.self = circular;
// JSON.parse(JSON.stringify(circular)); // 抛出错误
// 递归方法处理循环引用
function deepCloneSafe(obj, hash = new WeakMap()) {
if (obj === null || typeof obj !== 'object') return obj;
if (hash.has(obj)) return hash.get(obj);
const clone = Array.isArray(obj) ? [] : {};
hash.set(obj, clone);
for (const key of Reflect.ownKeys(obj)) {
clone[key] = deepCloneSafe(obj[key], hash);
}
return clone;
}
const circular = { name: 'test' };
circular.self = circular;
const safe = deepCloneSafe(circular);
console.log(safe === circular); // false
console.log(safe.self === safe); // true(循环引用被保留)
五、运行效果
测试 JSON 方法的局限性
const obj = {
name: '小明',
age: undefined,
hobby: undefined,
sayHi: () => 'hello',
createdAt: new Date(),
bigNum: BigInt(9007199254740991),
symbolKey: Symbol('id'),
regex: /test/gi
};
const copy = JSON.parse(JSON.stringify(obj));
console.log(copy.name); // '小明'
console.log(copy.age); // undefined(被忽略)
console.log(copy.hobby); // undefined(被忽略)
console.log(copy.sayHi); // undefined(函数被忽略)
console.log(copy.createdAt); // '2026-07-26T15:00:00.000Z'(变成字符串)
console.log(copy.bigNum); // undefined(BigInt 丢失)
console.log(copy.symbolKey); // undefined(Symbol 键被忽略)
console.log(copy.regex); // undefined(RegExp 被忽略)
测试 structuredClone 的能力
const obj = {
name: '小明',
date: new Date(),
map: new Map([['a', 1], ['b', 2]]),
set: new Set([1, 2, 3]),
regex: /test/gi,
error: new Error('出错了'),
arrayBuffer: new ArrayBuffer(8)
};
const copy = structuredClone(obj);
console.log(copy.date instanceof Date); // true
console.log(copy.map.get('a')); // 1
console.log(copy.set.has(2)); // true
console.log(copy.error.message); // '出错了'
console.log(copy.arrayBuffer.byteLength); // 8
console.log(copy === obj); // false(完全独立)
六、常见问题与解决方案
Q1:为什么修改新对象的深层属性会影响原对象?
这通常是因为使用了浅拷贝(扩展运算符、Object.assign、slice 等)。这些方法只复制了引用类型的"地址指针",而非实际数据。解决方案:检查拷贝深度,对于嵌套引用类型,需要逐层展开拷贝,或者使用深拷贝方法。
Q2:JSON.parse(JSON.stringify()) 有哪些坑?
最常见的问题有三个:① 函数、undefined、Symbol 会被忽略,导致数据丢失;② Date 对象会被转为字符串而非 Date 实例;③ 循环引用会抛出错误。解决方案:在明确数据结构简单、无循环引用的场景使用;否则使用 structuredClone 或 Lodash _.cloneDeep。
Q3:如何选择浅拷贝还是深拷贝?
性能上,浅拷贝远快于深拷贝(无需递归遍历整个对象图)。选择原则:如果只需要复制第一层且确定没有共享引用问题,用浅拷贝;如果对象包含嵌套引用类型,或需要在副本上做修改而不影响原数据,用深拷贝。工程实践中,很多框架(Redux、Immer)的状态管理机制已经帮你做了深拷贝的优化,不需要手动每次都深拷贝整个大对象。
Q4:循环引用如何处理?
循环引用是指对象属性间接引用自身,例如 a.b.c = a。使用 JSON 方法会直接报错;使用简单的递归拷贝也会导致栈溢出。正确做法是使用 WeakMap(记录已拷贝的对象,引用类型作键)来检测并复用已拷贝的节点,如上方手写 deepCloneSafe 示例所示。
Q5:Vue/React 中组件状态拷贝需要注意什么?
在 Vue 和 React 中,直接修改状态对象(this.state.xxx.property = value)是错误的(违反单向数据流原则)。需要使用 setState / this.setState 或 store.dispatch 触发更新。如果需要基于现有状态生成新状态,推荐使用函数式更新:this.setState(prev => ({ ...prev, nested: { ...prev.nested, key: 'newValue' } })),或者使用 Immer 库以"草稿式"写法实现不可变更新。
七、延伸阅读
1. Immer.js —— 傻瓜式的不可变数据操作
Immer 是目前最流行的不可变数据操作库,核心思想是"草稿(Draft)"——在 produce 函数中,你可以像直接修改对象一样写代码,但 Immer 会自动追踪所有修改并生成新的不可变对象。性能优异且用法直观,适合与 React/Vue 深度集成。
import { produce } from 'immer';
const base = { user: { name: '小明', age: 18 } };
const next = produce(base, draft => {
draft.user.age = 20;
});
console.log(base.user.age); // 18
console.log(next.user.age); // 20
2. immerst - MobX 的不可变扩展
MobX-State-Tree(MST)内置了对不可变更新的支持,其 patch 机制可以在不拷贝整个对象的情况下记录变更,对于大状态树的性能优化非常有价值。
3. 拷贝与 GC(垃圾回收)的关系
深拷贝会产生大量中间对象,在垃圾回收压力较大的场景(如高频更新的大数组)可能导致性能问题。了解 V8 引擎的分代回收(Young/Old spaces)和 GC 暂停对应用响应的影响,有助于在实际项目中做出合理取舍。必要时可以使用对象池(Object Pool)或共享不可变结构(Structural Sharing)来优化。
4. 浏览器环境中 structuredClone 的限制
structuredClone 不能拷贝函数、Error 对象的部分属性(message/name 有时能保留但 stack 不行)、DOM 节点(Node),以及 Proxy 对象的部分特性。如果项目需要拷贝这些特殊对象,仍然需要手写拷贝逻辑或借助 Lodash。
5. Node.js 环境中的 deepEqual 与 equal
Node.js 内置的 assert 模块提供了 deepEqual 和 strictDeepEqual 方法,用于深度比较两个对象是否"相等"(所有基本类型值相等、所有嵌套引用指向相同结构)。在单元测试中,deepEqual 是验证深拷贝结果是否正确的标准工具。