使用自訂 Hook 重複使用邏輯
React 內建了幾個 Hook,例如useState、useContext 和 useEffect。有時,你可能會希望有一個用於更特定目的的 Hook:例如,用於獲取資料、追蹤使用者是否在線,或連接到聊天室。你可能在 React 中找不到這些 Hook,但你可以根據應用程式的需求建立自己的 Hook。
您將學習
- 什麼是自訂 Hook,以及如何撰寫自己的 Hook
- 如何在元件之間重複使用邏輯
- 如何命名和組織你的自訂 Hook
- 何時以及為何要提取自訂 Hook
自訂 Hook:在元件之間共享邏輯
想像你正在開發一個嚴重依賴網路(就像大多數應用程式一樣)的應用程式。你希望在使用者使用你的應用程式時,如果他們的網路連線意外中斷,能夠警告他們。你會如何處理?看起來你的元件中需要兩樣東西:
這將使你的元件與網路狀態保持同步。你可能會從類似這樣的程式碼開始:
試著開啟和關閉你的網路,並注意這個 StatusBar如何根據你的操作進行更新。
現在想像你也想在另一個元件中使用相同的邏輯。你想實作一個儲存按鈕,當網路離線時,該按鈕將變為禁用狀態並顯示「重新連線中…」而不是「儲存」。
首先,你可以將 isOnline狀態和 Effect 複製並貼上到SaveButton中:
驗證一下,如果你關閉網路,按鈕的外觀將會改變。
這兩個元件運作良好,但它們之間的邏輯重複令人遺憾。看起來即使它們有不同的 視覺外觀,你還是希望在它們之間重複使用邏輯。
從元件中提取你自己的自訂 Hook
想像一下,如果類似於 useState 和 useEffect,有一個內建的useOnlineStatusHook。那麼這兩個元件都可以被簡化,你也可以消除它們之間的重複:
雖然沒有這樣的內建 Hook,但你可以自己撰寫它。宣告一個名為useOnlineStatus的函數,並將你之前撰寫的元件中的所有重複程式碼移入其中:
在函數的最後,回傳 isOnline。這讓你的元件可以讀取該值:
驗證切換網路開關是否會更新兩個元件。
現在您的元件沒有那麼多重複的邏輯了。更重要的是,它們內部的程式碼描述了它們想要做什麼(使用線上狀態!)而不是如何去做(透過訂閱瀏覽器事件)。
當您將邏輯提取到自訂 Hook 時,您可以隱藏處理某些外部系統或瀏覽器 API 的繁瑣細節。您的元件程式碼表達的是意圖,而不是實作細節。
Hook 名稱總是以use
React 應用程式由元件構成。元件由 Hook 構成,無論是內建的還是自訂的。您可能會經常使用其他人建立的自訂 Hook,但有時您也可能會自己編寫一個!
您必須遵循以下命名慣例:
- React 元件名稱必須以大寫字母開頭,例如
StatusBar和SaveButton。React 元件還需要返回 React 知道如何顯示的內容,例如一段 JSX。 - Hook 名稱必須以
use開頭,後接大寫字母,例如useState(內建)或useOnlineStatus(自訂,如本頁前面所示)。Hook 可以返回任意值。
此慣例確保您總是可以查看一個元件並知道其狀態、Effects 和其他 React 功能可能「隱藏」在哪裡。例如,如果您在元件內部看到一個getColor() 函數呼叫,您可以確定它內部不可能包含 React 狀態,因為它的名稱不是以 use 開頭。然而,像 useOnlineStatus()這樣的函數呼叫很可能內部包含對其他 Hook 的呼叫!
注意
如果您的 linter 已為 React 配置,它將強制執行此命名慣例。向上滾動到上面的沙箱,將 useOnlineStatus重新命名為getOnlineStatus。請注意,linter 將不允許您在內部呼叫useState 或 useEffect。只有 Hook 和元件才能呼叫其他 Hook!
自訂 Hook 讓你共享有狀態的邏輯,而非狀態本身
在前面的例子中,當你開啟和關閉網路時,兩個元件會一起更新。然而,認為它們之間共享了一個單一的isOnline狀態變數是錯誤的。看看這段程式碼:
它的運作方式與你提取重複程式碼之前相同:
這是兩個完全獨立的狀態變數和 Effect!它們之所以在同一時間擁有相同的值,是因為你用同一個外部值(網路是否連線)同步了它們。
為了更好地說明這一點,我們需要一個不同的例子。考慮這個Form 元件:
每個表單欄位都有一些重複的邏輯:
- 有一個狀態片段(
firstName和lastName)。 - 有一個變更處理函式(
handleFirstNameChange和handleLastNameChange)。 - 有一段 JSX 為該輸入框指定了
value和onChange屬性。
你可以將重複的邏輯提取到這個 useFormInput自訂 Hook 中:
請注意,它只宣告了一個名為 value的狀態變數。
然而,Form 元件呼叫了 useFormInput兩次:
這就是為什麼它的運作方式像是宣告兩個獨立的狀態變數!
自訂 Hook 讓你可以共享狀態邏輯,但不能共享狀態本身。每次呼叫 Hook 都與其他對同一 Hook 的呼叫完全獨立。這就是為什麼上面的兩個沙盒是完全等價的。如果你願意,可以滾動回去比較它們。提取自訂 Hook 前後的行為是相同的。
當你需要在多個元件之間共享狀態本身時,請 將其提升並向下傳遞。
在 Hook 之間傳遞響應式值
你的自訂 Hook 內的程式碼會在元件每次重新渲染時重新執行。這就是為什麼,和元件一樣,自訂 Hook必須是純粹的。請將自訂 Hook 的程式碼視為你元件主體的一部分!
由於自訂 Hook 會與你的元件一起重新渲染,它們總是能接收到最新的 props 和狀態。要了解這意味著什麼,請考慮這個聊天室範例。更改伺服器 URL 或聊天室:
當你更改 serverUrl 或 roomId時,Effect 會「響應」你的更改並重新同步。你可以從控制台訊息看出,每次更改 Effect 的依賴項時,聊天都會重新連接。
現在將 Effect 的程式碼移到一個自訂 Hook 中:
這讓你的 ChatRoom元件可以呼叫你的自訂 Hook,而無需擔心其內部運作方式:
這看起來簡單多了!(但它做的事情是一樣的。)
請注意,邏輯 仍然會響應props 和狀態的變化。試著編輯伺服器 URL 或選取的聊天室:
請注意您是如何取得一個 Hook 的回傳值:
並將其作為輸入傳遞給另一個 Hook:
每當您的 ChatRoom元件重新渲染時,它都會將最新的roomId 和 serverUrl傳遞給您的 Hook。這就是為什麼當它們的值在重新渲染後不同時,您的 Effect 會重新連接到聊天室。(如果您曾經使用過音訊或影片處理軟體,像這樣鏈接 Hook 可能會讓您想起鏈接視覺或音訊效果。就好像useState 的輸出「輸入」到 useChatRoom)
將事件處理函式傳遞給自訂 Hook
當你開始在更多元件中使用useChatRoom 時,你可能會希望讓元件能夠自訂其行為。例如,目前當訊息到達時要執行的邏輯是寫死在 Hook 內的:
假設你想將這個邏輯移回你的元件中:
為了讓這個做法生效,請修改你的自訂 Hook,使其接受onReceiveMessage作為其命名選項之一:
這樣做會生效,但當你的自訂 Hook 接受事件處理器時,還有一個可以改進的地方。
將 onReceiveMessage加入依賴項並不理想,因為這會導致聊天室在元件每次重新渲染時都重新連線。將這個事件處理器包裝到 Effect Event 中,以將其從依賴項中移除:
現在,聊天室不會在 ChatRoom元件每次重新渲染時都重新連線。以下是一個將事件處理器傳遞給自訂 Hook 的完整可運作範例,你可以試試看:
請注意,您不再需要了解 如何useChatRoom運作就能使用它。您可以將其添加到任何其他元件,傳遞任何其他選項,它都會以相同的方式工作。這就是自訂 Hook 的威力。
何時使用自訂 Hook
您不需要為每一小段重複的程式碼都提取一個自訂 Hook。有些重複是可以接受的。例如,像之前那樣提取一個useFormInputHook 來包裝單一的useState 呼叫可能是不必要的。
然而,每當您編寫一個 Effect 時,請考慮將其包裝在一個自訂 Hook 中是否會更清晰。您應該不常需要 Effects,因此,如果您正在編寫一個,這意味著您需要「跳出 React」來與某些外部系統同步,或者執行 React 沒有內建 API 的功能。將其包裝到自訂 Hook 中可以讓您精確地傳達您的意圖以及資料如何流經它。
例如,考慮一個 ShippingForm組件,它顯示兩個下拉選單:一個顯示城市列表,另一個顯示所選城市中的區域列表。您可能會從類似這樣的程式碼開始:
雖然這段程式碼相當重複,但將這些 Effect 彼此分開是正確的。它們同步兩個不同的事物,因此您不應該將它們合併到一個 Effect 中。相反,您可以透過將它們之間的共同邏輯提取到您自己的useData Hook 中,來簡化上面的 ShippingForm組件:
現在您可以將 ShippingForm組件中的兩個 Effect 都替換為對useData的呼叫:
提取自訂 Hook 使資料流變得明確。您輸入url,然後得到 data輸出。透過將您的 Effect「隱藏」在useData 內部,您還可以防止在 ShippingForm組件上工作的人為其添加不必要的依賴項。隨著時間推移,您應用程式中的大多數 Effect 都將位於自訂 Hook 中。
自訂 Hook 幫助您遷移到更好的模式
Effect 是一種「逃生艙」:當您需要「跳出 React」且沒有更好的內建解決方案來滿足您的使用情境時,才會使用它們。隨著時間推移,React 團隊的目標是透過為更具體的問題提供更具體的解決方案,將您應用程式中的 Effect 數量降至最低。將您的 Effect 封裝在自訂 Hook 中,可以讓您在這些解決方案可用時更容易升級程式碼。
讓我們回到這個範例:
在上面的範例中,useOnlineStatus是使用一對useState和useEffect來實作的。然而,這並非最佳的解決方案。它沒有考慮到許多邊緣情況。例如,它假設當元件掛載時,isOnline已經是true,但如果網路已經離線,這可能是錯誤的。您可以使用瀏覽器的navigator.onLineAPI 來檢查,但直接使用它將無法在伺服器上生成初始 HTML。總之,這段程式碼還有改進的空間。
React 包含一個名為useSyncExternalStore的專用 API,它可以為您處理所有這些問題。以下是您的useOnlineStatusHook,改寫後利用了這個新的 API:
請注意您無需更改任何元件即可完成此遷移:
這也是為何將 Effect 封裝在自訂 Hook 中通常有益的另一個原因:
- 您可以讓 Effect 的資料流向變得非常明確。
- 您可以讓元件專注於意圖,而非 Effect 的具體實作細節。
- 當 React 新增功能時,您可以在不更改任何元件的情況下移除這些 Effect。
類似於設計系統,您可能會發現將應用程式元件中的常見慣用語法提取到自訂 Hook 中很有幫助。這將使您的元件程式碼專注於意圖,並讓您避免經常編寫原始的 Effect。許多優秀的自訂 Hook 由 React 社群維護。
實現方法不止一種
假設您想從頭開始使用瀏覽器requestAnimationFrameAPI 實現淡入動畫。您可能會從一個設定動畫循環的 Effect 開始。在動畫的每一幀中,您可以更改您保存在 ref 中的 DOM 節點的不透明度,直到達到1。您的程式碼可能像這樣開始:
為了讓元件更易讀,您可以將邏輯提取到一個useFadeIn自訂 Hook 中:
您可以保持useFadeIn程式碼不變,但也可以進一步重構它。例如,您可以將設定動畫循環的邏輯從useFadeIn提取到一個自訂的useAnimationLoopHook 中:
然而,您並不需要這麼做。如同一般的函式,最終您決定在程式碼的不同部分之間劃分界線的位置。您也可以採取截然不同的方法。與其將邏輯保留在 Effect 中,您可以將大部分的命令式邏輯移到 JavaScript 的類別中:
Effect 讓您可以將 React 連接到外部系統。Effect 之間需要的協調越多(例如,串聯多個動畫),就越應該將該邏輯從 Effect 和 Hook 中完全提取出來,就像上面的沙盒一樣。然後,您提取的程式碼就成為了「外部系統」。這讓您的 Effect 保持簡單,因為它們只需要向您移到 React 外部的系統發送訊息。
上面的範例假設淡入邏輯需要用 JavaScript 編寫。然而,這種特定的淡入動畫使用純粹的CSS 動畫來實現會更簡單且更高效:
有時,您甚至不需要 Hook!
總結
- 自訂 Hook 讓您可以在元件之間共享邏輯。
- 自訂 Hook 的名稱必須以
use開頭,後面接一個大寫字母。 - 自訂 Hook 僅共享狀態邏輯,而非狀態本身。
- 您可以將響應式值從一個 Hook 傳遞到另一個 Hook,它們會保持最新狀態。
- 每次元件重新渲染時,所有 Hook 都會重新執行。
- 您的自訂 Hook 程式碼應該是純粹的,就像您的元件程式碼一樣。
- 將自訂 Hook 接收到的事件處理器包裝到 Effect Event 中。
- 不要建立像
useMount這樣的自訂 Hook。保持其目的明確。 - 如何以及在哪裡選擇程式碼的邊界由您決定。
Try out some challenges
Challenge 1 of 5:Extract a useCounter Hook #
This component uses a state variable and an Effect to display a number that increments every second. Extract this logic into a custom Hook called useCounter. Your goal is to make the Counter component implementation look exactly like this:
export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}You’ll need to write your custom Hook in useCounter.js and import it into the App.js file.
