State 可以儲存任何種類的 JavaScript 值,包括物件。但不應該直接更改在 React state 中儲存的物件。相反地,當你想更新物件時,你需要建立一個新的物件(或複製現有的物件),然後將 state 設定為使用該副本。
你將學到
- 如何在 React state 中正確更新物件
- 如何在不修改巢狀物件的情況下更新它
- 什麼是不變性,以及如何不破壞它
- 如何使用 Immer 減少物件複製的重複性
什麼是修改?
你可以在 state 中儲存任何種類的 JavaScript 值。
const [x, setX] = useState(0);
到目前為止,你一直在使用數字、字串和布林值。這種類型的 JavaScript 值是「不可變的」,表示不可更改或「唯讀」。你可以觸發重新渲染來*取代*一個值
setX(5);
x
state 從 0
改為 5
,但*數字 0
本身*並沒有改變。在 JavaScript 中,不可能對內建的原始值(如數字、字串和布林值)進行任何更改。
現在考慮 state 中的一個物件
const [position, setPosition] = useState({ x: 0, y: 0 });
從技術上講,可以更改*物件本身*的內容。 **這稱為修改:**
position.x = 5;
但是,儘管 React state 中的物件在技術上是可變的,但你應該將它們**視為**不可變的——就像數字、布林值和字串一樣。你不應該修改它們,而應該始終替換它們。
將 State 視為唯讀
換句話說,你應該**將放入 state 中的任何 JavaScript 物件視為唯讀。**
此範例在 state 中儲存一個物件來表示目前的指標位置。當你觸摸或將游標移到預覽區域上時,紅點應該會移動。但該點停留在初始位置
import { useState } from 'react'; export default function MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { position.x = e.clientX; position.y = e.clientY; }} style={{ position: 'relative', width: '100vw', height: '100vh', }}> <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ); }
問題出在這段程式碼。
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
這段程式碼修改了分配給 position
的物件,從先前的渲染。但是,如果不使用 state 設定函式,React 就不知道物件已更改。所以 React 沒有做出任何反應。這就像在你已經吃完飯之後試圖更改訂單一樣。雖然在某些情況下修改 state 可以正常運作,但我們不建議這樣做。你應該將在渲染中存取的 state 值視為唯讀。
要在此情況下實際觸發重新渲染,請**建立一個*新*物件並將其傳遞給 state 設定函式:**
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
使用 setPosition
,你是在告訴 React
- 使用這個新物件替換
position
- 並再次渲染此元件
請注意,當你觸摸或將滑鼠懸停在預覽區域上時,紅點現在如何跟隨你的指標
import { useState } from 'react'; export default function MovingDot() { const [position, setPosition] = useState({ x: 0, y: 0 }); return ( <div onPointerMove={e => { setPosition({ x: e.clientX, y: e.clientY }); }} style={{ position: 'relative', width: '100vw', height: '100vh', }}> <div style={{ position: 'absolute', backgroundColor: 'red', borderRadius: '50%', transform: `translate(${position.x}px, ${position.y}px)`, left: -10, top: -10, width: 20, height: 20, }} /> </div> ); }
深入探討
像這樣的程式碼會產生問題,因為它修改了 state 中*現有的*物件
position.x = e.clientX;
position.y = e.clientY;
但像這樣的程式碼**絕對沒問題**,因為你修改的是你*剛剛建立*的全新物件
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);
事實上,它與編寫以下程式碼完全等效
setPosition({
x: e.clientX,
y: e.clientY
});
只有當你更改 state 中已*存在的*物件時,修改才會成為問題。修改你剛剛建立的物件是可以的,因為*沒有其他程式碼引用它*。更改它不會意外影響依賴它的東西。這稱為「局部修改」。你甚至可以在渲染時進行局部修改。非常方便而且完全沒問題!
使用展開語法複製物件
在前面的例子中,position
物件總是根據目前的游標位置全新建立。但通常,您會希望將*現有*的資料包含在您正在建立的新物件中。例如,您可能只想更新表單中的*一個*欄位,但保留所有其他欄位的先前值。
這些輸入欄位無法正常運作,因為 onChange
事件處理函式會改變狀態
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com' }); function handleFirstNameChange(e) { person.firstName = e.target.value; } function handleLastNameChange(e) { person.lastName = e.target.value; } function handleEmailChange(e) { person.email = e.target.value; } return ( <> <label> First name: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label> Email: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> ); }
例如,這行程式碼會改變先前渲染的狀態
person.firstName = e.target.value;
要獲得您期望的行為,可靠的方法是建立一個新物件並將其傳遞給 setPerson
。但在這裡,您也希望將現有資料複製到其中,因為只有一個欄位已更改
setPerson({
firstName: e.target.value, // New first name from the input
lastName: person.lastName,
email: person.email
});
您可以使用 ...
物件展開 語法,這樣您就不需要單獨複製每個屬性。
setPerson({
...person, // Copy the old fields
firstName: e.target.value // But override this one
});
現在表單可以正常運作了!
請注意,您沒有為每個輸入欄位宣告單獨的狀態變數。對於大型表單,將所有資料分組在一個物件中非常方便—只要您正確地更新它!
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com' }); function handleFirstNameChange(e) { setPerson({ ...person, firstName: e.target.value }); } function handleLastNameChange(e) { setPerson({ ...person, lastName: e.target.value }); } function handleEmailChange(e) { setPerson({ ...person, email: e.target.value }); } return ( <> <label> First name: <input value={person.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={person.lastName} onChange={handleLastNameChange} /> </label> <label> Email: <input value={person.email} onChange={handleEmailChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> ); }
請注意,...
展開語法是「淺層」複製—它只複製一層深度。這使得它速度很快,但也意味著如果您想更新巢狀屬性,您將需要多次使用它。
深入探討
您也可以在物件定義中使用 [
和 ]
大括號來指定具有動態名稱的屬性。以下是相同的範例,但使用單一事件處理函式而不是三個不同的事件處理函式
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ firstName: 'Barbara', lastName: 'Hepworth', email: 'bhepworth@sculpture.com' }); function handleChange(e) { setPerson({ ...person, [e.target.name]: e.target.value }); } return ( <> <label> First name: <input name="firstName" value={person.firstName} onChange={handleChange} /> </label> <label> Last name: <input name="lastName" value={person.lastName} onChange={handleChange} /> </label> <label> Email: <input name="email" value={person.email} onChange={handleChange} /> </label> <p> {person.firstName}{' '} {person.lastName}{' '} ({person.email}) </p> </> ); }
在這裡,e.target.name
指的是賦予 <input>
DOM 元素的 name
屬性。
更新巢狀物件
考慮像這樣的巢狀物件結構
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
如果您想更新 person.artwork.city
,很清楚如何使用變動來做到這一點
person.artwork.city = 'New Delhi';
但在 React 中,您將狀態視為不可變的!為了更改 city
,您首先需要產生新的 artwork
物件(預先填入先前資料),然後產生指向新 artwork
的新 person
物件
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
或者,寫成單一函式呼叫
setPerson({
...person, // Copy other fields
artwork: { // but replace the artwork
...person.artwork, // with the same one
city: 'New Delhi' // but in New Delhi!
}
});
這有點囉嗦,但在許多情況下都能正常運作
import { useState } from 'react'; export default function Form() { const [person, setPerson] = useState({ name: 'Niki de Saint Phalle', artwork: { title: 'Blue Nana', city: 'Hamburg', image: 'https://i.imgur.com/Sd1AgUOm.jpg', } }); function handleNameChange(e) { setPerson({ ...person, name: e.target.value }); } function handleTitleChange(e) { setPerson({ ...person, artwork: { ...person.artwork, title: e.target.value } }); } function handleCityChange(e) { setPerson({ ...person, artwork: { ...person.artwork, city: e.target.value } }); } function handleImageChange(e) { setPerson({ ...person, artwork: { ...person.artwork, image: e.target.value } }); } return ( <> <label> Name: <input value={person.name} onChange={handleNameChange} /> </label> <label> Title: <input value={person.artwork.title} onChange={handleTitleChange} /> </label> <label> City: <input value={person.artwork.city} onChange={handleCityChange} /> </label> <label> Image: <input value={person.artwork.image} onChange={handleImageChange} /> </label> <p> <i>{person.artwork.title}</i> {' by '} {person.name} <br /> (located in {person.artwork.city}) </p> <img src={person.artwork.image} alt={person.artwork.title} /> </> ); }
深入探討
像這樣的物件在程式碼中看起來是「巢狀的」
let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};
然而,「巢狀」是一種不準確的思考物件行為的方式。當程式碼執行時,沒有「巢狀」物件這種東西。您實際上看到的是兩個不同的物件
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
obj1
物件不在 obj2
「內部」。例如,obj3
也可以「指向」obj1
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
let obj3 = {
name: 'Copycat',
artwork: obj1
};
如果您要改變 obj3.artwork.city
,它會同時影響 obj2.artwork.city
和 obj1.city
。這是因為 obj3.artwork
、obj2.artwork
和 obj1
是同一個物件。當您將物件視為「巢狀」時,這很難看出。相反,它們是透過屬性互相「指向」的獨立物件。
使用 Immer 編寫簡潔的更新邏輯
如果您的狀態深度巢狀,您可能需要考慮將其扁平化。但是,如果您不想更改狀態結構,您可能更喜歡巢狀展開的捷徑。Immer 是一個熱門的函式庫,可讓您使用方便但會改變狀態的語法編寫程式碼,並負責為您產生副本。使用 Immer,您編寫的程式碼看起來像是您在「破壞規則」並改變物件的狀態
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
但與常規的變動不同,它不會覆蓋過去的狀態!
深入探討
Immer 提供的 draft
是一種特殊物件,稱為 Proxy,它會「記錄」您對它的操作。這就是為什麼您可以隨意修改它的原因!在底層,Immer 會找出 draft
中哪些部分已更改,並產生一個包含您編輯內容的全新物件。
試用 Immer
- 執行
npm install use-immer
將 Immer 作為依賴項新增 - 然後將
import { useState } from 'react'
替換為import { useImmer } from 'use-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": {} }
請注意事件處理函式變得更簡潔。您可以在單個組件中根據需要混合搭配 useState
和 useImmer
。Immer 是一個讓更新處理函式保持簡潔的好方法,尤其是在您的狀態中存在巢狀結構,並且複製物件會導致程式碼重複的情況下。
深入探討
有幾個原因
- 除錯:如果您使用
console.log
且不修改狀態,您過去的紀錄就不會被最近的狀態更改覆蓋。因此您可以清楚地看到狀態在渲染之間是如何變化的。 - 優化:常見的 React 優化策略 依賴於如果先前的 props 或狀態與下一個相同,則跳過工作。如果您從不修改狀態,檢查是否有任何更改會非常快速。如果
prevObj === obj
,您可以確定它內部沒有任何變化。 - 新功能:我們正在構建的新 React 功能依賴於狀態被 視為快照。如果您正在修改過去版本的狀態,那可能會阻止您使用新功能。
- 需求變更:某些應用程式功能,例如實作復原/重做、顯示變更歷史記錄或讓使用者將表單重設為較早的值,在不修改任何內容時更容易完成。這是因為您可以將過去的狀態副本保留在記憶體中,並在適當的時候重複使用它們。如果您一開始就使用修改的方法,那麼以後可能難以新增此類功能。
- 更簡單的實作:由於 React 不依賴修改,因此它不需要對您的物件進行任何特殊處理。它不需要劫持其屬性、始終將其包裝到 Proxy 中,或像許多「反應式」解決方案那樣在初始化時執行其他工作。這也是為什麼 React 允許您將任何物件放入狀態(無論大小)而沒有額外的效能或正確性陷阱的原因。
在實務上,您經常可以在 React 中「僥倖逃脫」修改狀態,但我們強烈建議您不要這樣做,以便您可以使用以此方法開發的新 React 功能。未來的貢獻者,甚至未來的您都會感謝您!
摘要
- 將 React 中的所有狀態視為不可變的。
- 當您將物件儲存在狀態中時,修改它們不會觸發渲染,並且會更改先前渲染「快照」中的狀態。
- 不要修改物件,而是建立它的 *新* 版本,並透過將狀態設定為它來觸發重新渲染。
- 您可以使用
{...obj, something: 'newValue'}
物件展開語法來建立物件的副本。 - 展開語法是淺層的:它只複製一層深度。
- 要更新巢狀物件,您需要從您正在更新的位置一直向上建立副本。
- 要減少重複的複製程式碼,請使用 Immer。
挑戰 1之 3: 修正錯誤的狀態更新
此表單有一些錯誤。點擊幾次增加分數的按鈕。請注意,它沒有增加。然後編輯名字,並注意分數突然「趕上」了您的更改。最後,編輯姓氏,並注意分數已完全消失。
您的任務是修復所有這些錯誤。當您修復它們時,請解釋為什麼每個錯誤會發生。
import { useState } from 'react'; export default function Scoreboard() { const [player, setPlayer] = useState({ firstName: 'Ranjani', lastName: 'Shettar', score: 10, }); function handlePlusClick() { player.score++; } function handleFirstNameChange(e) { setPlayer({ ...player, firstName: e.target.value, }); } function handleLastNameChange(e) { setPlayer({ lastName: e.target.value }); } return ( <> <label> Score: <b>{player.score}</b> {' '} <button onClick={handlePlusClick}> +1 </button> </label> <label> First name: <input value={player.firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={player.lastName} onChange={handleLastNameChange} /> </label> </> ); }