React 最佳實踐之 “你可能不需要 Effect”
前言
本文思想來自 React 官方文檔 You Might Not Need an Effect,保熟,是我近幾天讀了 n 遍之後自己的理解,感覺受益匪淺,這裏小記一下跟大家分享。
曾經本小白 R 的水平一直停留在會用 React 寫業務,講究能跑就行的程度,最近嘗試學習一些關於 React 的最佳實踐,感興趣的朋友一起上車吧!!
useEffect 痛點概述
useEffect
的回調是異步宏任務,在 React 根據當前狀態更新視圖之後,下一輪事件循環裏纔會執行useEffect
的回調,一旦useEffect
回調的邏輯中存在狀態修改等操作,就會觸發渲染的重新執行(FC 函數體重新運行,渲染視圖),不光存在一定的性能損耗,而且因爲前後兩次渲染的數據不同,可能造成用戶視角下視圖的閃動,所以在開發過程中應該避免濫用useEffect
。
如何移除不必要的 Effect
-
對於渲染所需的數據,如果可以用組件內狀態(
props
、state
)轉換而來,轉換操作避免放在Effect
中,而應該直接放在 FC 函數體中。如果轉換計算的消耗比較大,可以用
useMemo
進行緩存。 -
對於一些用戶行爲引起數據變化,其後續的邏輯不應該放在
Effect
中,而是在事件處理函數中執行邏輯即可。比如點擊按鈕會使組件內
count
加一,我們希望count
變化後執行某些邏輯,那麼就沒必要把代碼寫成:function Counter() { const [count, setCount] = useState(0); function handleClick() { setCount(prev => prev + 1); } useEffect(() => { // count改變後的邏輯... }, [count]) // ... }
上面的 demo 大家肯定也看出來了,直接把
Effect
中的邏輯移動到事件處理函數中即可。
根據props
或state
來更新state
(類似於 vue 中的計算屬性)
如下Form
組件中fullName
由firstName
與lastName
計算(簡單拼接)而來,錯誤使用Effect
:
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 避免:多餘的 state 和不必要的 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
分析一下,按照上面的寫法,如果firstName
或者lastName
改變之後,首先根據新的firstName
與lastName
與舊的fullName
進行渲染,然後纔是useEffect
回調的執行,最後根據最新的fullName
再次渲染視圖。
我們要做的是儘可能把渲染的效果進行統一(同步fullName
與兩個組成 state 的新舊),並且減少渲染的次數:
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ 非常好:在渲染期間進行計算
const fullName = firstName + ' ' + lastName;
// ...
}
緩存昂貴的計算
基於上面的經驗,我們如果遇到比較複雜的計算邏輯,把它放在 FC 函數體中可能性能消耗較大,可以使用useMemo
進行緩存,如下,visibleTodos
這個數據由todos
與filter
兩個props
數據計算而得,並且計算消耗較大:
import { useMemo } from 'react';
function TodoList({ todos, filter }) {
// ✅ 除非 todos 或 filter 發生變化,否則不會重新執行 getFilteredTodos()
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}
當 props 變化時重置所有 state
比如一個ProfilePage
組件,它接收一個userId
代表當前正在操作的用戶,裏面有一個評論輸入框,用一個 state 來記錄輸入框中的內容。我們爲了防止切換用戶後,原用戶輸入的內容被當前的用戶發出這種誤操作,有必要在userId
改變時置空 state,包括ProfilePage
組件的所有子組件中的評論state
。
錯誤操作:
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 避免:當 prop 變化時,在 Effect 中重置 state
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
爲什麼避免上訴情況,本質還是避免Effect
的痛點,我們可以利用組件 **key
不同將會完全重新渲染 ** 的特點解決這個問題,只需要在父組件中給這個組件傳遞一個與props
同步的key
值即可:
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ 當 key 變化時,該組件內的 comment 或其他 state 會自動被重置
const [comment, setComment] = useState('');
// ...
}
當 prop 變化時調整部分 state
其實說白了還是上面的基於props
和state
來計算其它所需state
的邏輯,如下List
組件,當傳入的items
改變時希望同步selection
(被選中的數據),那麼我們直接在渲染階段計算所需內容就好了:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ 非常好:在渲染期間計算所需內容
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
在事件處理函數中共享邏輯
比如兩種用戶操作都可以修改某個數據,然後針對數據修改有相應的邏輯處理,這時候有一種錯誤(不好)的代碼邏輯:事件回調——> 修改 state——>state 修改觸發 Effect——>Effect 中執行後續邏輯。
我們不應該多此一舉的添加一個 Effect,這個 Effect 就類似於數據改變的監聽器一樣,完全是多餘的,我們只需要在數據改變之後接着寫後續的邏輯就好了!!
如下,用戶的購買與檢查兩種行爲都可以觸發addToCart
的邏輯,進而修改product
這個數據,然後可能觸發後續邏輯showNotification
:
function ProductPage({ product, addToCart }) {
// 🔴 避免:在 Effect 中處理屬於事件特定的邏輯
useEffect(() => {
if (product.isInCart) {
showNotification(`已添加 ${product.name} 進購物車!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
我們把Effect
中的邏輯提取出來放到事件處理函數中就好了:
function ProductPage({ product, addToCart }) {
// ✅ 非常好:事件特定的邏輯在事件處理函數中處理
function buyProduct() {
addToCart(product);
showNotification(`已添加 ${product.name} 進購物車!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
發送 POST 請求
也有一些典型的需要使用Effect
的情景,比如有些數據、邏輯是頁面初次渲染,因爲組件的呈現而需要的,而不是後續交互觸發的,比如異步數據的獲取,我們就可以寫一個依賴數組爲[]
的Effect
。
如下Form
組件,頁面加載之際就需要發送一個分析請求,這個行爲與後續交互無關,是因爲頁面的呈現就需要執行的邏輯,所以放在Effect
中,而表單提交的行爲觸發的網絡請求,我們直接放在事件回調中即可。
切忌再多寫一個state
和一個Effect
,然後把一部分邏輯寫在Effect
裏面,比如下面handleSubmit
中修改firstName
與lastName
,然後多寫一個Effect
監聽這兩個數據發送網絡請求,這就是上面我們一直糾正的問題,我就不放代碼了。
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ 非常好:這個邏輯應該在組件顯示時執行
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// ✅ 非常好:事件特定的邏輯在事件處理函數中處理
post('/api/register', { firstName, lastName });
}
// ...
}
鏈式計算
避免通過 state 將Effect
變成鏈式調用,如下Game
組件中,類似於一個卡牌合成遊戲,card
改變可能觸發goldCardCount
的改變,goldCardCount
的改變可能觸發round
的改變,最終round
的改變可能觸發isGameOver
的改變,試想如果某次card
改變,從而正好所有條件都依次滿足,最後isGameOver
改變,setCard
→ 渲染 → setGoldCardCount
→ 渲染 → setRound
→ 渲染 → setIsGameOver
→ 渲染,有三次不必要的重新渲染!!
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 避免:鏈接多個 Effect 僅僅爲了相互觸發調整 state
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('遊戲結束!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('遊戲已經結束了。');
} else {
setCard(nextCard);
}
}
// ...
因爲Game
中所有state
改變之後的行爲都是可以預測的,也就是說某個卡牌數據變了,後續要不要繼續合成更高級的卡牌,或者遊戲結束等等這些邏輯都是完全明確的,所以直接把數據修改的邏輯放在同一個事件回調中即可,然後根據入參判斷是哪種卡牌然後進行後續的操作即可:
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ 儘可能在渲染期間進行計算
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('遊戲已經結束了。');
}
// ✅ 在事件處理函數中計算剩下的所有 state
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('遊戲結束!');
}
}
}
}
// ...
初始化應用
因爲 React 嚴格模式 & 開發模式下:
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
組件的渲染會執行兩次(掛載 + 卸載 + 掛載),包括依賴爲[]
的Effect
同樣會執行兩次,這是 React 作者爲了提醒開發者 cleanup 有意而設計之的(比如一些需要手動清除的原生事件如果沒寫清除邏輯,事件觸發時就會執行兩次回調從而引起注意),所以執行兩次的邏輯可能會造成一些邏輯問題,我們可以用一個全局變量來保證即使在 React 嚴格模式 & 開發模式下也只執行一次Effect
的回調:
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 只在每次應用加載時執行一次
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
通知父組件有關 state 變化的信息
最佳實踐的本質還是我們剛剛一直強調的:減少 Effect 的使用,可以歸併到回調函數中的邏輯就不要放在Effect
中。
如下,假設我們正在編寫一個有具有內部 state isOn
的 Toggle
組件,該 state 可以是 true
或 false
,希望在 Toggle
的 state 變化時通知父組件。
錯誤示範:
(事件回調只負責修改 state, Effect 中執行通知父組件的邏輯)
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 避免:onChange 處理函數執行的時間太晚了
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
刪除Effect
:
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
// ✅ 事件回調中直接通知父組件即可
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
// ...
}
將數據傳遞給父組件
避免在 Effect
中傳遞數據給父組件,這樣會造成數據流的混亂。我們應該考慮把獲取數據的邏輯提取到父組件中,然後通過props
將數據傳遞給子組件:
錯誤示範:
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 避免:在 Effect 中傳遞數據給父組件
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
理想情況:
function Parent() {
const data = useSomeAPI();
// ...
// ✅ 非常好:向子組件傳遞數據
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
訂閱外部 store
說白了就是 React 給我們提供了一個專門的 hook 用來綁定外部數據(所謂外部數據,就是一些環境運行環境裏的數據,比如window.xxx
)
我們曾經常用的做法是在Effect
中編寫事件監聽的邏輯:
function useOnlineStatus() {
// 不理想:在 Effect 中手動訂閱 store
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
這裏可以換成useSyncExternalStore
這個 hook,關於這個 hook,還是有一點理解成本的,我的基於 useSyncExternalStore 封裝一個自己的 React 狀態管理模型吧這篇文章裏有詳細的解釋,下面直接放綁定外部數據最佳實踐的代碼了:
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ 非常好:用內置的 Hook 訂閱外部 store
return useSyncExternalStore(
subscribe, // 只要傳遞的是同一個函數,React 不會重新訂閱
() => navigator.onLine, // 如何在客戶端獲取值
() => true // 如何在服務端獲取值
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
獲取異步數據
比如組件內根據props
參數query
與一個組件內狀態page
來實時獲取異步數據,下面組件獲取異步數據的邏輯之所以沒有寫在事件回調中,是因爲首屏即使用戶沒有觸發數據修改,我們也需要主動發出數據請求(類似於首屏數據獲取),總之因爲業務場景需求吧,我們把請求邏輯放在一個Effect
中:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 避免:沒有清除邏輯的獲取數據
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
上面代碼的問題在於,由於每次網絡請求的不可預測性,我們不能保證請求結果是根據當前最新的組件狀態獲取的,也即是所謂的**「競態條件:兩個不同的請求 “相互競爭”,並以與你預期不符的順序返回。」**
「所以可以給我們的Effect
添加一個清理函數,來忽略較早的返回結果,」 如下,說白了用一個變量ignore
來控制這個Effect
回調的 "有效性",只要是執行了下一個Effect
回調,上一個Effect
裏的ignore
置反,也就是讓回調的核心邏輯失效,保證了只有最後執行的Effect
回調是 “有效” 的:
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 說白了用一個ignore變量來控制這個Effect回調的"有效性",
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/brQYKlQGoSRU8rvvGL1siA