数组在 JavaScript 中是可变的,但当你把它们存储在状态中时,应该把它们当作不可变的。和对象一样,当你想更新存储在状态中的数组时,你需要创建一个新的数组(或者复制现有数组),然后将状态设置为使用这个新数组。
You will learn
- 如何在 React 状态中的数组里添加、删除或更改项目
- 如何更新数组中的对象
- 如何使用 Immer 让数组复制变得不那么重复
更新数组而不发生突变
在 JavaScript 中,数组只是另一种对象。和对象一样,你应该把 React 状态中的数组视为只读。 这意味着你不应该像 arr[0] = 'bird' 这样重新赋值数组中的项,也不应该使用会修改数组的方法,比如 push() 和 pop()。
相反,每当你想更新一个数组时,都应该把一个新数组传递给状态设置函数。为此,你可以通过调用不改变原数组的方法(例如 filter() 和 map()),从状态中的原始数组创建一个新数组。然后你就可以将状态设置为得到的新数组。
下面是常见数组操作的参考表。当处理 React 状态中的数组时,你需要避免左侧的这些方法,而更倾向于右侧的方法:
| 避免(会修改数组) | 更推荐(返回新数组) | |
|---|---|---|
| 添加 | push, unshift | concat, [...arr] 扩展语法 (示例) |
| 删除 | pop, shift, splice | filter, slice (示例) |
| 替换 | splice, arr[i] = ... 赋值 | map (示例) |
| 排序 | reverse, sort | 先复制数组 (示例) |
或者,你也可以使用 Immer,这样你就能同时使用两列中的方法。
向数组中添加内容
push() 会修改数组,这不是你想要的:
import { useState } from 'react'; let nextId = 0; export default function List() { const [name, setName] = useState(''); const [artists, setArtists] = useState([]); return ( <> <h1>鼓舞人心的雕塑家:</h1> <input value={name} onChange={e => setName(e.target.value)} /> <button onClick={() => { artists.push({ id: nextId++, name: name, }); }}>添加</button> <ul> {artists.map(artist => ( <li key={artist.id}>{artist.name}</li> ))} </ul> </> ); }
相反,创建一个包含现有项目以及末尾新项目的新数组。有多种方法可以做到这一点,但最简单的方法是使用 ... 数组展开语法:
setArtists( // 替换状态
[ // 为一个新数组
...artists, // 它包含所有旧项目
{ id: nextId++, name: name } // 以及末尾的一个新项目
]
);现在它可以正确工作了:
import { useState } from 'react'; let nextId = 0; export default function List() { const [name, setName] = useState(''); const [artists, setArtists] = useState([]); return ( <> <h1>鼓舞人心的雕塑家:</h1> <input value={name} onChange={e => setName(e.target.value)} /> <button onClick={() => { setArtists([ ...artists, { id: nextId++, name: name } ]); }}>添加</button> <ul> {artists.map(artist => ( <li key={artist.id}>{artist.name}</li> ))} </ul> </> ); }
数组展开语法还允许你通过把项目放在原始 ...artists 的前面来在开头插入一个项目:
setArtists([
{ id: nextId++, name: name },
...artists // 把旧项目放到末尾
]);这样,展开语法既可以通过在数组末尾添加内容来实现 push() 的效果,也可以通过在数组开头添加内容来实现 unshift() 的效果。在上面的沙盒里试试吧!
从数组中删除内容
从数组中移除项目最简单的方法是把它过滤掉。换句话说,你将生成一个不包含该项目的新数组。为此,请使用 filter 方法,例如:
import { useState } from 'react'; let initialArtists = [ { id: 0, name: 'Marta Colvin Andrade' }, { id: 1, name: 'Lamidi Olonade Fakeye'}, { id: 2, name: 'Louise Nevelson'}, ]; export default function List() { const [artists, setArtists] = useState( initialArtists ); return ( <> <h1>鼓舞人心的雕塑家:</h1> <ul> {artists.map(artist => ( <li key={artist.id}> {artist.name}{' '} <button onClick={() => { setArtists( artists.filter(a => a.id !== artist.id ) ); }}> 删除 </button> </li> ))} </ul> </> ); }
点击几次“删除”按钮,看看它的点击处理函数。
setArtists(
artists.filter(a => a.id !== artist.id)
);这里,artists.filter(a => a.id !== artist.id) 的意思是“创建一个数组,由那些 ID 与 artist.id 不同的 artists 组成”。换句话说,每个艺术家的“删除”按钮都会把该艺术家过滤出数组,然后请求用结果数组重新渲染。注意,filter 不会修改原始数组。
转换数组
如果你想更改数组中的部分或全部项目,可以使用 map() 创建一个新数组。你传给 map 的函数可以根据每个项目的数据或索引(或两者)决定如何处理该项目。
在这个例子中,一个数组保存了两个圆和一个正方形的坐标。当你按下按钮时,它只会把圆向下移动 50 像素。它通过使用 map() 生成一个新的数据数组来实现这一点:
import { useState } from 'react'; let initialShapes = [ { id: 0, type: 'circle', x: 50, y: 100 }, { id: 1, type: 'square', x: 150, y: 100 }, { id: 2, type: 'circle', x: 250, y: 100 }, ]; export default function ShapeEditor() { const [shapes, setShapes] = useState( initialShapes ); function handleClick() { const nextShapes = shapes.map(shape => { if (shape.type === 'square') { // 没有变化 return shape; } else { // 返回一个向下 50px 的新圆 return { ...shape, y: shape.y + 50, }; } }); // 使用新数组重新渲染 setShapes(nextShapes); } return ( <> <button onClick={handleClick}> 向下移动圆形! </button> {shapes.map(shape => ( <div key={shape.id} style={{ background: 'purple', position: 'absolute', left: shape.x, top: shape.y, borderRadius: shape.type === 'circle' ? '50%' : '', width: 20, height: 20, }} /> ))} </> ); }
替换数组中的项目
在数组中替换一个或多个项目尤其常见。像 arr[0] = 'bird' 这样的赋值会修改原始数组,所以你同样应该为此使用 map。
要替换一个项目,可以使用 map 创建一个新数组。在你的 map 调用中,你会在第二个参数中得到项目索引。利用它来决定返回原始项目(第一个参数)还是其他内容:
import { useState } from 'react'; let initialCounters = [ 0, 0, 0 ]; export default function CounterList() { const [counters, setCounters] = useState( initialCounters ); function handleIncrementClick(index) { const nextCounters = counters.map((c, i) => { if (i === index) { // 增加被点击的计数器 return c + 1; } else { // 其余的都没有改变 return c; } }); setCounters(nextCounters); } return ( <ul> {counters.map((counter, i) => ( <li key={i}> {counter} <button onClick={() => { handleIncrementClick(i); }}>+1</button> </li> ))} </ul> ); }
插入到数组中
有时,你可能想在一个既不在开头也不在末尾的特定位置插入项目。为此,你可以将 ... 数组展开语法与 slice() 方法一起使用。slice() 方法可以让你切出数组的一“片”。要插入一个项目,你需要创建一个数组:先展开插入点之前的切片,然后是新项目,最后是原始数组的其余部分。
在这个例子中,Insert 按钮总是在索引 1 处插入:
import { useState } from 'react'; let nextId = 3; const initialArtists = [ { id: 0, name: 'Marta Colvin Andrade' }, { id: 1, name: 'Lamidi Olonade Fakeye'}, { id: 2, name: 'Louise Nevelson'}, ]; export default function List() { const [name, setName] = useState(''); const [artists, setArtists] = useState( initialArtists ); function handleClick() { const insertAt = 1; // 可以是任意索引 const nextArtists = [ // 插入点之前的项目: ...artists.slice(0, insertAt), // 新项目: { id: nextId++, name: name }, // 插入点之后的项目: ...artists.slice(insertAt) ]; setArtists(nextArtists); setName(''); } return ( <> <h1>鼓舞人心的雕塑家:</h1> <input value={name} onChange={e => setName(e.target.value)} /> <button onClick={handleClick}> 插入 </button> <ul> {artists.map(artist => ( <li key={artist.id}>{artist.name}</li> ))} </ul> </> ); }
对数组进行其他更改
有些事情你无法仅靠展开语法以及像 map() 和 filter() 这样的非变异方法来完成。例如,你可能想反转或排序一个数组。JavaScript 的 reverse() 和 sort() 方法会修改原始数组,所以你不能直接使用它们。
不过,你可以先复制数组,然后再对它进行修改。
例如:
import { useState } from 'react'; const initialList = [ { id: 0, title: 'Big Bellies' }, { id: 1, title: 'Lunar Landscape' }, { id: 2, title: 'Terracotta Army' }, ]; export default function List() { const [list, setList] = useState(initialList); function handleClick() { const nextList = [...list]; nextList.reverse(); setList(nextList); } return ( <> <button onClick={handleClick}> 反转 </button> <ul> {list.map(artwork => ( <li key={artwork.id}>{artwork.title}</li> ))} </ul> </> ); }
这里,你先使用 [...list] 展开语法创建原始数组的副本。既然你已经有了副本,就可以使用像 nextList.reverse() 或 nextList.sort() 这样的会修改数组的方法,甚至可以通过 nextList[0] = "something" 逐项赋值。
不过,即使你复制了一个数组,也不能直接修改其中已有的项目。 这是因为复制是浅拷贝——新数组仍然会包含与原数组相同的项目。所以如果你修改了复制数组中的某个对象,就等于修改了现有状态。例如,下面这样的代码就是有问题的:
const nextList = [...list];
nextList[0].seen = true; // 问题:修改了 list[0]
setList(nextList);尽管 nextList 和 list 是两个不同的数组,nextList[0] 和 list[0] 指向的是同一个对象。 所以当你改变 nextList[0].seen 时,也同时改变了 list[0].seen。这是一种状态突变,你应该避免!你可以用类似于更新嵌套的 JavaScript 对象的方式来解决这个问题——复制你想要更改的单个项目,而不是直接修改它们。下面是做法。
更新数组中的对象
对象其实并不 真的 位于数组“内部”。它们在代码中看起来像是“在里面”,但数组中的每个对象都是一个独立的值,而数组只是“指向”它。正因如此,当你修改像 list[0] 这样的嵌套字段时要格外小心。别人的艺术品列表可能也指向数组中的同一个元素!
当更新嵌套状态时,你需要从你想要更新的那个位置开始创建副本,一直创建到顶层。 我们来看看这是如何工作的。
在这个例子中,两个独立的艺术品列表有着相同的初始状态。它们本应彼此隔离,但由于一次突变,它们的状态意外地被共享了,在一个列表里勾选复选框会影响另一个列表:
import { useState } from 'react'; let nextId = 3; const initialList = [ { id: 0, title: 'Big Bellies', seen: false }, { id: 1, title: 'Lunar Landscape', seen: false }, { id: 2, title: 'Terracotta Army', seen: true }, ]; export default function BucketList() { const [myList, setMyList] = useState(initialList); const [yourList, setYourList] = useState( initialList ); function handleToggleMyList(artworkId, nextSeen) { const myNextList = [...myList]; const artwork = myNextList.find( a => a.id === artworkId ); artwork.seen = nextSeen; setMyList(myNextList); } function handleToggleYourList(artworkId, nextSeen) { const yourNextList = [...yourList]; const artwork = yourNextList.find( a => a.id === artworkId ); artwork.seen = nextSeen; setYourList(yourNextList); } return ( <> <h1>艺术待办清单</h1> <h2>我想看的艺术作品列表:</h2> <ItemList artworks={myList} onToggle={handleToggleMyList} /> <h2>你想看的艺术作品列表:</h2> <ItemList artworks={yourList} onToggle={handleToggleYourList} /> </> ); } function ItemList({ artworks, onToggle }) { return ( <ul> {artworks.map(artwork => ( <li key={artwork.id}> <label> <input type="checkbox" checked={artwork.seen} onChange={e => { onToggle( artwork.id, e.target.checked ); }} /> {artwork.title} </label> </li> ))} </ul> ); }
问题出在这样的代码里:
const myNextList = [...myList];
const artwork = myNextList.find(a => a.id === artworkId);
artwork.seen = nextSeen; // 问题:修改了现有项
setMyList(myNextList);虽然 myNextList 数组本身是新的,但其中的 项目本身 和原来的 myList 数组里的是同一个对象。所以修改 artwork.seen 实际上修改的是 原始 的 artwork 项。这个 artwork 项也在 yourList 中,于是就引发了这个 bug。这样的 bug 可能很难一下子想明白,但幸运的是,只要避免直接修改状态,它们就会消失。
你可以使用 map 用更新后的版本替换旧项,而无需修改原对象。
setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// 创建一个带有修改的新对象
return { ...artwork, seen: nextSeen };
} else {
// 没有变化
return artwork;
}
}));这里的 ... 是对象展开语法,用于创建对象副本。
采用这种方式后,现有状态项都没有被修改,bug 也就修复了:
import { useState } from 'react'; let nextId = 3; const initialList = [ { id: 0, title: 'Big Bellies', seen: false }, { id: 1, title: 'Lunar Landscape', seen: false }, { id: 2, title: 'Terracotta Army', seen: true }, ]; export default function BucketList() { const [myList, setMyList] = useState(initialList); const [yourList, setYourList] = useState( initialList ); function handleToggleMyList(artworkId, nextSeen) { setMyList(myList.map(artwork => { if (artwork.id === artworkId) { // 创建一个带有修改的新对象 return { ...artwork, seen: nextSeen }; } else { // 没有变化 return artwork; } })); } function handleToggleYourList(artworkId, nextSeen) { setYourList(yourList.map(artwork => { if (artwork.id === artworkId) { // 创建一个带有修改的新对象 return { ...artwork, seen: nextSeen }; } else { // 没有变化 return artwork; } })); } return ( <> <h1>艺术待办清单</h1> <h2>我想看的艺术作品列表:</h2> <ItemList artworks={myList} onToggle={handleToggleMyList} /> <h2>你想看的艺术作品列表:</h2> <ItemList artworks={yourList} onToggle={handleToggleYourList} /> </> ); } function ItemList({ artworks, onToggle }) { return ( <ul> {artworks.map(artwork => ( <li key={artwork.id}> <label> <input type="checkbox" checked={artwork.seen} onChange={e => { onToggle( artwork.id, e.target.checked ); }} /> {artwork.title} </label> </li> ))} </ul> ); }
一般来说,你只应该修改刚刚创建的对象。 如果你是在插入一个新的艺术作品,你可以修改它,但如果你处理的是已经存在于状态中的内容,就需要先创建副本。
使用 Immer 编写简洁的更新逻辑
在不修改原对象的情况下更新嵌套数组,可能会有点重复。和对象一样:
- 一般来说,你不需要把状态更新到超过两三层深。如果你的状态对象非常深,你可能需要以不同方式重构它们,让它们变成扁平结构。
- 如果你不想改变状态结构,你可能更倾向于使用 Immer,它允许你使用方便但看似可变的语法来编写代码,并负责为你生成副本。
下面是使用 Immer 重写后的艺术待办清单示例:
{ "dependencies": { "immer": "1.7.3", "react": "latest", "react-dom": "latest", "react-scripts": "latest", "use-immer": "0.5.1" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", "eject": "react-scripts eject" }, "devDependencies": {} }
注意在 Immer 中,像 artwork.seen = nextSeen 这样的修改现在是可以的:
updateMyTodos(draft => {
const artwork = draft.find(a => a.id === artworkId);
artwork.seen = nextSeen;
});这是因为你修改的不是 原始 状态,而是 Immer 提供的一个特殊 draft 对象。同样地,你也可以对 draft 的内容使用像 push() 和 pop() 这样的可变方法。
在内部,Immer 会根据你对 draft 所做的更改,从头开始构建下一个状态。这样一来,你的事件处理函数就能保持非常简洁,同时又不会修改状态。
Recap
- 你可以把数组放进状态里,但不能修改它们。
- 不要直接修改数组,而是创建它的一个 新 版本,并把状态更新为这个新版本。
- 你可以使用
[...arr, newItem]这种数组展开语法来创建包含新项的数组。 - 你可以使用
filter()和map()来创建经过过滤或转换的新数组。 - 你可以使用 Immer 来保持代码简洁。
Challenge 1 of 4: 更新购物车中的一项
补全 handleIncreaseClick 的逻辑,以便按下 ”+” 时增加对应的数量:
import { useState } from 'react'; const initialProducts = [{ id: 0, name: 'Baklava', count: 1, }, { id: 1, name: 'Cheese', count: 5, }, { id: 2, name: 'Spaghetti', count: 2, }]; export default function ShoppingCart() { const [ products, setProducts ] = useState(initialProducts) function handleIncreaseClick(productId) { } return ( <ul> {products.map(product => ( <li key={product.id}> {product.name} {' '} (<b>{product.count}</b>) <button onClick={() => { handleIncreaseClick(product.id); }}> + </button> </li> ))} </ul> ); }