背景: 繼續跟著官網的流程往后學,之前已經整理了描述UI以及添加交互兩個模塊,總體來說還是收獲不小的,至少我一個表面上用了四五年React的前端小卡拉米對React的使用都有了新的認知。接下來就到了狀態管理(React特地加了個中級的標簽)的模塊,那就一起學習吧~
前期回顧:
重學React(一):描述UI
重學React(二):添加交互
學習內容:
React官網教程:https://zh-hans.react.dev/learn/managing-state
其他輔助資料(看到再補充)
補充說明:這次學習更多的是以學習筆記的形式記錄,看到哪記到哪
隨著應用不斷變大,更有意識的去關注應用狀態如何組織,以及數據如何在組件之間流動會對你很有幫助。冗余或重復的狀態往往是缺陷的根源。這里將學習如何組織好狀態,如何保持狀態更新邏輯的可維護性,以及如何跨組件共享狀態。
1. 用 State 響應輸入
命令式UI編程:直接告訴計算機如何去更新 UI 的編程方式。比如打車時,你不告訴司機去哪,而是指揮他怎么走
聲明式UI編程:只需要 聲明你想要顯示的內容, React 就會通過計算得出該如何去更新 UI。比如打車時你只需要告訴司機去哪,怎么走司機會自己規劃
對一些小的獨立的系統來說,命令式地控制用戶頁面也能起到不錯的效果,比如你坐車從村口到家門口,有時候導航效果還不如口述清楚。但當系統變得復雜的時候,比如從北京到上海,還是讓司機和導航發揮來的好。
接下來我們來看一個在前端特別經典的例子——表單填寫。想象一個讓用戶提交答案的表單:
- 當你向表單輸入數據時,“提交”按鈕會隨之變成可用狀態
- 當你點擊“提交”后,表單和提交按鈕都會隨之變成不可用狀態,并且會加載動畫會隨之出現
- 如果網絡請求成功,表單會隨之隱藏,同時“提交成功”的信息會隨之出現
- 如果網絡請求失敗,錯誤信息會隨之出現,同時表單又變為可用狀態
<form id="form"><h2>City quiz</h2><p>What city is located on two continents?</p><textarea id="textarea"></textarea><br /><button id="button" disabled>Submit</button><p id="loading" style="display: none">Loading...</p><p id="error" style="display: none; color: red;"></p>
</form>
<h1 id="success" style="display: none">That's right!</h1><style>
* { box-sizing: border-box; }
body { font-family: sans-serif; margin: 20px; padding: 0; }
</style>
async function handleFormSubmit(e) {e.preventDefault();disable(textarea);disable(button);show(loadingMessage);hide(errorMessage);try {await submitForm(textarea.value);show(successMessage);hide(form);} catch (err) {show(errorMessage);errorMessage.textContent = err.message;} finally {hide(loadingMessage);enable(textarea);enable(button);}
}function handleTextareaChange() {if (textarea.value.length === 0) {disable(button);} else {enable(button);}
}function hide(el) {el.style.display = 'none';
}function show(el) {el.style.display = '';
}function enable(el) {el.disabled = false;
}function disable(el) {el.disabled = true;
}function submitForm(answer) {// Pretend it's hitting the network.return new Promise((resolve, reject) => {setTimeout(() => {if (answer.toLowerCase() === 'istanbul') {resolve();} else {reject(new Error('Good guess but a wrong answer. Try again!'));}}, 1500);});
}let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;
這段代碼可以實現表單的生成,但是能看到,這個邏輯會比較復雜,如果想要在這基礎上添加一些交互或者新的UI元素,還得從頭檢查一下,避免新bug的產生。接下來我們按照React 聲明式UI的思想來重新整理一下這個需求,你只需要完成以下幾個步驟:
- 定位你的組件中不同的視圖狀態
- 確定是什么觸發了這些 state 的改變
- 表示內存中的 state(需要使用 useState)
- 刪除任何不必要的 state 變量
- 連接事件處理函數去設置 state
定位你的組件中不同的視圖狀態
總結一下,在這個表單需求里我們需要以下幾個狀態:
- 無數據:表單有一個不可用狀態的“提交”按鈕。
- 輸入中:表單有一個可用狀態的“提交”按鈕。
- 提交中:表單完全處于不可用狀態,加載動畫出現。
- 成功時:顯示“成功”的消息而非表單。
- 錯誤時:與輸入狀態類似,但會多錯誤的消息。
我們首先要做的就是用state去模擬這些狀態
// app.js
import Form from './Form.js';
let statuses = ['empty','typing','submitting','success','error',
];export default function App() {return (<>{statuses.map(status => (<section key={status}><h4>Form ({status}):</h4><Form status={status} /></section>))}</>);
}// form.js
export default function Form({ status }) {if (status === 'success') {return <h1>That's right!</h1>}return (<form><textarea disabled={status === 'submitting'} /><br /><button disabled={status === 'empty' ||status === 'submitting'}>Submit</button>{status === 'error' &&<p className="Error">Good guess but a wrong answer. Try again!</p>}</form>);
}
確定是什么觸發了這些 state 的改變
上面的代碼把所有可能的狀態羅列了一遍。現實中,這些狀態應該都是互斥的,用戶在頁面中同時只能看到一個狀態下的UI界面,總不可能這個表單提交既成功又失敗吧(有些特定需求可能會有,但不在我們這次范圍內哈),接下來是確定下有什么情況會觸發state的更新:
- 人為輸入:比如點擊按鈕、在表單中輸入內容,或導航到鏈接。(typing,submitting都是人為輸入才會觸發,empty也可以人為刪除完所有輸入內容觸發)
- 計算機輸入:比如網絡請求得到反饋、定時器被觸發,或加載一張圖片。(success,error可以根據計算機反饋表單提交成功與否展示)
通過 useState 表示內存中的 state
按照之前的想法,結合state的含義,只要我們用useState表示組件中這些狀態,只要狀態改變,視圖自然會自動被更新(你只要告訴React現在要更新的是什么狀態,怎么更新交給React就好)
// 既然不知道要如何表示,那就先列舉出來,至少不遺漏某些狀態
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
刪除任何不必要的 state 變量
全部列舉出來當然是有很大可優化空間的,優化結構的最主要目的是防止出現在內存中的 state 不代表任何你希望用戶看到的有效 UI 的情況,比如empty狀態和禁止輸入不應該同時出現。
刪除不必要的state變量可以嘗試問自己這三個問題:
- 這個 state 是否會導致矛盾?
例如,isTyping 與 isSubmitting 的狀態不能同時為 true。矛盾的產生通常說明了這個 state 沒有足夠的約束條件。兩個布爾值有四種可能的組合,但是只有三種對應有效的狀態。為了將“不可能”的狀態移除,你可以將他們合并到一個 ‘status’ 中,它的值必須是 ‘typing’、‘submitting’ 以及 ‘success’ 這三個中的一個。 - 相同的信息是否已經在另一個 state 變量中存在?
另一個矛盾:isEmpty 和 isTyping 不能同時為 true。通過使它們成為獨立的 state 變量,可能會導致它們不同步并導致 bug。幸運的是,你可以移除 isEmpty 轉而用 message.length === 0。 - 你是否可以通過另一個 state 變量的相反值得到相同的信息?
isError 是多余的,因為你可以檢查 error !== null。
這一系列檢查下來,我們的state變量就可以改成這樣:
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'
還能不能接著優化,當然可以,不過需要引入一個新的概念——reducer,我們后面再講
連接事件處理函數以設置 state
state狀態定義好之后,最后一步就是創建事件處理函數去設置 state 變量。這樣就可以實現一個優雅又健壯的代碼啦~
其實這個方案也還有一些優化空間,可以先留個記號,等全部看完再看更優解是啥
import { useState } from 'react';export default function Form() {const [answer, setAnswer] = useState('');const [error, setError] = useState(null);const [status, setStatus] = useState('typing');if (status === 'success') {return <h1>That's right!</h1>}async function handleSubmit(e) {e.preventDefault();setStatus('submitting');try {await submitForm(answer);setStatus('success');} catch (err) {setStatus('typing');setError(err);}}function handleTextareaChange(e) {setAnswer(e.target.value);}return (<><h2>City quiz</h2><p>In which city is there a billboard that turns air into drinkable water?</p><form onSubmit={handleSubmit}><textareavalue={answer}onChange={handleTextareaChange}disabled={status === 'submitting'}/><br /><button disabled={answer.length === 0 ||status === 'submitting'}>Submit</button>{error !== null &&<p className="Error">{error.message}</p>}</form></>);
}function submitForm(answer) {// Pretend it's hitting the network.return new Promise((resolve, reject) => {setTimeout(() => {let shouldError = answer.toLowerCase() !== 'lima'if (shouldError) {reject(new Error('Good guess but a wrong answer. Try again!'));} else {resolve();}}, 1500);});
}
2. 選擇State結構
構建State的原則
當編寫一個帶有state的組件時,我們需要選擇使用多少個 state 變量以及它們都是怎樣的數據格式,選擇的可能性很多,像之前的例子,我們可以每一個可能的變量都設置成state,但更加合理的構建能使代碼更友好更健壯,最終目標是使 state 易于更新而不引入錯誤。“讓你的狀態盡可能簡單,但不要過于簡單”(這句是愛因斯坦說的)。接下來是一些構建State的原則:
- **合并關聯的 state。**如果你總是同時更新兩個或更多的 state 變量,請考慮將它們合并為一個單獨的 state 變量。
- **避免互相矛盾的 state。**當 state 結構中存在多個相互矛盾或“不一致”的 state 時,你就可能為此會留下隱患。應盡量避免這種情況。
- **避免冗余的 state。**如果你能在渲染期間從組件的 props 或其現有的 state 變量中計算出一些信息,則不應將這些信息放入該組件的 state 中。
- **避免重復的 state。**當同一數據在多個 state 變量之間或在多個嵌套對象中重復時,這會很難保持它們同步。應盡可能減少重復。
- **避免深度嵌套的 state。**深度分層的 state 更新起來不是很方便。如果可能的話,最好以扁平化方式構建 state。
合并關聯的 state
// 一個簡單的例子,如果兩個變量總是一起變化,比如記錄鼠標移動的位置
// 這個場景下鼠標移動的位置由x,y同時構成,合并兩個變量比同時更新兩個變量合理
const [x, setX] = useState(0);
const [y, setY] = useState(0);const [position, setPosition] = useState({ x: 0, y: 0 });// 但是要記住的是,如果只更新對象中其中一個數值,記得把另一個數值也帶上
setPosition({ x: 100 }) // ? 這樣會丟失y的值
setPosition({ ...position, x: 100 }) ?
避免矛盾的 state
考慮之前的例子,其實在刪除不必要的state變量時,就考慮了避免矛盾的state方法
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);// 其中isTyping,isSuccess和isError 都可以歸納成status
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'
避免冗余的 state
還是同樣的例子,我們可以發現,isError變量是多余的,因為我們在進行setError的時候,可以通過error是否為空判斷isError
關鍵邏輯是:如果能在渲染期間從組件的 props 或其現有的 state 變量中計算出一些信息,則不應該把這些信息放到該組件的 state 中
不要在 state 中鏡像 props
有一個很常見的場景,是state 變量被初始化為 prop 值function Message({ messageColor }) {const [color, setColor] = useState(messageColor);
這個例子的問題在于,state 僅在第一次渲染期間初始化,如果父組件稍后傳遞不同的 messageColor 值(例如,將其從 ‘blue’ 更改為 ‘red’),則 color state 變量將不會更新
如果想要單純把變量名縮短,直接使用常量聲明就好const color = messageColor;
這種把prop的值作為state初始化值的場景,只適用于想要 忽略特定 props 屬性的所有更新時。
避免重復的 state
請看下面的例子
import { useState } from 'react';const initialItems = [{ title: 'pretzels', id: 0 },{ title: 'crispy seaweed', id: 1 },{ title: 'granola bar', id: 2 },
];export default function Menu() {const [items, setItems] = useState(initialItems);const [selectedItem, setSelectedItem] = useState(items[0]);return (<><h2>What's your travel snack?</h2><ul>{items.map(item => (<li key={item.id}>{item.title}{' '}<button onClick={() => {setSelectedItem(item);}}>Choose</button></li>))}</ul><p>You picked {selectedItem.title}.</p></>);
}
這個代碼的問題是什么呢,假設一下,如果我們先點擊choose,然后修改當前選擇的snack的名稱,你會發現修改的名稱沒辦法同步到selectedItem.title
中。在這個場景中,selectedItem這個對象是重復的,重復聲明最容易出問題的原因是,每一次的更新,都需要確保每一個state都被同步。一個簡單的解法是,id是一直不變的,所以我們只需要記住被選中的id,每次都從items中渲染對應id的最新值,這樣就能避免多次同步更新的問題。
import { useState } from 'react';const initialItems = [{ title: 'pretzels', id: 0 },{ title: 'crispy seaweed', id: 1 },{ title: 'granola bar', id: 2 },
];export default function Menu() {const [items, setItems] = useState(initialItems);const [selectedId, setSelectedId] = useState(0);const selectedItem = items.find(item =>item.id === selectedId);function handleItemChange(id, e) {setItems(items.map(item => {if (item.id === id) {return {...item,title: e.target.value,};} else {return item;}}));}return (<><h2>What's your travel snack?</h2><ul>{items.map((item, index) => (<li key={item.id}><inputvalue={item.title}onChange={e => {handleItemChange(item.id, e)}}/>{' '}<button onClick={() => {setSelectedId(item.id);}}>Choose</button></li>))}</ul><p>You picked {selectedItem.title}.</p></>);
}
避免深度嵌套的 state
想象一個很大有多層嵌套的數據,比如我國的省市區GDP數據,如果想更新某個省某個市某個區的GDP,那需要一層一層嵌套的復制上去,會變得很麻煩。
如果 state 嵌套太深,難以輕松更新,可以考慮將其“扁平化”。以下是官方的一個例子,關鍵思想是:讓每個節點的 place 作為數組保存 其子節點的 ID。然后存儲一個節點 ID 與相應節點的映射關系。
import { useState } from 'react';
// 原始數據
export const initialTravelPlan = {0: {id: 0,title: '(Root)',childIds: [1, 42, 46],},1: {id: 1,title: 'Earth',childIds: [2, 10, 19, 26, 34]},2: {id: 2,title: 'Africa',childIds: [3, 4, 5, 6 , 7, 8, 9]}, 3: {id: 3,title: 'Botswana',childIds: []},4: {id: 4,title: 'Egypt',childIds: []},5: {id: 5,title: 'Kenya',childIds: []},6: {id: 6,title: 'Madagascar',childIds: []}, 7: {id: 7,title: 'Morocco',childIds: []},8: {id: 8,title: 'Nigeria',childIds: []},9: {id: 9,title: 'South Africa',childIds: []},10: {id: 10,title: 'Americas',childIds: [11, 12, 13, 14, 15, 16, 17, 18], },11: {id: 11,title: 'Argentina',childIds: []},12: {id: 12,title: 'Brazil',childIds: []},13: {id: 13,title: 'Barbados',childIds: []}, 14: {id: 14,title: 'Canada',childIds: []},15: {id: 15,title: 'Jamaica',childIds: []},16: {id: 16,title: 'Mexico',childIds: []},17: {id: 17,title: 'Trinidad and Tobago',childIds: []},18: {id: 18,title: 'Venezuela',childIds: []},19: {id: 19,title: 'Asia',childIds: [20, 21, 22, 23, 24, 25], },20: {id: 20,title: 'China',childIds: []},21: {id: 21,title: 'India',childIds: []},22: {id: 22,title: 'Singapore',childIds: []},23: {id: 23,title: 'South Korea',childIds: []},24: {id: 24,title: 'Thailand',childIds: []},25: {id: 25,title: 'Vietnam',childIds: []},26: {id: 26,title: 'Europe',childIds: [27, 28, 29, 30, 31, 32, 33], },27: {id: 27,title: 'Croatia',childIds: []},28: {id: 28,title: 'France',childIds: []},29: {id: 29,title: 'Germany',childIds: []},30: {id: 30,title: 'Italy',childIds: []},31: {id: 31,title: 'Portugal',childIds: []},32: {id: 32,title: 'Spain',childIds: []},33: {id: 33,title: 'Turkey',childIds: []},34: {id: 34,title: 'Oceania',childIds: [35, 36, 37, 38, 39, 40, 41], },35: {id: 35,title: 'Australia',childIds: []},36: {id: 36,title: 'Bora Bora (French Polynesia)',childIds: []},37: {id: 37,title: 'Easter Island (Chile)',childIds: []},38: {id: 38,title: 'Fiji',childIds: []},39: {id: 39,title: 'Hawaii (the USA)',childIds: []},40: {id: 40,title: 'New Zealand',childIds: []},41: {id: 41,title: 'Vanuatu',childIds: []},42: {id: 42,title: 'Moon',childIds: [43, 44, 45]},43: {id: 43,title: 'Rheita',childIds: []},44: {id: 44,title: 'Piccolomini',childIds: []},45: {id: 45,title: 'Tycho',childIds: []},46: {id: 46,title: 'Mars',childIds: [47, 48]},47: {id: 47,title: 'Corn Town',childIds: []},48: {id: 48,title: 'Green Hill',childIds: []}
};export default function TravelPlan() {const [plan, setPlan] = useState(initialTravelPlan);function handleComplete(parentId, childId) {const parent = plan[parentId];// 創建一個其父級地點的新版本// 但不包括子級 ID。const nextParent = {...parent,childIds: parent.childIds.filter(id => id !== childId)};// 更新根 state 對象...setPlan({...plan,// ...以便它擁有更新的父級。[parentId]: nextParent});}const root = plan[0];const planetIds = root.childIds;return (<><h2>Places to visit</h2><ol>{planetIds.map(id => (<PlaceTreekey={id}id={id}parentId={0}placesById={plan}onComplete={handleComplete}/>))}</ol></>);
}function PlaceTree({ id, parentId, placesById, onComplete }) {const place = placesById[id];const childIds = place.childIds;return (<li>{place.title}<button onClick={() => {onComplete(parentId, id);}}>Complete</button>{childIds.length > 0 &&<ol>{childIds.map(childId => (<PlaceTreekey={childId}id={childId}parentId={id}placesById={placesById}onComplete={onComplete}/>))}</ol>}</li>);
}
3. 在組件間共享狀態
有時候會存在兩個組件的狀態始終同步更改。要實現這一點,可以將相關 state 從這兩個組件上移除,并把 state 放到它們的公共父級,再通過 props 將 state 傳遞給這兩個組件。這被稱為“狀態提升”。
還是直接看例子:
import { useState } from 'react';function Panel({ title, children }) {const [isActive, setIsActive] = useState(false);return (<section className="panel"><h3>{title}</h3>{isActive ? (<p>{children}</p>) : (<button onClick={() => setIsActive(true)}>顯示</button>)}</section>);
}export default function Accordion() {return (<><h2>哈薩克斯坦,阿拉木圖</h2><Panel title="關于">阿拉木圖人口約200萬,是哈薩克斯坦最大的城市。它在 1929 年到 1997 年間都是首都。</Panel><Panel title="詞源">這個名字來自于 <span lang="kk-KZ">алма</span>,哈薩克語中“蘋果”的意思,經常被翻譯成“蘋果之鄉”。事實上,阿拉木圖的周邊地區被認為是蘋果的發源地,<i lang="la">Malus sieversii</i> 被認為是現今蘋果的祖先。</Panel></>);
}
這段代碼本身是沒有任何問題的,點擊展開會打開當前的Panel。但是在實際的開發過程中,我們經常會遇到這樣的需求:希望一次只能展開一個Panel,點擊某個展開其余的內容就會自動收起。因為兩個Panel之間的組件是相互不影響的,為了實現這個功能,最直接的方式就是將這個控制是否展開的state放到父組件中,由父組件傳入props的方式來控制。
可以分成三步來進行代碼改造:
- 從子組件中 移除 state 。
- 從父組件 傳遞 硬編碼數據。
- 為共同的父組件添加 state ,并將其與事件處理函數一起向下傳遞。
從子組件中 移除 state
簡單一句話,將子組件的state聲明刪除,isActive
改成從props傳入。這樣就可以通過父組件中傳入的isActive控制Panel組件是否展開
從公共父組件傳遞硬編碼數據
也是簡單一句話,找到公共的父組件,把isActive
硬編碼成true
或者false
,傳入到Panel組件中,看看是否生效
為公共父組件添加狀態
把硬編碼改成狀態。狀態提升通常會改變原狀態的數據存儲類型。原本isActive
是個布爾值,但請記住需求是實現每次只能打開一個Panel,也就意味著需要記錄當前打開的panel是哪個,實現方式其實也很簡單,直接看代碼吧
import { useState } from 'react';export default function Accordion() {
// 當 activeIndex 為 0 時,激活第一個面板,為 1 時,激活第二個面板const [activeIndex, setActiveIndex] = useState(0);// 在任意一個 Panel 中點擊“顯示”按鈕都需要更改 Accordion 中的激活索引值// Accordion 組件需要顯式允許 Panel 組件通過 將事件處理程序作為 prop 向下傳遞 來更改其狀態,也就是這個onShow方法const onShow = (index)=>setActiveIndex(index)return (<><h2>哈薩克斯坦,阿拉木圖</h2><Paneltitle="關于"isActive={activeIndex === 0}onShow={() =>onShow(0)}>阿拉木圖人口約200萬,是哈薩克斯坦最大的城市。它在 1929 年到 1997 年間都是首都。</Panel><Paneltitle="詞源"isActive={activeIndex === 1}onShow={() => onShow(1)}>這個名字來自于 <span lang="kk-KZ">алма</span>,哈薩克語中“蘋果”的意思,經常被翻譯成“蘋果之鄉”。事實上,阿拉木圖的周邊地區被認為是蘋果的發源地,<i lang="la">Malus sieversii</i> 被認為是現今蘋果的祖先。</Panel></>);
}function Panel({title,children,isActive,onShow
}) {return (<section className="panel"><h3>{title}</h3>{isActive ? (<p>{children}</p>) : (<button onClick={onShow}>顯示</button>)}</section>);
}
如果還是有點不太明白的可以看看這個圖解:
受控組件和非受控組件
通常把包含“不受控制”狀態的組件稱為“非受控組件”,例如,最開始帶有 isActive 狀態變量的 Panel 組件就是不受控制的,因為其父組件無法控制面板的激活狀態
當組件中的重要信息是由 props 而不是其自身狀態驅動時,就可以認為該組件是“受控組件”。最后帶有 isActive 屬性的 Panel 組件是由 Accordion 組件控制的
每個狀態都對應唯一的數據源
在 React 應用中,很多組件都有自己的狀態。有些組件的狀態只和自己有關系,例如輸入框。有些組件的狀態則是從上層上層再上層傳入的,例如,客戶端路由庫也是通過將當前路由存儲在 React 狀態中,利用 props 將狀態層層傳遞下去來實現的。
對于每個獨特的狀態,都應該存在且只存在于一個指定的組件中作為 state。這一原則也被稱為擁有 “可信單一數據源”。它并不意味著所有狀態都存在一個地方——對每個狀態來說,都需要一個特定的組件來保存這些狀態信息。你應該 將狀態提升 到公共父級,或 將狀態傳遞 到需要它的子級中,而不是在組件之間復制共享的狀態
可信單一數據源: 在系統中,某類數據只在一個地方進行維護和存儲,這個地方被視為該數據的唯一真實、可信來源。所有其他使用該數據的地方都必須從這個數據源中讀取,而不能自行復制或維護一份副本。
4. 對 state 進行保留和重置
各個組件的 state 是各自獨立的。根據組件在 UI 樹中的位置,React 可以跟蹤哪些 state 屬于哪個組件。在重新渲染過程中可以控制何時對 state 進行保留和重置。
狀態與渲染樹中的位置相關
狀態是由 React 保存的。React 通過組件在渲染樹中的位置將它保存的每個狀態與正確的組件關聯起來。
import { useState } from 'react';export default function App() {return (<div><Counter /><Counter /></div>);
}function Counter() {const [score, setScore] = useState(0);const [hover, setHover] = useState(false);let className = 'counter';if (hover) {className += ' hover';}return (<divclassName={className}onPointerEnter={() => setHover(true)}onPointerLeave={() => setHover(false)}><h1>{score}</h1><button onClick={() => setScore(score + 1)}>加一</button></div>);
}
上面的代碼渲染了兩個Counter組件,下面是它們完整的樹形結構。這是兩個獨立的 counter,因為它們在樹中被渲染在了各自的位置。也就是說,在這棵樹中,它們每一個組件都是有自己的位置的。所以操作其中一個Counter,并不會影響到另外的Counter。
只有當在樹中相同的位置渲染相同的組件時,React 才會一直保留著組件的 state。
我們再來看下面這段代碼:
import { useState } from 'react';export default function App() {const [showB, setShowB] = useState(true);return (<div><Counter />{showB && <Counter />} <label><inputtype="checkbox"checked={showB}onChange={e => {setShowB(e.target.checked)}}/>渲染第二個計數器</label></div>);
}function Counter() {const [score, setScore] = useState(0);const [hover, setHover] = useState(false);let className = 'counter';if (hover) {className += ' hover';}return (<divclassName={className}onPointerEnter={() => setHover(true)}onPointerLeave={() => setHover(false)}><h1>{score}</h1><button onClick={() => setScore(score + 1)}>加一</button></div>);
}
在渲染出來的結果里,先點擊第二個Counter,然后取消復選框的勾選,再重新勾選,你會發現第二個Counter中的值被清空了,也就是state回到了初始狀態。這是因為React在移除一個組件時,也會銷毀它的state
在React的機制里,只要一個組件還被渲染在 UI 樹的相同位置,React 就會保留它的 state。 如果它被移除,或者一個不同的組件被渲染在相同的位置,那么 React 就會丟掉它的 state。
相同位置的相同組件會使得 state 被保留下來
還是Counter的例子,先看代碼
import { useState } from 'react';export default function App() {const [isFancy, setIsFancy] = useState(false);return (<div>{isFancy ? (<Counter isFancy={true} /> ) : (<Counter isFancy={false} /> )}<label><inputtype="checkbox"checked={isFancy}onChange={e => {setIsFancy(e.target.checked)}}/>使用好看的樣式</label></div>);
}function Counter({ isFancy }) {const [score, setScore] = useState(0);const [hover, setHover] = useState(false);let className = 'counter';if (hover) {className += ' hover';}if (isFancy) {className += ' fancy';}return (<divclassName={className}onPointerEnter={() => setHover(true)}onPointerLeave={() => setHover(false)}><h1>{score}</h1><button onClick={() => setScore(score + 1)}>加一</button></div>);
}
執行這段代碼會發生什么?跑完之后會很神奇的發現,復選框勾選與否并不會重置state中的值。這跟想象中的不太一樣,看上去這不也是兩個Counter么?(這是我之前沒想到的,果然常看常新)
這個代碼和之前的區別在于,在DOM中通過if來渲染Counter,這意味著不管渲染的是if還是else中的代碼,它們都是根組件 App 返回的 div 的第一個子組件。位于相同位置的相同組件,對 React 來說,就是同一個計數器。
對 React 來說重要的是組件在 UI 樹中的位置,而不是在 JSX 中的位置!
相同位置的不同組件會使 state 重置
之前的例子在相同位置渲染的是同一個組件,現在來看看渲染不同的組件會發生什么
import { useState } from 'react';export default function App() {const [isPaused, setIsPaused] = useState(false);return (<div>{isPaused ? (<p>待會見!</p> ) : (<Counter /> )}<label><inputtype="checkbox"checked={isPaused}onChange={e => {setIsPaused(e.target.checked)}}/>休息一下</label></div>);
}function Counter() {const [score, setScore] = useState(0);const [hover, setHover] = useState(false);let className = 'counter';if (hover) {className += ' hover';}return (<divclassName={className}onPointerEnter={() => setHover(true)}onPointerLeave={() => setHover(false)}><h1>{score}</h1><button onClick={() => setScore(score + 1)}>加一</button></div>);
}
這次雖然也在同一個位置上,但是移除Counter后重新添加,會發現Counter中的state被重置了。這是因為React的機制中,相同位置的不同組件會被重置,不僅如此,當你在相同位置渲染不同的組件時,組件的整個子樹都會被重置。圖例如下:
如果你想在重新渲染時保留 state,幾次渲染中的樹形結構就應該相互“匹配”。
// 這里只寫關鍵的Counter代碼
// 此時不會被重置
{isPaused ? (<Counter />
) : (<Counter />
)}
// 此時也不會被重置,因為樹結構是匹配的,都是div > Counter
{isPaused ? (<div> <Counter /></div>) : (<div><Counter /> </div>)}
// 會被重置,因為樹結構不一致了,一個是div > Counter,一個是section > Counter
{isPaused ? (<div> <Counter /></div>
) : (<section><Counter /> </section>
)}
再來看一個例子
import { useState } from 'react';export default function MyComponent() {const [counter, setCounter] = useState(0);function MyTextField() {const [text, setText] = useState('');return (<inputvalue={text}onChange={e => setText(e.target.value)}/>);}return (<><MyTextField /><button onClick={() => {setCounter(counter + 1)}}>點擊了 {counter} 次</button></>);
}
每次點擊按鈕,輸入框的 state 都會消失!這是因為每次 MyComponent 渲染時都會創建一個 不同 的 MyTextField 函數。在相同位置渲染的是 不同 的組件,所以 React 將其下所有的 state 都重置了。這樣會導致 bug 以及性能問題。為了避免這個問題, 永遠要將組件定義在最上層并且不要把它們的定義嵌套起來。
在相同位置重置 state
在實際編碼過程中,我們更多的是需要實現在相同位置重置state,上面的例子里其實已經給了一種實現方案,就是渲染一個不匹配的樹結構,但是這個方案會額外增加DOM結構,看上去不優雅,接下來有兩個方法可以更好的實現這個功能
將組件渲染在不同位置
這個方法其實也是上面說過的,只要不在同一個位置渲染兩個組件,不就不會相互干擾了嘛,這告訴我們三目運算符或者if條件判斷固然好用優雅,但也可能會出問題。
import { useState } from 'react';export default function Scoreboard() {const [isPlayerA, setIsPlayerA] = useState(true);return (<div>{isPlayerA &&<Counter person="Taylor" />}{!isPlayerA &&<Counter person="Sarah" />}<button onClick={() => {setIsPlayerA(!isPlayerA);}}>下一位玩家!</button></div>);
}function Counter({ person }) {const [score, setScore] = useState(0);const [hover, setHover] = useState(false);let className = 'counter';if (hover) {className += ' hover';}return (<divclassName={className}onPointerEnter={() => setHover(true)}onPointerLeave={() => setHover(false)}><h1>{person} 的分數:{score}</h1><button onClick={() => setScore(score + 1)}>加一</button></div>);
}
圖解如下:
使用key來重置state
上一次見到key這個字段還是在渲染列表里,它被用來唯一標識出各個數組項,其實可以使用 key 來讓 React 區分任何組件,如果組件沒有指定key,React會默認按照父組件內部的順序來排列,可以類比一下數組的index。但key的出現會告訴React,這個組件有一個特殊的標識符,類比于數組的唯一id,這樣無論它出現在什么地方,React都會根據key來判斷它是哪個組件,而不是通過順序。
import { useState } from 'react';export default function Scoreboard() {const [isPlayerA, setIsPlayerA] = useState(true);// 有了key之后React就會識別這是Taylor的還是Sarah的Counter// 而不是這是第一個還是第二個Counterreturn (<div>{isPlayerA ? (<Counter key="Taylor" person="Taylor" />) : (<Counter key="Sarah" person="Sarah" />)}<button onClick={() => {setIsPlayerA(!isPlayerA);}}>下一位玩家!</button></div>);
}
指定一個 key 能夠讓 React 將 key 本身而非它們在父組件中的順序作為位置的一部分。這就是為什么盡管你用 JSX 將組件渲染在相同位置,但在 React 看來它們是兩個不同的計數器。因此它們永遠都不會共享 state。請記住 key 不是全局唯一的。它們只能指定 父組件內部 的順序。
利用key來重置state這個用法,在重置表單的場景中十分有用
假設一個聊天應用,切換用戶聊天窗口時,聊天窗口用的是同一個chat組件,這時候需要做到不同用戶聊天信息互不干擾,這時候key的使用就很關鍵。
但是,在真正的聊天應用中,你可能會想在用戶再次選擇前一個收件人時恢復輸入 state。對于一個不可見的組件,有幾種方法可以讓它的 state “活下去”:
- 與其只渲染現在這一個聊天,可以把 所有 聊天都渲染出來,但用 CSS 把其他聊天隱藏起來。這些聊天就不會從樹中被移除了,所以它們的內部 state 會被保留下來。這種解決方法對于簡單 UI 非常有效。但如果要隱藏的樹形結構很大且包含了大量的 DOM 節點,那么性能就會變得很差。
- 進行 狀態提升 并在父組件中保存每個收件人的草稿消息。這樣即使子組件被移除了也無所謂,因為保留重要信息的是父組件。這是最常見的解決方法。
- 除了 React 的 state,你也可以使用其他數據源。例如,也許你希望即使用戶不小心關閉頁面也可以保存一份信息草稿。要實現這一點,讓 Chat 組件通過讀取 localStorage 對其 state 進行初始化,并把草稿保存在那里。
無論采取哪種策略,與 Alice 的聊天在概念上都不同于 與 Bob 的聊天,因此根據當前收件人為 樹指定一個 key 是合理的。
狀態管理內容實在太多,剩下的我們下次再來~