数组在 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 重写的艺术品清单示例:
{ "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> ); }