v19.2Latest

使用自訂 Hook 重複使用邏輯

React 內建了幾個 Hook,例如useStateuseContextuseEffect。有時,你可能會希望有一個用於更特定目的的 Hook:例如,用於獲取資料、追蹤使用者是否在線,或連接到聊天室。你可能在 React 中找不到這些 Hook,但你可以根據應用程式的需求建立自己的 Hook。

您將學習
  • 什麼是自訂 Hook,以及如何撰寫自己的 Hook
  • 如何在元件之間重複使用邏輯
  • 如何命名和組織你的自訂 Hook
  • 何時以及為何要提取自訂 Hook

自訂 Hook:在元件之間共享邏輯

想像你正在開發一個嚴重依賴網路(就像大多數應用程式一樣)的應用程式。你希望在使用者使用你的應用程式時,如果他們的網路連線意外中斷,能夠警告他們。你會如何處理?看起來你的元件中需要兩樣東西:

  1. 一個追蹤網路是否在線的狀態片段。
  2. 一個訂閱全域 onlineoffline事件並更新該狀態的 Effect。

這將使你的元件與網路狀態保持同步。你可能會從類似這樣的程式碼開始:

試著開啟和關閉你的網路,並注意這個 StatusBar如何根據你的操作進行更新。

現在想像你想在另一個元件中使用相同的邏輯。你想實作一個儲存按鈕,當網路離線時,該按鈕將變為禁用狀態並顯示「重新連線中…」而不是「儲存」。

首先,你可以將 isOnline狀態和 Effect 複製並貼上到SaveButton中:

驗證一下,如果你關閉網路,按鈕的外觀將會改變。

這兩個元件運作良好,但它們之間的邏輯重複令人遺憾。看起來即使它們有不同的 視覺外觀,你還是希望在它們之間重複使用邏輯。

從元件中提取你自己的自訂 Hook

想像一下,如果類似於 useStateuseEffect,有一個內建的useOnlineStatusHook。那麼這兩個元件都可以被簡化,你也可以消除它們之間的重複:

雖然沒有這樣的內建 Hook,但你可以自己撰寫它。宣告一個名為useOnlineStatus的函數,並將你之前撰寫的元件中的所有重複程式碼移入其中:

在函數的最後,回傳 isOnline。這讓你的元件可以讀取該值:

驗證切換網路開關是否會更新兩個元件。

現在您的元件沒有那麼多重複的邏輯了。更重要的是,它們內部的程式碼描述了它們想要做什麼(使用線上狀態!)而不是如何去做(透過訂閱瀏覽器事件)。

當您將邏輯提取到自訂 Hook 時,您可以隱藏處理某些外部系統或瀏覽器 API 的繁瑣細節。您的元件程式碼表達的是意圖,而不是實作細節。

Hook 名稱總是以use

React 應用程式由元件構成。元件由 Hook 構成,無論是內建的還是自訂的。您可能會經常使用其他人建立的自訂 Hook,但有時您也可能會自己編寫一個!

您必須遵循以下命名慣例:

  1. React 元件名稱必須以大寫字母開頭,例如StatusBarSaveButton。React 元件還需要返回 React 知道如何顯示的內容,例如一段 JSX。
  2. Hook 名稱必須以use開頭,後接大寫字母,例如useState(內建)或useOnlineStatus(自訂,如本頁前面所示)。Hook 可以返回任意值。

此慣例確保您總是可以查看一個元件並知道其狀態、Effects 和其他 React 功能可能「隱藏」在哪裡。例如,如果您在元件內部看到一個getColor() 函數呼叫,您可以確定它內部不可能包含 React 狀態,因為它的名稱不是以 use 開頭。然而,像 useOnlineStatus()這樣的函數呼叫很可能內部包含對其他 Hook 的呼叫!

注意

如果您的 linter 已為 React 配置,它將強制執行此命名慣例。向上滾動到上面的沙箱,將 useOnlineStatus重新命名為getOnlineStatus。請注意,linter 將不允許您在內部呼叫useStateuseEffect。只有 Hook 和元件才能呼叫其他 Hook!

Deep Dive
所有在渲染期間呼叫的函式都應該以 use 開頭嗎?

自訂 Hook 讓你共享有狀態的邏輯,而非狀態本身

在前面的例子中,當你開啟和關閉網路時,兩個元件會一起更新。然而,認為它們之間共享了一個單一的isOnline狀態變數是錯誤的。看看這段程式碼:

它的運作方式與你提取重複程式碼之前相同:

這是兩個完全獨立的狀態變數和 Effect!它們之所以在同一時間擁有相同的值,是因為你用同一個外部值(網路是否連線)同步了它們。

為了更好地說明這一點,我們需要一個不同的例子。考慮這個Form 元件:

每個表單欄位都有一些重複的邏輯:

  1. 有一個狀態片段(firstNamelastName)。
  2. 有一個變更處理函式(handleFirstNameChangehandleLastNameChange)。
  3. 有一段 JSX 為該輸入框指定了valueonChange 屬性。

你可以將重複的邏輯提取到這個 useFormInput自訂 Hook 中:

請注意,它只宣告了一個名為 value的狀態變數。

然而,Form 元件呼叫了 useFormInput兩次:

這就是為什麼它的運作方式像是宣告兩個獨立的狀態變數!

自訂 Hook 讓你可以共享狀態邏輯,但不能共享狀態本身。每次呼叫 Hook 都與其他對同一 Hook 的呼叫完全獨立。這就是為什麼上面的兩個沙盒是完全等價的。如果你願意,可以滾動回去比較它們。提取自訂 Hook 前後的行為是相同的。

當你需要在多個元件之間共享狀態本身時,請 將其提升並向下傳遞

在 Hook 之間傳遞響應式值

你的自訂 Hook 內的程式碼會在元件每次重新渲染時重新執行。這就是為什麼,和元件一樣,自訂 Hook必須是純粹的。請將自訂 Hook 的程式碼視為你元件主體的一部分!

由於自訂 Hook 會與你的元件一起重新渲染,它們總是能接收到最新的 props 和狀態。要了解這意味著什麼,請考慮這個聊天室範例。更改伺服器 URL 或聊天室:

當你更改 serverUrlroomId時,Effect 會「響應」你的更改並重新同步。你可以從控制台訊息看出,每次更改 Effect 的依賴項時,聊天都會重新連接。

現在將 Effect 的程式碼移到一個自訂 Hook 中:

這讓你的 ChatRoom元件可以呼叫你的自訂 Hook,而無需擔心其內部運作方式:

這看起來簡單多了!(但它做的事情是一樣的。)

請注意,邏輯 仍然會響應props 和狀態的變化。試著編輯伺服器 URL 或選取的聊天室:

請注意您是如何取得一個 Hook 的回傳值:

並將其作為輸入傳遞給另一個 Hook:

每當您的 ChatRoom元件重新渲染時,它都會將最新的roomIdserverUrl傳遞給您的 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 中。

Deep Dive
讓你的自訂 Hook 專注於具體的高階使用案例

自訂 Hook 幫助您遷移到更好的模式

Effect 是一種「逃生艙」:當您需要「跳出 React」且沒有更好的內建解決方案來滿足您的使用情境時,才會使用它們。隨著時間推移,React 團隊的目標是透過為更具體的問題提供更具體的解決方案,將您應用程式中的 Effect 數量降至最低。將您的 Effect 封裝在自訂 Hook 中,可以讓您在這些解決方案可用時更容易升級程式碼。

讓我們回到這個範例:

在上面的範例中,useOnlineStatus是使用一對useStateuseEffect來實作的。然而,這並非最佳的解決方案。它沒有考慮到許多邊緣情況。例如,它假設當元件掛載時,isOnline已經是true,但如果網路已經離線,這可能是錯誤的。您可以使用瀏覽器的navigator.onLineAPI 來檢查,但直接使用它將無法在伺服器上生成初始 HTML。總之,這段程式碼還有改進的空間。

React 包含一個名為useSyncExternalStore的專用 API,它可以為您處理所有這些問題。以下是您的useOnlineStatusHook,改寫後利用了這個新的 API:

請注意您無需更改任何元件即可完成此遷移:

這也是為何將 Effect 封裝在自訂 Hook 中通常有益的另一個原因:

  1. 您可以讓 Effect 的資料流向變得非常明確。
  2. 您可以讓元件專注於意圖,而非 Effect 的具體實作細節。
  3. 當 React 新增功能時,您可以在不更改任何元件的情況下移除這些 Effect。

類似於設計系統,您可能會發現將應用程式元件中的常見慣用語法提取到自訂 Hook 中很有幫助。這將使您的元件程式碼專注於意圖,並讓您避免經常編寫原始的 Effect。許多優秀的自訂 Hook 由 React 社群維護。

Deep Dive
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.