背景: 話不多說,繼續學習,現在是Effect時間。
前期回顧:
重學React(一):描述UI
重學React(二):添加交互
重學React(三):狀態管理
重學React(四):狀態管理二
重學React(五):脫圍機制一
學習內容:
React官網教程:https://zh-hans.react.dev/learn/synchronizing-with-effects
其他輔助資料(看到再補充)
補充說明:這次學習更多的是以學習筆記的形式記錄,看到哪記到哪
Effect這部分內容很豐富,需要仔細多品品,在實際代碼中,useEffect基本上是使用頻率第二高的Hook了(第一肯定是useState),但在使用過程中經常會遇到各種各樣的問題,而且接觸了很多人,他們對Effect的理解也不是很到位。這部分內容看下來收獲還是超級大的,它通過很多實際的例子將它的原理以及使用方法解析得很透徹,最大的感受就是現在代碼里很多問題都是沒有正確使用Effect導致的。這篇總結筆記的順序會按照我理解的方式來記錄,可能會和原文順序不一致,建議多看幾遍,有時間可以多看看原文。
使用 Effect 進行同步
什么是 Effect
最重要的一個概念是,什么是Effect?
先復習一下之前說過的React的兩種邏輯類型:渲染代碼以及事件處理程序。
- 渲染代碼:位于組件的頂層。在這里可以處理 props 和 state,對它們進行轉換,并返回希望在頁面上顯示的 JSX。它必須是純粹的,只關注計算結果。
- 事件處理程序: 組件內部的嵌套函數,它們不光進行計算, 還會執行一些操作。事件處理程序包含由特定用戶操作(例如按鈕點擊或輸入)引起的“副作用”(它們改變了程序的狀態)。
在這個基礎上,其實還缺了一塊,要如何處理在渲染過程中的操作。考慮一個聊天室的例子,當用戶進入聊天室時,頁面上顯示時必須連接到聊天服務器。連接到服務器并不是純粹的計算(它是一個副作用),因此它不能在渲染期間發生。但此時也沒有點擊某個按鈕或者其他用戶行為來主動觸發。
這時候就需要Effect出場了。
Effect 允許指定由渲染自身,而不是特定事件引起的副作用。 建立服務器連接是一個 Effect,因為無論哪種交互致使組件出現,它都應該發生。Effect 在 提交 結束后、頁面更新后運行。此時是將 React 組件與外部系統(如網絡或第三方庫)同步的最佳時機。
在本文此處和后續文本中,大寫的 Effect 是 React 中的專有定義——由渲染引起的副作用。至于更廣泛的編程概念(任何改變程序狀態或外部系統的行為),我們則使用“副作用(side effect)” 來指代。
先來學習一下如何編寫一個Effect,主要分為以下三個步驟:
- 聲明 Effect。 通常 Effect 會在每次 提交 后運行。
- 指定 Effect 依賴。 大多數 Effect 應該按需運行,而不是在每次渲染后都運行。例如,淡入動畫應該只在組件出現時觸發。連接和斷開服務器的操作只應在組件出現和消失時,或者切換聊天室時執行。你將通過指定 依賴項 來學習如何控制這一點。
- 必要時添加清理操作。 一些 Effect 需要指定如何停止、撤銷,或者清除它們所執行的操作。例如,“連接”需要“斷開”,“訂閱”需要“退訂”,而“獲取數據”需要“取消”或者“忽略”。你將學習如何通過返回一個 清理函數 來實現這些。
來看一個最簡單的聊天室例子:
// App.js
// 首先和其他hook一樣,從react中引入useEffect
import { useState, useEffect } from 'react';
import { createConnection } from './chat.js';export default function ChatRoom() {
// 在組件頂部調用,使用useEffect,它包括一個函數和一個依賴項useEffect(() => {// 組件渲染時React會先更新頁面,然后再執行這個函數中的代碼const connection = createConnection();connection.connect();// 同時還包括一個清理函數,用于清除連接// Effect會在組件卸載(被移除)時最后一次調用這個清理函數return () => connection.disconnect();}, // 代碼不能無限制的每次渲染都執行,因此會添加一個依賴項,React會監聽這個依賴項是否改變(通過objtct.is),如果依賴項沒發生改變,就不進入代碼執行//數組里可以有多個依賴項// 依賴項為空表示只有在組件掛載(首次出現)后執行[]);return <h1>歡迎來到聊天室!</h1>;
}// chat.js
export function createConnection() {// 真正的實現實際上會連接到服務器return {connect() {console.log('? 連接中……');},disconnect() {console.log('? 連接斷開。');}};
}
Effect的生命周期
Effect 與組件有不同的生命周期。組件可以掛載、更新或卸載。Effect 只能做兩件事:開始同步某些東西,然后停止同步它。很多人在聊到useEffect的時候都會將它和React之前的生命周期聯系在一起,但其實它更多的是描述了如何將外部系統與當前的 props 和 state 同步。
React組件都會有相同的生命周期:
- 當組件被添加到屏幕上時,它會進行組件的 掛載。
- 當組件接收到新的 props 或 state 時,通常是作為對交互的響應,它會進行組件的 更新。
- 當組件從屏幕上移除時,它會進行組件的 卸載。
為了更好的理解Effect所謂的“生命周期”,請看這個聊天室的例子
const serverUrl = 'https://localhost:1234';function ChatRoom({ roomId }) {
// Effect將組件連接到聊天服務器useEffect(() => {// Effect的主體部分,指定了如何開始同步const connection = createConnection(serverUrl, roomId);connection.connect();// 清理函數指定了如何 停止同步return () => {connection.disconnect();};// 依賴項里有roomId,表示遇到roomId更新時會觸發上一次的清理函數及下一次的同步}, [roomId]);// ...
}
直觀上,可能會認為當組件掛載時React會開始同步,當組件卸載時會停止同步,但事實卻不是這樣的,在組件保持掛載的同時,可能會出現多次的開始和停止同步,關鍵在roomId
假設roomId包含general, travel兩個值,用戶可以通過下拉列表選擇當前的聊天室,初始階段,用戶選擇進入general聊天室,UI顯示之后,React會怎么做呢?它將運行Effect來開始同步(記住,運行Effect是在UI更新后),連接到general聊天室,
接著,用戶從下拉列表里選擇了travel房間,React會再更新UI,這個時候用戶在界面中看到的是travel的聊天頁面,但此時Effect還沒運行,連接的還是general聊天室。
React接下來要做的就是先停止同步,調用Effect返回的清理函數,斷開與general的連接,然后運行在此渲染期間提供的Effect,這次,roomId是travel,所以它會開始同步,連接到travel聊天室。
最后,用戶切換去其他頁面,組件將被卸載,React將最后一次停止同步Effect,調用Effect返回的清理函數,斷開與travel的連接
從組件角度思考一下這個過程:
- ChatRoom 組件掛載,roomId 設置為 “general”
- ChatRoom 組件更新,roomId 設置為 “travel”
- ChatRoom 組件卸載
在組件生命周期的每個階段,Effect會執行不同的操作:
- Effect 連接到了 “general” 聊天室
- Effect 斷開了與 “general” 聊天室的連接,并連接到了 “travel” 聊天室
- Effect 斷開了與 “music” 聊天室的連接
我們再來看看這段代碼:
useEffect(() => {// Effect 連接到了通過 roomId 指定的聊天室...const connection = createConnection(serverUrl, roomId);connection.connect();return () => {// ...直到它斷開連接connection.disconnect();};}, [roomId]);
實際上,如果拋開組件生命周期的角度,Effect本質上就做了兩件事,開始同步以及停止同步,React會解決其余的問題,無論組件是掛載,更新還是卸載,這兩件事都不應該有影響。
Effect依賴項
還是聊天室的例子,我們知道依賴項的選擇決定著Effect的開始同步以及停止同步,當指定的所有依賴項的值與上一次渲染時完全相同,React會跳過重新運行該Effect
useEffect(() => {// 這里的代碼會在每次渲染后運行
});useEffect(() => {// 這里的代碼只會在組件掛載(首次出現)時運行// 空的 [] 依賴數組意味著這個 Effect 僅在組件掛載時觸發,并在卸載時停止同步
}, []);useEffect(() => {// 這里的代碼不但會在組件掛載時運行,而且當 a 或 b 的值自上次渲染后發生變化后也會運行
}, [a, b]);
React 如何知道需要重新進行 Effect 的同步
其實道理也很簡單,因為我們把roomId放在了依賴列表中。實際上,Effect 重新進行同步的主要原因是它所使用的某些數據發生了變化。
function ChatRoom({ roomId }) { // roomId 屬性可能會隨時間變化。useEffect(() => {const connection = createConnection(serverUrl, roomId); // 這個 Effect 讀取了 roomIdconnection.connect();return () => {connection.disconnect();};}, [roomId]); // 告訴 React 這個 Effect "依賴于" roomId
每次組件重新渲染之后,React都會檢查傳遞的依賴數組,按照之前說的,如果發現渲染之后依賴數組發生改變了,就會進行上一輪的停止以及新一輪的同步。
但是,不要僅僅因為某個邏輯需要和已經寫好的Effect一起運行,就將它添加到Effect中,比如接下來這個例子
function ChatRoom({ roomId }) {useEffect(() => {logVisit(roomId); // 想要在用戶訪問聊天室時發送一個分析事件const connection = createConnection(serverUrl, roomId);connection.connect();return () => {connection.disconnect();};}, [roomId]);// ...
}
在當前階段看起來似乎沒問題,這個分析事件確實也只依賴roomId一個依賴項。但如果,connection的過程中,serverUrl也變成一個依賴項,但logVisit并不需要它,那會發生什么?
function ChatRoom({ roomId }) {
// roomId發生改變,發送分析事件,重新連接
// serverUrl發生改變,發送分析事件,重新連接,因為React會執行整個同步過程
// 但實際上我們并不需要在serverUrl發生改變時發送分析事件useEffect(() => {logVisit(roomId); // 想要在用戶訪問聊天室時發送一個分析事件const connection = createConnection(serverUrl, roomId);connection.connect();return () => {connection.disconnect();};}, [serverUrl, roomId]);// ...
}
因此,為了避免維護起來的困難,代碼中的每個 Effect 應該代表一個獨立的同步過程,當刪除一個 Effect 不會影響另一個 Effect 的邏輯時,可以考慮將它們拆開
function ChatRoom({ roomId }) {useEffect(() => {logVisit(roomId);}, [roomId]);useEffect(() => {const connection = createConnection(serverUrl, roomId);// ...}, [roomId]);// ...
}
Effect 會“響應”于響應式值
依賴項需要填什么呢?一個同步函數里包含了許多內容,哪些內容需要放到依賴項里,哪些又是不必要的,請記住這個原則:**Effect 會“響應”于響應式值 **。
// 在這里例子中,serverUrl時一個常量,永遠不會因為重新渲染產生變化,因此把它放置在依賴項里就沒有任何意義。
// 依賴項只有在隨時間變化時才起作用
const serverUrl = 'https://localhost:1234';// roomId是組件內部聲明的props,它會在渲染過程中計算,并且參與React的數據流,是響應式的,因此在重新渲染時值可能會發生變化
// 比如從general變成了travel,監聽它就變得有意義
function ChatRoom({ roomId }) {useEffect(() => {const connection = createConnection(serverUrl, roomId);connection.connect();return () => {connection.disconnect();};}, [roomId]);// ...
}function ChatRoom({ roomId }) { // Props 隨時間變化
// 當serverUrl變成一個state后,它也變成響應式了,那么它就必須包含在依賴項中const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // State 可能隨時間變化useEffect(() => {const connection = createConnection(serverUrl, roomId); // Effect 讀取 props 和 stateconnection.connect();return () => {connection.disconnect();};}, [roomId, serverUrl]); // 因此,你告訴 React 這個 Effect "依賴于" props 和 state// ...
}
在組件主體中聲明的所有變量都是響應式的
Props 和 state 并不是唯一的響應式值。從它們計算出的值也是響應式的。如果 props 或 state 發生變化,組件將重新渲染,從中計算出的值也會隨之改變。這就是為什么 Effect 使用的組件主體中的所有變量都應該在依賴列表中。
那什么不能作為依賴項?
- 全局變量或可變值
全局變量的例子之前已經看到了,可變值是一些在React渲染數據流之外任何時間發生變化的值,比如location.pathname,更改它并不會觸發組件的重新渲染,因此就算在依賴項里指定了,React 也無法知道在其更改時重新同步 Effect。應該使用 useSyncExternalStore(一個之前沒接觸過的hook出現了!!!) 來讀取和訂閱外部可變值 - ref.current 或從中讀取的值也不能作為依賴項
useRef 返回的 ref 對象本身可以作為依賴項,但其 current 屬性是有意可變的。它允許 跟蹤某些值而不觸發重新渲染。但由于更改它不會觸發重新渲染,它不是響應式值,React 不會知道在其更改時重新運行 Effect。 - useState 返回的 set 函數
函數具有穩定的標識,它們保證在重新渲染時不會改變。通常情況下就算作為依賴項也會被忽略 - 對象和函數作為依賴項需要十分謹慎
在 JavaScript 中,每個新創建的對象和函數都被認為與其他所有對象和函數不同。即使他們的值相同。
function ChatRoom({ roomId }) {const [message, setMessage] = useState('');// options在組件中聲明,因此它是響應式的// 所以理論上在Effect中需要把它放到依賴項中// 每次重新渲染組件時,比如message更新,都會觸發整個組件的代碼從頭開始運行// 會從頭開始創建一個新的options對象,就算options里的serverUrl和roomId和之前的值一樣,options和以前也不是同一個了。const options = {serverUrl: serverUrl,roomId: roomId};useEffect(() => {// 所以只要組件重新渲染,都會觸發同步函數,導致很多不必要的渲染const connection = createConnection(options);connection.connect();return () => connection.disconnect();}, [options]);return (<><h1>歡迎來到 {roomId} 房間!</h1><input value={message} onChange={e => setMessage(e.target.value)} /></>);
}
如何避免將對象和函數作為 Effect 的依賴?有以下三種方法:
- 將靜態對象和函數移出組件
如果對象中的內容不是變量,可以將它移出組件
const options = {serverUrl: 'https://localhost:1234',roomId: '音樂'
};
// 函數也是一樣
function createOptions() {return {serverUrl: 'https://localhost:1234',roomId: '音樂'};
}function ChatRoom() {const [message, setMessage] = useState('');useEffect(() => {// 如果是函數就會是這樣,也是不需要添加到依賴項中// const options = createOptions();const connection = createConnection(options);connection.connect();return () => connection.disconnect();// 因為它是固定值,所以連依賴項都可以不加}, []); // ? 所有依賴已聲明
- 將動態對象和函數移動到 Effect 中
如果對象依賴于一些可能因重新渲染而改變的響應值,就可以在Effect內部聲明它。
const serverUrl = 'https://localhost:1234';function ChatRoom({ roomId }) {const [message, setMessage] = useState('');useEffect(() => {// 在Effect內部聲明,不是響應式值,也就不是Effect的依賴const options = {serverUrl: serverUrl,roomId: roomId};const connection = createConnection(options);connection.connect();return () => connection.disconnect();// roomId是props,屬于響應值,所以放到依賴項中,確保roomId發生改變會觸發同步}, [roomId]); // ? 所有依賴已聲明
- 從對象中讀取原始值
有時候這個對象可能是從父元素直接傳過來的
// 父組件可能是這樣的
<ChatRoomroomId={roomId}options={{serverUrl: serverUrl,roomId: roomId}}
/>// 如果父組件options是個對象,每次父組件重新渲染都會導致options更新
function ChatRoom({ options }) {const [message, setMessage] = useState('');
// 從 Effect 外部 讀取對象信息,并避免依賴對象和函數類型
// 先把它們都拆開變成簡單類型,在Effect中監聽這兩個簡單類型const { roomId, serverUrl } = options;useEffect(() => {// 雖然這樣看上去有點多此一舉,拆了對象又重新拼接成對象// 但這使得 Effect 實際 依賴的信息非常明確。const connection = createConnection({roomId: roomId,serverUrl: serverUrl});connection.connect();return () => connection.disconnect();}, [roomId, serverUrl]); // ? 所有依賴已聲明
移除不必要的依賴
React中會有linter(很多時候是和eslint之類的結合使用)來驗證是否已經將 Effect 讀取的每一個響應式值(如 props 和 state)包含在 Effect 的依賴中
。不必要的依賴可能會導致 Effect 運行過于頻繁,甚至產生無限循環。因此在寫代碼過程中要注意審查并移除Effect中不必要的依賴。
移除一個依賴,你需要向 linter 證明其不需要這個依賴。因為Effect會對響應式值做出“反應”,所以如果你想移除一個值,那必須要證明它不是響應式的,方法在上面都講過了,這里就不再贅述。
在調整 Effect 的依賴以適配代碼時,請注意一下當前的依賴。當這些依賴發生變化時,讓 Effect 重新運行是否有意義?
- 可能想在不同的條件下重新執行 Effect 的 不同部分。
- 可能想只讀取某個依賴的 最新值,而不是對其變化做出“反應”。
- 依賴可能會因為它的類型是對象或函數而 無意間 改變太頻繁。
為了找到正確的解決方案,通常需要思考下面幾個問題:
- 這段代碼是否應該移到事件處理程序中
最開始需要思考的就是,這段代碼是否應該成為Effect,這部分涉及的內容比較多,會在后面專門講。 - Effect是否在做幾件不相關的事情
之前說過,代碼中的每個 Effect 應該代表一個獨立的同步過程,所以在寫Effect時需要思考,這個Effect是不是只做了一件事,如果它同時做了兩個獨立的事情,不妨把代碼獨立開來。 - 是否在讀取一些狀態下來計算下一個狀態
看聊天過程中的這個例子,每次有新的消息到達時,這個 Effect 會用新創建的數組更新 messages state
寫的代碼可能是這樣的:
function ChatRoom({ roomId }) {const [messages, setMessages] = useState([]);useEffect(() => {const connection = createConnection();connection.connect();// 每次有新消息到達時,都要更新消息connection.on('message', (receivedMessage) => {setMessages([...messages, receivedMessage]);});return () => connection.disconnect();}, [roomId, messages]); // ? 所有依賴已聲明
這段代碼的問題也十分明顯,每次message發生改變,都會觸發Effect,導致重新連接,顯然這不符合用戶行為,解決方案也很簡單,將一個 state 更新函數 傳遞給 setMessages
function ChatRoom({ roomId }) {const [messages, setMessages] = useState([]);useEffect(() => {const connection = createConnection();connection.connect();connection.on('message', (receivedMessage) => {// React 將更新程序函數放入隊列 并將在下一次渲染期間向其提供 msgs 參數// 這樣Effect本身并不會需要message這個依賴項setMessages(msgs => [...msgs, receivedMessage]);});return () => connection.disconnect();}, [roomId]); // ? 所有依賴已聲明
- 想實現讀取一個值而不對其變化做出“反應”嗎
可以實現,但實現的前提是在穩定版本的 React 中 尚未發布的實驗性 API。先學著,都寫在教程里了大概率都是很有用的。
這個問題主要出現在這種場景下,有時候,我們需要混合響應式和非響應式的邏輯。例如,假設你想在用戶連接到聊天室時展示一個通知。并且通過從 props 中讀取當前 theme(dark 或者 light)來展示對應顏色的通知,那代碼可能是這樣的:
function ChatRoom({ roomId, theme }) {useEffect(() => {const connection = createConnection(serverUrl, roomId);connection.on('connected', () => {showNotification('Connected!', theme);});connection.connect();return () => {connection.disconnect()};}, [roomId, theme]); // ? 聲明所有依賴項
按照之前學習的知識,這樣寫是完全符合React規范的。但是,看到這里的大部分人應該已經能看出來這段代碼在交互上的問題了——修改主題也會觸發同步連接。在這個場景下,更加適用的是每次連接時獲取最新的theme,但不希望theme改變時觸發同步,這時需要一個新的概念: Effect Event,與之一起的是一個新的hook:useEffectEvent
Effect Event是 Effect 邏輯的一部分,但是其行為更像事件處理函數。它內部的邏輯不是響應式的,而且能一直“看見”最新的 props 和 state。
function ChatRoom({ roomId, theme }) {
// useEffectEvent會從 Effect 中提取非響應式邏輯
// 它是 Effect 邏輯的一部分,但是其行為更像事件處理函數。它內部的邏輯不是響應式的,而且能一直“看見”最新的 props 和 state
// 因為它不是響應式的,所以可以在里面使用任意響應式值,而不用擔心周圍代碼因為變化而重新執行const onConnected = useEffectEvent(() => {showNotification('Connected!', theme);});useEffect(() => {const connection = createConnection(serverUrl, roomId);connection.on('connected', () => {onConnected();});connection.connect();return () => connection.disconnect();// onConnected是非響應式的,所以不需要聲明// }, [roomId]); // ? 聲明所有依賴項
Effect Event 也是有一定的局限性:
- 只在 Effect 內部調用他們。
- 永遠不要把他們傳給其他的組件或者 Hook。
Effect Event 是 Effect 代碼的非響應式“片段”。他們應該在使用他們的 Effect 的旁邊。
function Timer() {const [count, setCount] = useState(0);// 不能寫成這樣,避免傳遞 Effect Event// const onTick = useEffectEvent(() => {// setCount(count + 1);// });// useTimer(onTick, 1000); // 🔴 避免: 傳遞 Effect EventuseTimer(() => {setCount(count + 1);}, 1000);return <h1>{count}</h1>
}function useTimer(callback, delay) {const onTick = useEffectEvent(() => {callback();});useEffect(() => {const id = setInterval(() => {onTick(); // ? 好: 只在 Effect 內部局部調用}, delay);return () => {clearInterval(id);};}, [delay]); // 不需要指定 “onTick” (Effect Event) 作為依賴項
}
- 一些響應式值是否無意中改變了?
需要檢查一下,依賴項中是否出現了對象或者函數這種每次重新渲染都是新的值的數據結構,畢竟依賴項內對比是否發生改變使用的是object.is
Effect清理函數
還是聊天室的例子,如果用戶第一次進入包含ChatRoom的頁面,那么很正常的會觸發connect,接著,用戶切換到其他頁面,然后又重新回來,此時會再次觸發connect,表面上看上去毫無問題,但實際上會發現,現在程序中會有兩個connect連接,第一個連接始終沒有被銷毀!!!如果用戶不斷切換退出進入,連接就會不斷累積
export default function ChatRoom() {useEffect(() => {const connection = createConnection();connection.connect();}, []);return <h1>歡迎來到聊天室!</h1>;
}
這時候就需要添加清理函數,在組件被卸載或者重新渲染時Effect會調用清理函數,在這個例子中就是斷開連接。
然后,你會很驚奇的發現,在開發環境中,初次掛載時,Effect會運行兩次,為什么要這么設計呢?答案是實現清理函數,清理函數應該停止或撤銷 Effect 所做的一切。原則是用戶不應該感受到 Effect 只執行一次(在生產環境中)和連續執行“掛載 → 清理 → 掛載”(在開發環境中)之間的區別。需要思考的不是“如何只運行一次 Effect”,而是“如何修復我的 Effect 來讓它在重新掛載后正常運行”
下面來看一些場景:
- 管理非 React 小部件
有時需要添加不是用 React 實現的 UI 小部件。例如添加一個地圖組件。它有一個 setZoomLevel() 方法,希望地圖的縮放比例和代碼中的 zoomLevel state 保持同步。你的 Effect 應該類似于:
useEffect(() => {const map = mapRef.current;// 在開發環境中,雖然 React 會調用 Effect 兩次,但這沒關系,因為用相同的值調用 setZoomLevel 兩次不會造成任何影響。只是可能會慢點map.setZoomLevel(zoomLevel);
}, [zoomLevel]);// 有些 API 可能不允許你連續調用兩次。例如,內置的 <dialog> 元素的 showModal 方法在連續被調用兩次時會拋出異常。此時可以通過實現清理函數來使其關閉對話框
// 在測試環境中,這個Effect會先調用showModal(),然后調用close(),再調用一次showModal(),這和生產環境只調用一次showModal()行為是一致的
useEffect(() => {const dialog = dialogRef.current;dialog.showModal();return () => dialog.close();
}, []);
但是,還會有一些組件在初次渲染和二次渲染中表現不一致,遇到這種表現不一致的行為,還是需要做一些額外處理(比如最近項目中遇到的pdf-preview組件就屬于這個類型)
2. 訂閱事件
如果 Effect 訂閱了某些事件,清理函數應退訂這些事件:
useEffect(() => {function handleScroll(e) {console.log(window.scrollX, window.scrollY);}window.addEventListener('scroll', handleScroll);return () => window.removeEventListener('scroll', handleScroll);
}, []);
- 觸發動畫
如果 Effect 觸發了一些動畫,清理函數應將動畫重置為初始狀態,如果使用了支持補間動畫的第三方動畫庫,清理函數應將時間軸重置為初始狀態
useEffect(() => {const node = ref.current;node.style.opacity = 1; // 觸發動畫return () => {node.style.opacity = 0; // 重置為初始值};
}, []);
- 獲取數據
如果Effect 需要獲取數據,清理函數應 中止請求 或忽略其結果
useEffect(() => {let ignore = false;async function startFetching() {const json = await fetchTodos(userId);// 第二次發送請求時,忽略返回的數據if (!ignore) {setTodos(json);}}startFetching();return () => {ignore = true;};
}, [userId]);
但是在開發環境中,避免不了的是會觸發兩次請求,但有時兩次請求會導致一些問題,比如數據量特別大時,或者網絡特別不好時,此時可以構建一個緩存機制,第一次請求之后就將結果緩存下來,再次觸發一模一樣的請求(接口一致,參數一致),直接從緩存中獲取結果,這樣就能避免重復的網絡請求。
在 Effect 中直接編寫 fetch 請求 是一種常見的數據獲取方式,特別是在完全客戶端渲染的應用中。但這種方法非常手動化,還有明顯的弊端:
- Effect 不會在服務端運行。這意味著最初由服務器渲染的 HTML 只會包含加載狀態,而沒有實際數據。客戶端必須先下載所有的 JavaScript 并渲染應用,才會發現它需要加載數據——這并不高效。
- 直接在 Effect 中進行數據請求,容易產生“網絡瀑布(network waterfall)”。首先父組件渲染時請求一些數據,隨后渲染子組件,接著子組件開始請求它們的數據。如果網絡速度不快,這種方式會比并行獲取所有數據慢得多。
- 直接在 Effect 中進行數據請求往往無法預加載或緩存數據。例如,如果組件卸載后重新掛載,它必須重新獲取數據。
不夠簡潔。編寫 fetch 請求時為了避免 競態條件(race condition) 等問題,會需要很多樣板代碼。
這些弊端并不僅限于 React。任何庫在組件掛載時進行數據獲取都會遇到這些問題。與路由處理一樣,要做好數據獲取并非易事,因此我們推薦以下方法: - 如果正在使用 框架 ,請使用其內置的數據獲取機制。現代 React 框架集成了高效的數據獲取機制,不會出現上述問題。
- 考慮使用或構建客戶端緩存。流行的開源解決方案包括 React Query、useSWR 和 React Router v6.4+。你也可以自己構建解決方案:在底層使用 Effect,但添加對請求的去重、緩存響應以及避免網絡瀑布(通過預加載數據或將數據請求提升到路由層次)的邏輯。
- 發送分析報告
考慮一個在頁面訪問時發送分析事件的場景,代碼可能是這樣的:
useEffect(() => {logVisit(url); // 發送 POST 請求
}, [url]);
在開發環境中,這個請求可能會被發送兩次,從用戶角度上來說,發送一次還是兩次沒有本質上的區別,但對收集者而言,會造成分析數據的偏差。因此不建議在開發環境開啟這種功能。
如果確實不需要加載兩次的功能,可以暫時禁用嚴格模式,但React不鼓勵這樣做,因為這樣容易發現不了隱藏的問題,導致函數或者事件未被清除。
你可能不需要Effect
本質上,Effect也是一種脫圍機制(不然也不會歸納在脫圍機制這章里),它通常用于暫時“跳出” React 并與一些 外部 系統進行同步。這包括瀏覽器 API、第三方小部件,以及網絡等等。如果發現編寫的Effect只是根據其他狀態來調整某些狀態,那你可能不需要一個Effect。
在事件處理函數和 Effect 中做選擇
在使用Effect之前,首先要判斷這段代碼是否應該成為Effect。
假設你正在實現一個聊天室組件,需求如下:
- 組件應該自動連接選中的聊天室。
- 每當你點擊“Send”按鈕,組件應該在當前聊天界面發送一條消息。
經過之前的學習,應該能很容易就判斷出來,第一個需求應該用Effect,第二個需求用事件處理函數。因為事件處理函數只在響應特定的交互操作時運行,而每當需要同步,Effect 就會運行。
直觀上,可以說事件處理函數總是“手動”觸發的,例如點擊按鈕。另一方面, Effect 是自動觸發:每當需要保持同步的時候他們就會開始運行和重新運行。
- 事件處理函數內部的邏輯是非響應式的。除非用戶又執行了同樣的操作(例如點擊),否則這段邏輯不會再運行。事件處理函數可以在“不響應”他們變化的情況下讀取響應式值。
- Effect 內部的邏輯是響應式的。如果 Effect 要讀取響應式值,你必須將它指定為依賴項。如果接下來的重新渲染引起那個值變化,React 就會使用新值重新運行 Effect 內的邏輯。
如果想要在Effect中實現非響應式的邏輯,可以參考Effect Event(上面講過的,不記得了可以返回去復習一下)
來看幾個例子:
例子1: 想象一個表單,提交時將 submitted 狀態變量設置為 true,并在 submitted 為 true 時,需要發送 POST 請求并顯示通知。
// 如果把事件放在Effect里,代碼就會變成這樣
// 但這里的問題是,代碼不應該以 Effect 實現,參考上面的原則,發送消息并在提交時顯示通知,這是一個特定的動作,一般需要手動觸發
// 不是說不能用Effect實現,而是這樣會多了些不必要的渲染和消耗
function Form() {const [submitted, setSubmitted] = useState(false);useEffect(() => {if (submitted) {// 🔴 避免: Effect 中有特定事件的邏輯post('/api/register');showNotification('Successfully registered!');}}, [submitted]);function handleSubmit() {setSubmitted(true);}// ...
}// 它其實只需要這樣:
// 代碼簡潔了不說,也不會導致不必要的開銷
function Form() {function handleSubmit() {// ? 好:從事件處理程序調用特定于事件的邏輯post('/api/register');showNotification('Successfully registered!', theme);} // ...
}
例子2: 假設一個產品頁面,上面有兩個按鈕(購買和付款),都可以滿足購買產品。當用戶將產品添加進購物車時,顯示一個通知。如果兩個按鈕的 click 事件處理函數中都調用 showNotification() 感覺有點重復,所以可能想把這個邏輯放在一個 Effect 中:
function ProductPage({ product, addToCart }) {// 🔴 避免:在 Effect 中處理屬于事件特定的邏輯// 這個 Effect 是多余的。而且很可能會導致問題// 假設應用在頁面重新加載之前 “記住” 了購物車中的產品。然后把一個產品添加到購物車中并刷新頁面,通知將再次出現。// 每次刷新該產品頁面時,它都會出現。useEffect(() => {if (product.isInCart) {showNotification(`已添加 ${product.name} 進購物車!`);}}, [product]);function handleBuyClick() {addToCart(product);}function handleCheckoutClick() {addToCart(product);navigateTo('/checkout');}// ...
}
// 當不確定某些代碼應該放在 Effect 中還是事件處理函數中時,先自問 為什么 要執行這些代碼。
// Effect 只用來執行那些顯示給用戶時組件 需要執行 的代碼。
function ProductPage({ product, addToCart }) {// ? 非常好:事件特定的邏輯在事件處理函數中處理function buyProduct() {addToCart(product);showNotification(`已添加 ${product.name} 進購物車!`);}function handleBuyClick() {buyProduct();}function handleCheckoutClick() {buyProduct();navigateTo('/checkout');}// ...
}
根據 props 或 state 來更新 state
假設有一個包含了兩個 state 變量的組件:firstName 和 lastName。你想通過把它們聯結起來計算出 fullName。此外,每當 firstName 和 lastName 變化時,希望 fullName 都能更新,在學了Effect之后,可能會寫成這樣:
// 這樣寫的問題在于
// 先是用 fullName 的舊值執行了整個渲染流程,然后立即使用更新后的值又重新渲染了一遍,造成了多余的渲染function Form() {const [firstName, setFirstName] = useState('Taylor');const [lastName, setLastName] = useState('Swift');// 🔴 避免:多余的 state 和不必要的 Effectconst [fullName, setFullName] = useState('');useEffect(() => {setFullName(firstName + ' ' + lastName);}, [firstName, lastName]);// ...
}// 修改成這樣之后,首先代碼變得更加簡潔
// 如果一個值可以基于現有的 props 或 state 計算得出,不要把它作為一個 state,而是在渲染期間直接計算這個值
// 這將使代碼更快(避免了多余的 “級聯” 更新)、更簡潔(移除了一些代碼)以及更少出錯(避免了一些因為不同的 state 變量之間沒有正確同步而導致的問題)
function Form() {const [firstName, setFirstName] = useState('Taylor');const [lastName, setLastName] = useState('Swift');// ? 非常好:在渲染期間進行計算const fullName = firstName + ' ' + lastName;// ...
}
緩存昂貴的計算
這個例子是使用組件接收到的 props 中的 filter 對另一個 prop todos 進行篩選,計算得出 visibleTodos。直覺可能是把結果存到一個 state 中,并在 Effect 中更新它
function TodoList({ todos, filter }) {const [newTodo, setNewTodo] = useState('');// 🔴 避免:多余的 state 和不必要的 Effectconst [visibleTodos, setVisibleTodos] = useState([]);useEffect(() => {setVisibleTodos(getFilteredTodos(todos, filter));}, [todos, filter]);// ...
}// 和上面的例子本質是一樣的,getFilteredTodos是通過props更新來的,那就直接在渲染過程中計算
function TodoList({ todos, filter }) {const [newTodo, setNewTodo] = useState('');// ? 如果 getFilteredTodos() 的耗時不長,這樣寫就可以了。const visibleTodos = getFilteredTodos(todos, filter);// ...
}
// 注意一個情況是,如果getFilteredTodos是個非常耗時的計算,容易阻塞渲染
// 這個時候可以用useMemo(memo函數)把這種長耗時的計算緩存起來
// 傳入 useMemo 的函數會在渲染期間執行,所以它僅適用于 純函數 場景
import { useMemo, useState } from 'react';function TodoList({ todos, filter }) {const [newTodo, setNewTodo] = useState('');// ? 除非 todos 或 filter 發生變化,否則不會重新執行 getFilteredTodos()const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);// ...
}
useMemo 在每次重新渲染的時候能夠緩存計算的結果
開發環境的嚴格模式下,它也會觸發兩次計算函數的調用
它并不會讓 第一次 渲染變快。只是會跳過不必要的更新
當props變化時重置或調整states
首先來講重置,場景有很多,比如一直在說的聊天室例子,當切換聊天室時(也就是roomId改變時),原則上會希望都切換成當前聊天室初始化狀態,腦袋里的第一個蹦出來的想法是利用Effect,監聽roomId,當roomId發生改變時,將所有的state設置成初始值:
function ChatRoom({ roomId }) {
const clearAll = () => {// 某些清除函數
}useEffect(() => {const connection = createConnection(serverUrl, roomId);connection.on('connected', () => {showNotification('Connected!');});connection.connect();return () => {connection.disconnect()// 監聽到roomId發生改變時,先執行清理函數// 但是這樣就違背了把兩件不相干的事分開來處理的原則clearAll()};}, [roomId]);
//
}
// 代碼還可以改成這樣
function ChatRoom({ roomId }) {
const clearAll = () => {// 某些清除函數
}useEffect(() => {const connection = createConnection(serverUrl, roomId);connection.on('connected', () => {showNotification('Connected!');});connection.connect();return () => {connection.disconnect()};}, [roomId]);
// 這樣寫確實把不同功能的事件分開了,但這樣做就變得低效了
// 因為這個組件首先會用舊值渲染,然后再用新值重新渲染,而且中間狀態還可能會出現一些狀況外的問題useEffect(() => {clearAll()}, [roomId]); //
}
還有一個弊端是,如果這個組件是個超級復雜的組件,里面包含了很多很多子組件,那每次修改roomId,所有的子組件都需要執行一次它們自身的清理函數。那有沒有更簡單的方法,回想一下之前的知識點,可以通過為每個聊天室組件提供一個明確的鍵(就是之前用過的key值啦)來告訴 React 它們原則上是 不同 的。
// 假設有個page組件是ChatRoom的父組件
// 每當 key(這里是 roomId)變化時,React 將重新創建 DOM,并 重置 ChatRoom 組件和它的所有子組件的 state。
function Page(){
// ...return (<ChatRoom roomId={roomId}key={roomId}/>)
}
有些場景下,只想重置或調整部分 state ,而不是所有 state
List 組件接收一個 items 列表作為 prop,然后用 state 變量 selection 來保持已選中的項。當 items 接收到一個不同的數組時,想將 selection 重置為 null
//
function List({ items }) {const [isReverse, setIsReverse] = useState(false);const [selection, setSelection] = useState(null);
// 每當 items 變化時,List 及其子組件會先使用舊的 selection 值渲染。然后 React 會更新 DOM 并執行 Effect。
// 最后,調用 setSelection(null) 將導致 List 及其子組件重新渲染,重新啟動整個流程。// 🔴 避免:當 prop 變化時,在 Effect 中調整 stateuseEffect(() => {setSelection(null);}, [items]);// ...
}
你可以修改成這樣:
function List({ items }) {const [isReverse, setIsReverse] = useState(false);const [selection, setSelection] = useState(null);// 好一些:在渲染期間調整 state// 在渲染過程中直接調用了 setSelection。當它執行到 return 語句退出后,React 將 立即 重新渲染 List。此時 React 還沒有渲染 List 的子組件或更新 DOM,這使得 List 的子組件可以跳過渲染舊的 selection 值。// 條件判斷 items !== prevItems 是必要的,它可以避免無限循環。你可以像這樣調整 state,但任何其他副作用(比如變化 DOM 或設置的延時)應該留在事件處理函數或 Effect 中,以 保持組件純粹。const [prevItems, setPrevItems] = useState(items);if (items !== prevItems) {setPrevItems(items);setSelection(null);}// ...
}// 可以通過添加 key 來重置所有 state,或者 在渲染期間計算所需內容的方式
// 換一種思路來解決問題,可能能獲取到最優解
function List({ items }) {const [isReverse, setIsReverse] = useState(false);const [selectedId, setSelectedId] = useState(null);// ? 非常好:在渲染期間計算所需內容const selection = items.find(item => item.id === selectedId) ?? null;// ...
}
鏈式計算
有時候可能想鏈接多個 Effect,每個 Effect 都基于某些 state 來調整其他的 state
function Game() {const [card, setCard] = useState(null);const [goldCardCount, setGoldCardCount] = useState(0);const [round, setRound] = useState(1);const [isGameOver, setIsGameOver] = useState(false);// 🔴 避免:鏈接多個 Effect 僅僅為了相互觸發調整 stateuseEffect(() => {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);}}
這段代碼有兩個問題:
第一個問題是它非常低效:在鏈式的每個 set 調用之間,組件(及其子組件)都不得不重新渲染。在上面的例子中,在最壞的情況下(setCard → 渲染 → setGoldCardCount → 渲染 → setRound → 渲染 → setIsGameOver → 渲染)有三次不必要的重新渲染。
第二個問題是,即使不考慮渲染效率問題,隨著代碼不斷擴展,你會遇到這條 “鏈式” 調用不符合新需求的情況。試想一下,你現在需要添加一種方法來回溯游戲的歷史記錄,可以通過更新每個 state 變量到之前的值來實現。然而,將 card 設置為之前的的某個值會再次觸發 Effect 鏈并更改你正在顯示的數據。這樣的代碼往往是僵硬而脆弱的。
更好的做法是:盡可能在渲染期間進行計算,以及在事件處理函數中調整 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('游戲已經結束了。');}// ? 在事件處理函數中計算剩下的所有 statesetCard(nextCard);if (nextCard.gold) {if (goldCardCount <= 3) {setGoldCardCount(goldCardCount + 1);} else {setGoldCardCount(0);setRound(round + 1);if (round === 5) {alert('游戲結束!');}}}}
但在某些情況下是無法 在事件處理函數中直接計算出下一個 state。例如,試想一個具有多個下拉菜單的表單,如果下一個下拉菜單的選項取決于前一個下拉菜單選擇的值。這時,Effect 鏈是合適的,因為需要與網絡進行同步。
初始化應用
有些邏輯只需要在應用加載時執行一次。第一反應可能是把它放在一個頂層組件的 Effect 中。
// 在開發環境中,這會被調用兩次,但對于組件掛載這個場景而言,不管是開發環境還是生產環境,都應該行為一致,只掛載一次
function App() {// 🔴 避免:把只需要執行一次的邏輯放在 Effect 中useEffect(() => {loadDataFromLocalStorage();checkAuthToken();}, []);// ...
}
// 避免這個問題,可以考慮在頂層添加一個變量來記錄是否掛載的狀態
let didInit = false;function App() {useEffect(() => {if (!didInit) {didInit = true;// ? 只在每次應用加載時執行一次loadDataFromLocalStorage();checkAuthToken();}}, []);// ...
}// 也可以在模塊初始化和應用渲染之前執行它
if (typeof window !== 'undefined') { // 檢測我們是否在瀏覽器環境// ? 只在每次應用加載時執行一次checkAuthToken();loadDataFromLocalStorage();
}function App() {// ...
}
與父組件的交互
通知父組件有關 state 變化的信息
假設你正在編寫一個有具有內部 state isOn 的 Toggle 組件,該 state 可以是 true 或 false。有幾種不同的方式來進行切換(通過點擊或拖動)。你希望在 Toggle 的 state 變化時通知父組件,因此你暴露了一個 onChange 事件并在 Effect 中調用它:
function Toggle({ onChange }) {const [isOn, setIsOn] = useState(false);// 🔴 避免:onChange 處理函數執行的時間太晚了// 這個事件會在子組件渲染之后才通知到父組件,父組件這個時候才會更新它的state,然后渲染更新,此時也有可能會再次觸發子組件更新useEffect(() => {onChange(isOn);}, [isOn, onChange])function handleClick() {setIsOn(!isOn);}function handleDragEnd(e) {if (isCloserToRightEdge(e)) {setIsOn(true);} else {setIsOn(false);}}// ...
}// 更好的方式是在單個流程中完成所有操作。在這個例子中,可以刪除 Effect,并在同一個事件處理函數中更新 兩個 組件的 state
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);}}// ...
}// 又或者,可以做狀態提升,把state移除,并從父組件中接收 isOn,
// ? 也很好:該組件完全由它的父組件控制
function Toggle({ isOn, onChange }) {function handleClick() {onChange(!isOn);}function handleDragEnd(e) {if (isCloserToRightEdge(e)) {onChange(true);} else {onChange(false);}}// ...
}
將數據傳遞給父組件
還有一個常見的和父組件交互的場景是將數據傳遞給父組件。
在React中,數據從父組件流向子組件,因此這個過程中進行debug或一些數據追蹤操作是容易的
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 state 之外的一些數據。這些數據可能來自第三方庫或內置瀏覽器 API。由于這些數據可能在 React 無法感知的情況下發變化,需要在組件中手動訂閱它們。這經常使用 Effect 來實現
function useOnlineStatus() {// 不理想:在 Effect 中手動訂閱 storeconst [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();// ...
}// 不是不行,但React提供了一個更好的hook:useSyncExternalStore專門用于訂閱外部store
// 與手動使用 Effect 將可變數據同步到 React state 相比,這種方法能減少錯誤。
function subscribe(callback) {window.addEventListener('online', callback);window.addEventListener('offline', callback);return () => {window.removeEventListener('online', callback);window.removeEventListener('offline', callback);};
}function useOnlineStatus() {// ? 非常好:用內置的 Hook 訂閱外部 storereturn useSyncExternalStore(subscribe, // 只要傳遞的是同一個函數,React 不會重新訂閱() => navigator.onLine, // 如何在客戶端獲取值() => true // 如何在服務端獲取值);
}function ChatIndicator() {const isOnline = useOnlineStatus();// ...
}
useSyncExternalStore 是一個讓你訂閱外部 store 的 React Hook
獲取數據
這可能是最常見的一個需求了
function SearchResults({ query }) {const [results, setResults] = useState([]);const [page, setPage] = useState(1);useEffect(() => {// 🔴 避免:沒有清除邏輯的獲取數據// 在這個場景中,這個不是需要用戶主動觸發的事件,所以不應該放到事件處理函數中// 但這段代碼有一個問題。假設你快速地輸入 “hello”。那么 query 會從 “h” 變成 “he”,“hel”,“hell” 最后是 “hello”。這會觸發一連串不同的數據獲取請求,但無法保證對應的返回順序。// 這種情況被稱為 “競態條件”:兩個不同的請求 “相互競爭”,并以與你預期不符的順序返回。fetchResults(query, page).then(json => {setResults(json);});}, [query, page]);function handleNextPageClick() {setPage(page + 1);}// ...
}
// 修復這個問題有個簡易版的方式:添加一個 清理函數 來忽略較早的返回結果
function SearchResults({ query }) {const [results, setResults] = useState([]);const [page, setPage] = useState(1);useEffect(() => {let ignore = false;fetchResults(query, page).then(json => {if (!ignore) {setResults(json);}});return () => {ignore = true;};}, [query, page]);function handleNextPageClick() {setPage(page + 1);}// ...
}
但處理競態條件并不是實現數據獲取的唯一難點。你可能還需要考慮緩存響應結果(使用戶點擊后退按鈕時可以立即看到先前的屏幕內容),如何在服務端獲取數據(使服務端初始渲染的 HTML 中包含獲取到的內容而不是加載動畫),以及如何避免網絡瀑布(使子組件不必等待每個父組件的數據獲取完畢后才開始獲取數據)。這個時候,之前提到那些緩存方案就派上用場了(React Query, useSWR等)。
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
到此為止,Effect的知識點終于梳理完成啦,官網的React教程也學習得差不多了(剩了一個自定義hooks的章節留到下次和已有的hook一起)
完結撒花~下次見~