React中的浅层和深层复制

React(以及广义上的JavaScript)中的“浅层复制”(Shallow Copy)和“深层复制”(Deep Copy)。

理解这两个概念对于在React中正确地管理状态(State)至关重要,因为它们直接关系到React的更新机制和性能优化。

核心概念:基本类型 vs. 引用类型

在深入讲解复制之前,必须先理解JavaScript中的两种数据类型:

  1. 基本类型 (Primitive Types): string, number, boolean, null, undefined, symbol, bigint

    • 这些值直接存储在变量访问的位置(栈内存)。
    • 当你把一个基本类型的值赋给另一个变量时,你是在复制这个值本身
    let a = 10;
    let b = a; // b 得到的是 10 这个值的副本
    b = 20;    // 修改 b 不会影响 a
    console.log(a); // 输出 10
    
  2. 引用类型 (Reference Types): Object, Array, Function

    • 这些值(对象本身)存储在堆内存中,而变量访问的位置(栈内存)只存储一个指向该对象的内存地址(引用)
    • 当你把一个引用类型的值赋给另一个变量时,你只是在复制这个内存地址,而不是对象本身。两个变量指向的是同一个对象。
    let objA = { name: 'Alice' };
    let objB = objA; // objB 得到的是指向 { name: 'Alice' } 这个对象的内存地址的副本
    objB.name = 'Bob'; // 通过 objB 修改对象
    console.log(objA.name); // 输出 'Bob',因为 objA 和 objB 指向同一个对象
    

浅层复制和深层复制主要就是针对“引用类型”的复制问题。

一、 浅层复制 (Shallow Copy)

1. 什么是浅层复制?

浅层复制会创建一个新的对象或数组,但它只复制第一层的属性。

  • 如果第一层的属性是基本类型,它会复制该值的副本。
  • 如果第一层的属性是引用类型(比如一个嵌套的对象或数组),它只会复制该引用类型的内存地址

通俗比喻

想象你有一本地址簿(原始对象),里面记录了朋友们的姓名和电话号码(基本类型),还记录了另一个地址簿的存放位置(引用类型)。

  • 浅层复制就像是你买了一本新的地址簿,然后把原地址簿里的姓名和电话号码抄了一遍。但是,对于“另一个地址簿的存放位置”,你只是把那个位置信息抄了过来,并没有去复印那个地址簿本身。
  • 结果就是,你有了两本独立的顶级地址簿,但它们都指向同一个“另一个地址簿”。如果你修改了那个共享的地址簿,两本顶级地址簿看到的内容都会改变。

2. 如何在JavaScript/React中实现浅层复制?

在现代React中,最常用的方法是展开语法 (Spread Syntax ...) 和一些数组方法。

展开运算符:React中的展开运算符 - 编程技术 - CEPD@BBS

  • 对于对象:

    const originalObject = {
      name: 'Admin',
      level: 99,
      details: {
        role: 'Superuser',
        permissions: ['read', 'write', 'delete']
      }
    };
    
    // 使用展开语法进行浅层复制
    const shallowCopy = { ...originalObject };
    
    // 验证
    console.log(shallowCopy === originalObject); // false,因为 shallowCopy 是一个新的对象
    
    // 修改第一层的基本类型
    shallowCopy.level = 100;
    console.log(originalObject.level); // 99 (原始对象未受影响)
    
    // 修改嵌套对象的属性
    shallowCopy.details.role = 'Guest';
    console.log(originalObject.details.role); // 'Guest' (原始对象被影响了!)
    // 因为 shallowCopy.details 和 originalObject.details 指向同一个对象
    
  • 对于数组:

    const originalArray = [1, 2, { id: 3 }];
    
    // 使用展开语法
    const shallowCopy1 = [...originalArray];
    // 使用 slice() 方法
    const shallowCopy2 = originalArray.slice();
    // 使用 Array.from()
    const shallowCopy3 = Array.from(originalArray);
    
    // 修改嵌套对象的属性
    shallowCopy1[2].id = 99;
    console.log(originalArray[2].id); // 99 (原始对象被影响了!)
    

3. 为什么在React中如此重要?

React的核心原则之一是状态不可变性 (Immutability)。当组件的状态(state)更新时,React会通过比较新旧状态的引用来决定是否需要重新渲染。

  • 错误的做法 (直接修改状态):

    const [user, setUser] = useState({ name: 'Alice', age: 25 });
    
    function handleAgeIncrease() {
      // 错误!直接修改了原始状态对象
      user.age = 26;
      setUser(user); // user 的内存地址没有改变,React 认为状态没有变化,不会重新渲染!
    }
    
  • 正确的做法 (使用浅层复制):

    const [user, setUser] = useState({ name: 'Alice', age: 25 });
    
    function handleAgeIncrease() {
      // 正确!创建了一个新的对象
      const newUser = { ...user, age: 26 };
      setUser(newUser); // newUser 是一个新对象,有新的内存地址,React 检测到引用变化,触发重新渲染
    }
    

对于大部分React状态更新场景,浅层复制已经足够了,因为它能确保顶层状态对象的引用发生变化,从而触发React的更新。

二、 深层复制 (Deep Copy)

1. 什么是深层复制?

深层复制会创建一个完全独立的副本。它会递归地复制原始对象或数组中的所有层级,包括所有嵌套的对象和数组。

  • 复制后的对象与原始对象没有任何共享的引用。修改新对象中的任何内容(无论多深),都不会影响到原始对象。

通俗比喻

回到地址簿的例子。

  • 深层复制就像是不仅买了一本新地址簿,抄写了姓名电话,而且当你遇到“另一个地址簿的存放位置”时,你会找到那个地址簿,把它也完完整整地复印一本,然后把这本全新复印本的位置记录在新地址簿里。这个过程会一直持续下去,直到所有嵌套的东西都被复制为全新的副本。
  • 结果是,你得到了两套完全独立的地址簿系统,互不干扰。

2. 如何在JavaScript/React中实现深层复制?

实现深层复制比浅层复制要复杂。

  • 方法一:JSON.parse(JSON.stringify(obj)) (简单但有缺陷)

    这是最简单快捷的方法,但有几个重要的缺点:

    • 会丢失 undefinedfunctionsymbol 类型的值。
    • 会把 Date 对象转换为字符串。
    • 不能处理循环引用(对象属性直接或间接引用自身),会报错。
    const originalObject = {
      name: 'Admin',
      joined: new Date(),
      details: { role: 'Superuser' },
      logout: () => console.log('logout')
    };
    
    const deepCopy = JSON.parse(JSON.stringify(originalObject));
    
    // 修改嵌套对象的属性
    deepCopy.details.role = 'Guest';
    console.log(originalObject.details.role); // 'Superuser' (原始对象未受影响,成功!)
    
    // 检查缺陷
    console.log(originalObject.joined); // Date 对象
    console.log(deepCopy.joined);     // 变成了一个字符串
    console.log(deepCopy.logout);     // undefined (函数丢失了)
    
  • 方法二:使用第三方库 (推荐)

    在生产环境中,最可靠的方法是使用成熟的库,例如 LodashcloneDeep 方法。

    import { cloneDeep } from 'lodash';
    
    const originalObject = { /* ... */ };
    const deepCopy = cloneDeep(originalObject); // 完美处理各种情况
    
  • 方法三:手动实现递归函数

    你可以自己编写一个递归函数来遍历对象的所有属性,并为每个属性创建副本。这很复杂,容易出错,通常不推荐自己写。

3. 什么时候在React中使用深层复制?

当你有一个深度嵌套的状态,并且你需要修改深层的某个属性时,就需要深层复制。

场景示例:

假设你有一个复杂的表单状态,结构如下:

const [formState, setFormState] = useState({
  user: {
    name: 'Alice',
    address: {
      city: 'New York',
      zip: '10001'
    }
  },
  items: [
    { id: 1, name: 'Laptop' }
  ]
});

如果你想修改 city,只用浅层复制是不够的:

function updateCity(newCity) {
  // 仅浅层复制第一层
  const newFormState = { ...formState };

  // newFormState.user 和 formState.user 仍然是同一个对象!
  newFormState.user.address.city = newCity; // 这样仍然修改了原始状态!这是错误的!

  setFormState(newFormState);
}

正确的做法(不使用深拷贝库,手动逐层复制):

function updateCity(newCity) {
  setFormState(prevState => ({
    ...prevState, // 1. 复制顶层
    user: {
      ...prevState.user, // 2. 复制 user 层
      address: {
        ...prevState.user.address, // 3. 复制 address 层
        city: newCity // 4. 更新 city
      }
    }
  }));
}

这种手动逐层展开的方式是React中处理嵌套状态更新的常见模式,它避免了引入整个深拷贝库的开销。

如果状态结构非常复杂,或者更新逻辑繁琐,使用深拷贝库(如Lodash)或状态管理库(如Immer)会更方便。

总结与对比

特性 浅层复制 (Shallow Copy) 深层复制 (Deep Copy)
定义 只复制对象/数组的第一层。嵌套的引用类型只复制其内存地址。 递归复制所有层级,创建一个完全独立的副本。
性能 速度快,开销小。 速度慢,开销大,因为它需要遍历整个数据结构。
实现方式 ... (展开语法), Object.assign(), Array.slice() JSON.parse(JSON.stringify()) (有缺陷), lodash.cloneDeep() (推荐)
共享引用 内部的引用类型(嵌套对象/数组)是共享的。 没有任何共享引用。
React使用场景 绝大多数情况。用于更新状态对象或数组的顶层属性,以触发重新渲染。 少数情况。当状态结构非常深,且需要修改深层嵌套的属性时使用。
黄金法则 默认使用浅层复制。当且仅当浅层复制导致你意外地修改了原始状态时,才考虑深层复制。 /