記一次 React 輸入框文本閃爍的問(wèn)題
遇到什么問(wèn)題?
在開(kāi)發(fā) React 項(xiàng)目時(shí),遇到了一個(gè)經(jīng)典的輸入框 內(nèi)容閃爍問(wèn)題。
具體表現(xiàn)為:當(dāng)在輸入框中連續(xù)輸入內(nèi)容時(shí),例如原本內(nèi)容是 1,接著鍵盤(pán)敲擊 2,輸入框的內(nèi)容會(huì)短暫地變成 12,然后詭異地回退成 1,最后再跳回到正確的 12。
產(chǎn)生的原因
這是典型的 “狀態(tài)撕裂” (State Tearing) 或者說(shuō)是由 雙重渲染 (Double-Render) 引起的視覺(jué) Bug。根本原因是:濫用了 useEffect 來(lái)同步派生狀態(tài) (Derived State)。
代碼特征
遇到此類(lèi)問(wèn)題的代碼結(jié)構(gòu)通常如下:
以下為代碼簡(jiǎn)寫(xiě)
const [formData, setFormData] = useState({ itemList: [] });
// ? 錯(cuò)誤的做法:將從頂層 formData 派生出的數(shù)據(jù)存放在獨(dú)立的 state 中
const [derivedList, setDerivedList] = useState([]);
// ? 錯(cuò)誤的做法:使用 useEffect 來(lái)監(jiān)聽(tīng)更新并同步數(shù)據(jù)
useEffect(() => {
const newList = formData.itemList.map(item => ({ ...item, ... })); // 費(fèi)時(shí)的處理
setDerivedList(newList);
}, [formData.itemList]);
const handleChange = (newValue) => {
// 觸發(fā)頂層 state 更新
setFormData({ itemList: newValue });
}
// 渲染派生的列表
return derivedList.map(item => <Textarea value={item.value} onChange={handleChange} />);
完整的時(shí)間線(xiàn)
為什么會(huì)看到 12 -> 1 -> 12?
-
輸入瞬間(
12): 用戶(hù)按下2,底層的原生輸入控件瞬間接收到輸入并顯示12。同時(shí)觸發(fā)了onChange,調(diào)用setFormData更新了頂層狀態(tài)。 -
第一輪渲染(回退為
1): React 發(fā)現(xiàn)狀態(tài)更新,開(kāi)始執(zhí)行第一輪渲染。重點(diǎn)來(lái)了!此時(shí)useEffect還未執(zhí)行,所以依賴(lài)它更新的derivedList里的數(shù)據(jù)依然是舊的1。React 拿著舊的值去比對(duì),強(qiáng)行把輸入框的值拉回了舊狀態(tài)1。 -
useEffect介入與第二輪渲染(恢復(fù)為12): 第一輪渲染剛剛把舊界面畫(huà)到屏幕上之后,React 終于開(kāi)始執(zhí)行useEffect。此時(shí)發(fā)現(xiàn)formData變了,于是重新計(jì)算derivedList,并調(diào)用setDerivedList觸發(fā)了第二輪渲染。這次derivedList中的數(shù)據(jù)終于是正確的12,界面再次更新。
這種 接收輸入 -> 渲染舊值 -> useEffect執(zhí)行 -> 渲染新值 的兩輪渲染死循環(huán),就是造成問(wèn)題的原因。
如何解決
如果在組件中,一個(gè)值可以從現(xiàn)有的 props 或 state 中直接推導(dǎo)出(計(jì)算出),就不要把它單獨(dú)存進(jìn)一個(gè) state 里,也絕對(duì)不要用 useEffect 來(lái)同步它。
正確的做法是直接在渲染函數(shù)中計(jì)算,或者為了性能使用 useMemo:
const [formData, setFormData] = useState({ itemList: [] });
// ? 正確的做法:在渲染過(guò)程中同步計(jì)算派生數(shù)據(jù)
const derivedList = useMemo(() => {
return formData.itemList.map(item => ({ ...item, ... }));
}, [formData.itemList]);
const handleChange = (newValue) => {
setFormData({ itemList: newValue });
}
return derivedList.map(item => <Textarea value={item.value} onChange={handleChange} />);
為什么 useMemo 能完美解決問(wèn)題?
useMemo 和 useEffect 最大的區(qū)別在于它們的執(zhí)行時(shí)機(jī)。
-
useEffect是在虛擬 DOM 計(jì)算完成、并且真實(shí) DOM 也更新到屏幕之后才“事后”執(zhí)行的。 -
useMemo則是在組件函數(shù)的渲染過(guò)程之中執(zhí)行的(它是同步計(jì)算的)。
當(dāng)我們使用 useMemo 改造后,時(shí)間線(xiàn)變成了:
輸入 2 ?? 觸發(fā) setFormData ?? React 開(kāi)始本輪渲染 ?? 在計(jì)算虛擬 DOM 的途中,useMemo 立刻根據(jù)最新的 formData 同步算出了包含 12 的最新 derivedList ?? React 直接拿著這個(gè)最新的列表完成界面的更新。
整個(gè)計(jì)算和更新在同一個(gè)渲染周期內(nèi)完成,徹底消除了“拿著舊數(shù)據(jù)瞎更”的中間狀態(tài)。