Menu

React 中更新 state 里的数组和对象(不可变方式)

要修改 React state 中的数组或对象,就创建一个新的,再把它传给 setter。了解如何添加、删除、替换、插入和排序元素,如何更新嵌套对象,以及为什么直接修改 state 不会让界面更新。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

在 React 中,要更新 state 里的数组或对象,就创建一个新的,再把它传给 setter。永远不要原地修改现有的值:用展开运算符(...)、map、filter 或 slice 构造一个带有修改的副本,然后调用 setItems(copy)。

[...items, { ... }] 是一个多了一项的新数组,filter 则返回一个不含被删除项的新数组。两者都不会动旧数组。

为什么直接修改不会触发渲染

调用 setter 时,React 会用 Object.is 比较新值和旧值。对于数组和对象,比较的是引用,而不是内容。如果你用 push 修改了数组,又把同一个数组传回去,React 看到的是同一个引用,认为什么都没变,于是跳过渲染。

点击几次“push”。控制台显示数组在变长,但没有出现新的 render 行,文本也一直是 a。然后点击“spread”:发生了一次渲染,之前 push 进去的元素一下子全部显示出来,因为它们一直都在数组里。直接修改不会抛出错误,它只会让界面一直停留在过期的状态,直到别的东西触发渲染。在那之后再点一次“push”,这次它可能会渲染:React 并不总能提前跳过这些工作,所以直接修改有时会显示出来,有时不会。正是这种不可预测性,让我们不应该直接修改 state。

数组:该用和该避免的方法

规则很简单:使用返回新数组的方法,避免会修改调用者本身的方法。

目的避免(会修改原数组)使用(返回新数组)
添加push, unshift[...arr, item], [item, ...arr]
删除pop, shift, splicefilter, slice
替换arr[i] = x, splicemap, with(i, x)
插入splice[...arr.slice(0, i), x, ...arr.slice(i)], toSpliced
排序、反转sort, reversetoSorted, toReversed,或者先复制

toSorted、toReversed、toSpliced 和 with 是较新的数组方法(ES2023),自 2023 年起所有主流浏览器的当前版本都已支持。它们在副本上完成和各自那些会修改原数组的“表亲”相同的工作。

用 map 更新单个元素

要修改某一项,就对数组调用 map,为你想改的那一项返回一个新对象,其他每一项都返回原来的对象。

{ ...todo, done: !todo.done } 这部分很关键。一个常见的 bug 是复制了数组,却修改了数组里的对象:

const next = [...todos];
next[0].done = true; // next is new, but next[0] is the same object as todos[0]
setTodos(next);

这段代码会触发渲染,所以看起来没问题,但旧数组和新数组现在共享着一个被修改过的对象。任何拿旧元素和新元素做比较的地方(一个 React.memo 子组件,一个依赖这个元素的 Effect)都看不出区别。要把你修改的那一项也复制一份。

在指定索引处插入

没有不修改原数组的 insert,所以要把数组切成两半,把新元素放在中间。toSpliced(index, 0, item) 一次调用就能做到同样的事。

把索引设为 0 再插入一次:新的 C 会出现在最前面。把 next 表达式换成 letters.toSpliced(index, 0, 'C'),结果是一样的。

排序和反转

sort 和 reverse 会修改调用它们的数组,尽管它们也会返回这个数组。这让 setItems(items.sort()) 变成了一次直接修改,再加上一次传入相同引用的 set,也就是本页开头那个 bug。应该对副本排序。

[...scores].reverse() 之所以可行,是因为展开运算符先创建了副本,reverse 随后只修改这个副本。

更新对象和嵌套对象

对象遵循同样的规则。把旧对象展开到一个新对象里,再覆盖要修改的字段。对于嵌套对象,要在通往目标字段的每一层都展开:只展开一次只会复制顶层,内层对象仍然是旧的那个。

你复制的每一层都是一个新对象;你没碰的每一层都保持为同一个对象。这正是展开运算符开销小的原因:如果 state 里还有一个很长的订单列表,{ ...user } 复制的只是这个列表的引用,而不是列表本身。

如果 state 中的某个对象嵌套了三四层,而你总在写一长串展开运算符,这往往说明 state 可以设计得更扁平。按 id 存储元素以便查找,或者把一个大对象拆成几个 useState 调用,都能让每次更新保持简短。当很多事件都会修改同一个结构时,reducer 也会有帮助(见 useReducer)。

structuredClone 与展开运算符

structuredClone(user) 一次调用就能做出深拷贝,之后你就可以随意修改这个副本:

const next = structuredClone(user);
next.address.city = 'Oslo';
setUser(next);

这样做是正确的,但它每次都会复制整个对象,包括你没有修改的部分。在 state 很大时这会耗费时间,而且意味着每个嵌套对象都会得到一个新引用,所以一个接收 user.orders 的 memo 化子组件即使订单没变也会重新渲染。structuredClone 遇到函数还会抛出错误,类的实例复制后会变成普通对象。展开运算符只复制你修改的那条路径,所以它是通常的选择;只有在极少数真正要重写对象大部分内容的更新中,才使用 structuredClone。

用 Immer 以“修改”的方式编写

Immer 是一个小巧的库,让你可以写出看起来像直接修改的代码。它交给你一个草稿(draft),记录你做了哪些修改,然后生成一个新的不可变值,只复制发生变化的部分。use-immer 包把它封装成了一个 Hook:

import { useImmer } from 'use-immer';

function Profile() {
    const [user, updateUser] = useImmer({
        name: 'Ada',
        address: { city: 'London' },
    });

    function moveTo(city) {
        updateUser((draft) => {
            draft.address.city = city;
        });
    }
    // ...
}

本页的编辑器只加载了 React,所以无法使用 Immer。当你的 state 嵌套很深、展开运算符变得难以阅读时,值得引入它。对于扁平的 state,普通的展开运算符和 map 就够了,而且大多数 React 代码里你看到的也正是这种写法。

常见问题

如何向 React state 中的数组添加元素?

创建一个包含旧元素和新元素的新数组,再把它传给 setter:setItems([...items, newItem])。不要调用 items.push(newItem),因为它会修改现有的数组,React 看到的还是同一个引用。

为什么更新数组后组件没有重新渲染?

你很可能是原地修改了数组(push、splice、sort 或 arr[i] = x),又把同一个数组传了回去。React 用 Object.is 比较新旧值,发现是同一个对象,于是跳过了渲染。应该传入一个新数组。

如何在 React 中更新数组里的某一个对象?

用 map,为要修改的那一项返回一个新对象,其他每一项都返回原来的对象:setTodos(todos.map(t => t.id === id ? { ...t, done: !t.done } : t))。

如何更新 React state 中的嵌套对象?

把通往要修改字段的路径上的每一层都复制一遍:setUser({ ...user, address: { ...user.address, city: 'Oslo' } })。只展开一次只会复制顶层,内层对象仍然是共享的。

React state 应该用 structuredClone 还是展开运算符?

优先用展开运算符。它只复制发生变化的部分,没动的部分保持为同一个对象,这对 React.memo 和比较都有帮助。structuredClone 每次都复制全部内容,写起来更简单,但在 state 很大时更慢,而且会让没有变化的部分也失去引用相等。

Coddy 编程语言插图

和 Coddy 一起学编程

开始