JavaScript 深拷贝与浅拷贝

小飞兽 JavaScript 289 次阅读 2026-06-02

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 是验证深拷贝结果是否正确的标准工具。