在 JavaScript 中,数组是另一种可变的对象,但在将它们存储在状态中时,应将其视为不可变的。与对象一样,当要更新存储在状态中的数组时,需要创建一个新的数组(或现有数组的副本),然后设置状态以使用新数组。
你将学习
- 如何在 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>Inspiring sculptors:</h1> <input value={name} onChange={e => setName(e.target.value)} /> <button onClick={() => { artists.push({ id: nextId++, name: name, }); }}>Add</button> <ul> {artists.map(artist => ( <li key={artist.id}>{artist.name}</li> ))} </ul> </> ); }
相反,创建一个包含现有项目 *和* 新项目的新数组。有多种方法可以做到这一点,但最简单的方法是使用...
数组展开 语法。
setArtists( // Replace the state
[ // with a new array
...artists, // that contains all the old items
{ id: nextId++, name: name } // and one new item at the end
]
);
现在它可以正常工作了。
import { useState } from 'react'; let nextId = 0; export default function List() { const [name, setName] = useState(''); const [artists, setArtists] = useState([]); return ( <> <h1>Inspiring sculptors:</h1> <input value={name} onChange={e => setName(e.target.value)} /> <button onClick={() => { setArtists([ ...artists, { id: nextId++, name: name } ]); }}>Add</button> <ul> {artists.map(artist => ( <li key={artist.id}>{artist.name}</li> ))} </ul> </> ); }
数组展开语法还可以通过将其放在原始...artists
之前来添加项目。
setArtists([
{ id: nextId++, name: name },
...artists // Put old items at the end
]);
这样,展开语法既可以像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>Inspiring sculptors:</h1> <ul> {artists.map(artist => ( <li key={artist.id}> {artist.name}{' '} <button onClick={() => { setArtists( artists.filter(a => a.id !== artist.id ) ); }}> Delete </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') { // No change return shape; } else { // Return a new circle 50px below return { ...shape, y: shape.y + 50, }; } }); // Re-render with the new array setShapes(nextShapes); } return ( <> <button onClick={handleClick}> Move circles down! </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) { // Increment the clicked counter return c + 1; } else { // The rest haven't changed return c; } }); setCounters(nextCounters); } return ( <ul> {counters.map((counter, i) => ( <li key={i}> {counter} <button onClick={() => { handleIncrementClick(i); }}>+1</button> </li> ))} </ul> ); }
插入数组元素
有时,你可能希望在一个既不是开头也不是结尾的特定位置插入一个项目。为此,可以将...
数组展开语法与slice()
方法一起使用。slice()
方法允许你切割数组的“切片”。要插入一个项目,你将创建一个数组,该数组在插入点之前展开切片,然后是新项目,然后是原始数组的其余部分。
在这个例子中,“插入”按钮始终在索引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; // Could be any index const nextArtists = [ // Items before the insertion point: ...artists.slice(0, insertAt), // New item: { id: nextId++, name: name }, // Items after the insertion point: ...artists.slice(insertAt) ]; setArtists(nextArtists); setName(''); } return ( <> <h1>Inspiring sculptors:</h1> <input value={name} onChange={e => setName(e.target.value)} /> <button onClick={handleClick}> Insert </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}> Reverse </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; // Problem: mutates 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>Art Bucket List</h1> <h2>My list of art to see:</h2> <ItemList artworks={myList} onToggle={handleToggleMyList} /> <h2>Your list of art to see:</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; // Problem: mutates an existing item
setMyList(myNextList);
虽然myNextList
数组本身是新的,但项目本身与原始myList
数组中的项目相同。因此,更改artwork.seen
会更改原始艺术品项目。该艺术品项目也在yourList
中,这导致了错误。这种错误可能很难考虑,但幸运的是,如果你避免修改状态,它们就会消失。
你可以使用map
用其更新版本替换旧项目,而不会发生突变。
setMyList(myList.map(artwork => {
if (artwork.id === artworkId) {
// Create a *new* object with changes
return { ...artwork, seen: nextSeen };
} else {
// No changes
return artwork;
}
}));
这里,...
是对象展开语法,用于创建对象的副本。
使用这种方法,不会修改任何现有的状态项目,并且错误已修复。
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) { // Create a *new* object with changes return { ...artwork, seen: nextSeen }; } else { // No changes return artwork; } })); } function handleToggleYourList(artworkId, nextSeen) { setYourList(yourList.map(artwork => { if (artwork.id === artworkId) { // Create a *new* object with changes return { ...artwork, seen: nextSeen }; } else { // No changes return artwork; } })); } return ( <> <h1>Art Bucket List</h1> <h2>My list of art to see:</h2> <ItemList artworks={myList} onToggle={handleToggleMyList} /> <h2>Your list of art to see:</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 重写的 Art Bucket List 示例
{ "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
对象。同样,你可以将push()
和pop()
之类的可变方法应用于draft
的内容。
在幕后,Immer 总是根据你对draft
所做的更改从头开始构建下一个状态。这使得你的事件处理程序非常简洁,并且永远不会修改状态。
总结
- 你可以将数组放入状态,但你不能更改它们。
- 不要修改数组,而是创建它的新版本,并将其更新到状态。
- 你可以使用
[...arr, newItem]
数组展开语法创建包含新项目的新数组。 - 你可以使用
filter()
和map()
创建具有过滤或转换项目的数组。 - 你可以使用 Immer 来保持代码简洁。
挑战 1的 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> ); }