參考文章
更新 state 中的對象
state 中可以保存任意類型的 JavaScript 值,包括對象。但是,不應該直接修改存放在 React state 中的對象。相反,當想要更新一個對象時,需要創建一個新的對象(或者將其拷貝一份),然后將 state 更新為此對象。
什么是 mutation?
可以在 state 中存放任意類型的 JavaScript 值。
const [x, setX] = useState(0);
在 state 中存放數字、字符串和布爾值,這些類型的值在 JavaScript 中是不可變(immutable)的,這意味著它們不能被改變或是只讀的。可以通過替換它們的值以觸發一次重新渲染。
setX(5);
state x
從 0
變為 5
,但是數字 0
本身并沒有發生改變。在 JavaScript 中,無法對內置的原始值,如數字、字符串和布爾值,進行任何更改。
現在考慮 state 中存放對象的情況:
const [position, setPosition] = useState({ x: 0, y: 0 });
從技術上來講,可以改變對象自身的內容。當這樣做時,就制造了一個 mutation:
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 (<divonPointerMove={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 (<divonPointerMove={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>);
}
使用展開語法復制對象
在之前的例子中,始終會根據當前指針的位置創建出一個新的 position
對象。但是通常,會希望把 現有 數據作為所創建的新對象的一部分。例如,可能只想要更新表單中的一個字段,其他的字段仍然使用之前的值。
下面的代碼中,輸入框并不會正常運行,因為 onChange
直接修改了 state :
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:<inputvalue={person.firstName}onChange={handleFirstNameChange}/></label><label>Last name:<inputvalue={person.lastName}onChange={handleLastNameChange}/></label><label>Email:<inputvalue={person.email}onChange={handleEmailChange}/></label><p>{person.firstName}{' '}{person.lastName}{' '}({person.email})</p></>);
}
例如,下面這行代碼修改了上一次渲染中的 state:
person.firstName = e.target.value;
想要實現需求,最可靠的辦法就是創建一個新的對象并將它傳遞給 setPerson
。但是在這里,還需要 把當前的數據復制到新對象中,因為只改變了其中一個字段:
setPerson({firstName: e.target.value, // 從 input 中獲取新的 first namelastName: person.lastName,email: person.email
});
可以使用 ...
對象展開 語法,這樣就不需要單獨復制每個屬性。
setPerson({...person, // 復制上一個 person 中的所有字段firstName: e.target.value // 但是覆蓋 firstName 字段
});
現在表單可以正常運行了!
可以看到,并沒有為每個輸入框單獨聲明一個 state。對于大型表單,將所有數據都存放在同一個對象中是非常方便的——前提是能夠正確地更新它!
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:<inputvalue={person.firstName}onChange={handleFirstNameChange}/></label><label>Last name:<inputvalue={person.lastName}onChange={handleLastNameChange}/></label><label>Email:<inputvalue={person.email}onChange={handleEmailChange}/></label><p>{person.firstName}{' '}{person.lastName}{' '}({person.email})</p></>);
}
請注意 ...
展開語法本質是“淺拷貝”——它只會復制一層。這使得它的執行速度很快,但是也意味著當想要更新一個嵌套屬性時,必須得多次使用展開語法。
更新一個嵌套對象
考慮下面這種結構的嵌套對象:
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
的值,用 mutation 來實現的方法非常容易理解:
person.artwork.city = 'New Delhi';
但是在 React 中,需要將 state 視為不可變的!為了修改 city
的值,首先需要創建一個新的 artwork
對象(其中預先填充了上一個 artwork
對象中的數據),然后創建一個新的 person
對象,并使得其中的 artwork
屬性指向新創建的 artwork
對象:
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
或者,寫成一個函數調用:
setPerson({...person, // 復制其它字段的數據 artwork: { // 替換 artwork 字段 ...person.artwork, // 復制之前 person.artwork 中的數據city: 'New Delhi' // 但是將 city 的值替換為 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:<inputvalue={person.name}onChange={handleNameChange}/></label><label>Title:<inputvalue={person.artwork.title}onChange={handleTitleChange}/></label><label>City:<inputvalue={person.artwork.city}onChange={handleCityChange}/></label><label>Image:<inputvalue={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}/></>);
}
使用 Immer 編寫簡潔的更新邏輯
如果 state 有多層的嵌套,或許應該考慮 將其扁平化。但是,如果不想改變 state 的數據結構,可以使用 Immer 來實現嵌套展開的效果。Immer 是一個非常流行的庫,它可以讓你使用簡便但可以直接修改的語法編寫代碼,并會幫你處理好復制的過程。通過使用 Immer,寫出的代碼看起來就像是“打破了規則”而直接修改了對象:
updatePerson(draft => {draft.artwork.city = 'Lagos';
});
但是不同于一般的 mutation,它并不會覆蓋之前的 state!
嘗試使用 Immer:
- 運行
npm install use-immer
添加 Immer 依賴 - 用
import { useImmer } from 'use-immer'
替換掉import { useState } from 'react'
下面我們把上面的例子用 Immer 實現一下:
import { useImmer } from 'use-immer';export default function Form() {const [person, updatePerson] = useImmer({name: 'Niki de Saint Phalle',artwork: {title: 'Blue Nana',city: 'Hamburg',image: 'https://i.imgur.com/Sd1AgUOm.jpg',}});function handleNameChange(e) {updatePerson(draft => {draft.name = e.target.value;});}function handleTitleChange(e) {updatePerson(draft => {draft.artwork.title = e.target.value;});}function handleCityChange(e) {updatePerson(draft => {draft.artwork.city = e.target.value;});}function handleImageChange(e) {updatePerson(draft => {draft.artwork.image = e.target.value;});}return (<><label>Name:<inputvalue={person.name}onChange={handleNameChange}/></label><label>Title:<inputvalue={person.artwork.title}onChange={handleTitleChange}/></label><label>City:<inputvalue={person.artwork.city}onChange={handleCityChange}/></label><label>Image:<inputvalue={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}/></>);
}
package.json:
{"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 會是一個不錯的選擇,尤其是當 state 中有嵌套,并且復制對象會帶來重復的代碼時。
摘要
- 將 React 中所有的 state 都視為不可直接修改的。
- 當在 state 中存放對象時,直接修改對象并不會觸發重渲染,并會改變前一次渲染“快照”中 state 的值。
- 不要直接修改一個對象,而要為它創建一個 新 版本,并通過把 state 設置成這個新版本來觸發重新渲染。
- 可以使用這樣的
{...obj, something: 'newValue'}
對象展開語法來創建對象的拷貝。 - 對象的展開語法是淺層的:它的復制深度只有一層。
- 想要更新嵌套對象,需要從更新的位置開始自底向上為每一層都創建新的拷貝。
- 想要減少重復的拷貝代碼,可以使用 Immer。