在 JavaScript 中,陣列是可變的,但在將它們儲存在 state 中時,你應該將它們視為不可變的。就像物件一樣,當你想更新儲存在 state 中的陣列時,你需要建立一個新的陣列(或複製現有的陣列),然後將 state 設定為使用新的陣列。
你將學到
- 如何在 React state 中的陣列新增、移除或更改項目
- 如何更新陣列內的物件
- 如何使用 Immer 減少陣列複製的重複性
不使用突變更新陣列
在 JavaScript 中,陣列只是另一種物件。如同物件,你應該將 React state 中的陣列視為唯讀。這表示你不應該重新指派陣列內的項目,例如 arr[0] = 'bird'
,也不應該使用會改變陣列的方法,例如 push()
和 pop()
。
相反地,每次你想更新陣列時,你都需要將一個*新的*陣列傳遞給你的 state 設定函式。要做到這一點,你可以透過呼叫其非變異方法(例如 filter()
和 map()
)從 state 中的原始陣列建立一個新的陣列。然後,你可以將 state 設定為產生的新陣列。
以下是常見陣列操作的參考表格。在處理 React state 內的陣列時,你需要避免使用左欄中的方法,而應優先使用右欄中的方法
避免(會改變陣列) | 建議(回傳一個新陣列) | |
---|---|---|
新增 | 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> ); }