跳至主要内容

課程:JavaScript 與 React 底層原理 第 19 堂:Hooks 底層收尾

60 自訂 Hook 設計

想像一下,如果你在開發 React 應用程式時,能像玩樂高積木一樣,把複雜的狀態邏輯、API 請求流程、甚至是視窗大小監聽功能,隨意拆卸並組合到任何元件中,開發會變得多麼輕鬆?

這就是自訂 Hook(Custom Hook)的魔力。它不是 React 引擎中某個神秘的硬編碼功能,而是 React 設計哲學中最純粹的體現:將「邏輯」與「 UI」徹底解耦。當我們在 Topic 10 前幾個單元深入探討了 useStateuseEffectuseReducer 的底層鏈結串列結構後,現在我們要學習如何將這些原子級的積木,封裝成你自己專屬的強力工具。

自訂 Hook 的物理本質:普通函數的華麗轉身

很多初學者會覺得自訂 Hook 帶有一種「魔法感」,認為它背後一定有某種複雜的 React 內部註冊機制。但事實上,自訂 Hook 的物理本質非常樸素:

自訂 Hook = 普通 JavaScript 函數 + 內部呼叫了其他 Hooks + 遵守 Hooks 規則。

它既不是 React 內建的 API,也不是編譯器處理後的特殊產物。如果你寫了一個函數,裡面沒有用到任何 useStateuseEffect,那它就只是一個普通的工具函數(Utility Function)。但一旦你在函數內部呼叫了 React 的內建 Hooks,它就獲得了「參與 React 生命週期」的能力。

為什麼一定要以 「use」 開頭?

這不僅僅是為了語意化。React 團隊強制要求自訂 Hook 必須以 use 開頭(例如 useFetch 而非 getFetch),主要有兩個底層原因:

  1. ESLint 外掛的識別標誌:React 的 Linter 工具依賴這個前綴來判斷這個函數是否「應該」遵守 Hooks 規則。如果沒有 use 前綴,Linter 就無法幫你檢查你是否在條件語句(if/else)中錯誤地呼叫了它。
  2. 建立開發者的心理預期:看到 use,開發者就會立刻意識到:「這個函數內部可能持有狀態,或會觸發副作用,我必須在元件的最頂層呼叫它。」

底層結構的「扁平化」真相

還記得我們在 10.1 學習過的 Hooks 鏈結串列(Linked List) 嗎?每個 Fiber 節點都有一個 memoizedState 欄位,用來存放該元件的所有 Hooks 狀態。

當你在元件中呼叫一個自訂 Hook 時,底層發生了什麼?

// 自訂 Hook
const useCounter = () => {
const [count, setCount] = useState(0); // Hook 1
useEffect(() => { ... }, [count]); // Hook 2
return [count, setCount];
};

// 元件呼叫
const MyComponent = () => {
const [name, setName] = useState("Aria"); // Hook 0
const [count, setCount] = useCounter(); // 內部包含 Hook 1, Hook 2
const [val, setVal] = useState(true); // Hook 3
};

在 Fiber 節點的眼中,它並不知道 useCounter 的存在。它只看到了一串依序呼叫的指令。執行 MyComponent 時,Fiber 上的 Hooks 鏈結串列會被建構成: useState(name)useState(count)useEffect(count)useState(val)

自訂 Hook 的呼叫在執行時會被「展開」或「扁平化」。這解釋了為什麼自訂 Hook 內部也必須嚴格遵守「不能在條件式中呼叫」的規則,因為一旦 useCounter 被包在 if 裡面,整個鏈結串列的順序(Hook 1 和 Hook 2)就會發生位移,導致後續的 Hook 3 讀取到錯誤的資料。


狀態抽象:讓 UI 變「笨」,讓邏輯變「聰明」

自訂 Hook 最直觀的應用就是狀態抽象(State Abstraction)。當多個元件都在重複相同的 <u>useState</u><u>onChange</u> 邏輯時,就是抽離的好時機。

案例:封裝表單邏輯 useInput

在處理多個輸入框時,我們常會寫出大量重複的代碼。讓我們預測一下,如果將其封裝,會長什麼樣子?

import { useState } from 'react';

// 自訂 Hook:封裝 input 的狀態與行為
const useInput = (initialValue) => {
const [value, setValue] = useState(initialValue);

const handleChange = (e) => {
setValue(e.target.value);
};

const reset = () => setValue(initialValue);

// 回傳一個介面,讓元件可以輕鬆對接
return {
value,
onChange: handleChange,
reset
};
};

現在,在元件中,原本三五行的邏輯簡化成了一行:

const SearchBar = () => {
const nameInput = useInput("");

return (
<div>
{/* 直接展開屬性,這就是物件解構的回傳優勢 */}
<input type="text" {...nameInput} />
<button onClick={nameInput.reset}>清除</button>
<p>目前輸入:{nameInput.value}</p>
</div>
);
};

這種模式的本質是:自訂 Hook 捕獲了變數變更的「過程」,而元件只負責展示變更後的「結果」****。 透過這種方式,我們可以輕易地在不同的表單元件間複用這套 useInput 邏輯,甚至在 useInput 內部加入更複雜的驗證(Validation)邏輯,而不需要修改任何 UI 元件。


副作用抽象:自動化的生命週期管理

除了狀態,自訂 Hook 另一個強大的地方在於封裝 <u>useEffect</u>。這在處理外部訂閱、計時器或 API 請求時尤為關鍵

案例:優雅的 API 請求 useFetch

假設我們希望在元件掛載時自動抓取資料,並處理加載中(Loading)與錯誤(Error)狀態。

const useFetch = (url) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
let isMounted = true; // 防止組件卸載後的狀態更新導致錯誤
setLoading(true);

fetch(url)
.then(res => res.json())
.then(json => {
if (isMounted) {
setData(json);
setLoading(false);
}
})
.catch(err => {
if (isMounted) {
setError(err);
setLoading(false);
}
});

return () => {
isMounted = false; // 清理函數:標記已卸載
};
}, [url]); // 當 URL 改變時重新抓取

return { data, loading, error };
};

這個 Hook 展示了副作用抽象的核心原則:封裝複雜的「清理(Cleanup)」與「邊界處理」邏輯。 呼叫它的元件完全不需要知道 isMounted 的存在,也不需要手動寫 useEffect 的依賴陣列。

邏輯組合:當 Hook 遇上 Hook

自訂 Hook 可以像套娃一樣互相嵌套。例如,我們可以在自訂 Hook 內部呼叫我們上一個單元學過的 useReducer,來管理更複雜的狀態機。

// 在自訂 Hook 中使用 useReducer 實作狀態機
const useComplexStatus = () => {
const [state, dispatch] = useReducer(statusReducer, initialState);

// 封裝 Action Creators,讓元件不需要接觸 dispatch 字串
const startAction = () => dispatch({ type: 'START' });
const stopAction = () => dispatch({ type: 'STOP' });

return { state, startAction, stopAction };
};

這種「邏輯層疊」的能力,讓我們能從最基礎的 useState 出發,建構出極度強大且語意明確的業務邏輯單元。


自訂 Hook 與 Closure 的深層連結

為什麼自訂 Hook 能「記住」狀態?這要回歸到我們在 Topic 2 深入討論過的 Closure(閉包) 機制。

當一個自訂 Hook 執行時,它實際上是在該次渲染的執行環境(EC)中定義了一些局部變數和函數。

  1. 捕獲環境參考:當 Hook 回傳一個函數(例如 resetdispatch)時,這個函數就形成了一個閉包,它「背著」該次渲染時的環境背包(包含當時的 setValuedispatch 地址)。
  2. 穩定的介面:即使元件重新渲染(執行環境重啟),因為 Fiber 節點保留了狀態指針,自訂 Hook 每次被呼叫時都能「接續」之前的狀態,並回傳新的閉包函數。

Stale Closure 的風險

在使用自訂 Hook 時,必須非常小心閉包捕獲到「舊值」的問題。如果自訂 Hook 內部的 useEffectuseCallback 沒有正確設置依賴陣列,它們可能會持有上一次渲染時的變數快照,導致行為異常。這就是為什麼我們在 10.6 強調 useMemouseCallback 對於保持參考穩定的重要性——這在設計高品質的自訂 Hook 時是必備技能。


設計最佳實踐:如何寫出「專業級」的 Hook?

寫出一個會跑的 Hook 不難,但寫出一個易用、強健的 Hook 需要一些設計決策。

1. 返回值設計:陣列還是物件?

這是設計自訂 Hook 時最常遇到的選擇。

  • 陣列解構 (Array Destructuring)
    • 範例const [value, setValue] = useState();
  • 優點:呼叫方可以隨意命名,非常靈活。如果一個元件要呼叫同一個 Hook 兩次(例如兩個計時器),陣列解構最方便。
  • 缺點:如果回傳值超過 3 個,使用者必須記住嚴格的順序,且難以擴充新屬性(不具名)。
  • 物件解構 (Object Destructuring)
    • 範例const { data, loading } = useFetch();
  • 優點:具名屬性,順序不重要,極易擴充(增加一個 error 屬性不會破壞舊有的解構代碼)。
  • 缺點:如果要重新命名,語法較繁瑣(const { data: userData } = useFetch();)。

建議: 如果 Hook 只有 1-2 個回傳值且是通用的(如 <u>useToggle</u>),用陣列;如果是具備多種狀態的複雜邏輯(如 <u>useForm</u>, <u>useFetch</u>),優先使用物件

2. 封裝原則:什麼時候該抽離?

不要為了抽離而抽離。過度抽象會增加程式碼的跳轉成本(追程式碼時要點進好幾層文件)。

以下三種情況是抽離的黃金信號:

  1. 邏輯複用:當你在第二個元件中寫下幾乎一樣的 useEffect 時。
  2. 複雜度隔離:元件已經超過 200 行,且其中一半是在處理某個特定的 WebSocket 連線邏輯。將其抽離可以讓元件主體保持清爽,專注於 UI 佈局。
  3. 可測試性:邏輯抽離成 Hook 後,可以脫離複雜的 UI 環境,使用像 @testing-library/react-hooks 這樣的工具進行獨立的單元測試。

3. 傳遞與響應:依賴項的透明度

如果你的自訂 Hook 接受參數(例如 useFetch(url)),請確保內部正確處理了參數變更的情況。

// 壞示範:只在 mount 時執行
useEffect(() => {
fetch(url);
}, []); // 遺漏了 url 依賴

// 好示範:參數應作為內部 Hook 的依賴項
useEffect(() => {
fetch(url);
}, [url]);

這保證了自訂 Hook 在 React 的「數據流」中是透明且具備響應性的。


總結:自訂 Hook 是邏輯的「封裝袋」

自訂 Hook 的出現,象徵著 React 開發從「生命週期導向」進化到了「功能邏輯導向」。我們不再思考「在 componentDidMount 我要做什麼」,而是思考「這個 API 請求邏輯應該如何運作」

透過自訂 Hook,我們將原生的 useStateuseReduceruseEffect 編織在一起,並利用閉包鎖定行為,利用 Fiber 鏈結串列 持久化狀態,最終為 UI 元件提供一個乾淨、穩定且強大的操作介面。

至此,我們已經完整解構了 React Hooks 的核心家族。從鏈結串列的底層結構,到 useState/useReducer 的狀態更新佇列,再到 useEffect/useLayoutEffect 的執行時機差異,以及 useRef 與記憶化 Hook 的特殊用途,最後到自訂 Hook 的抽象藝術。

知識體系回顧與整合

我們已經完成了 Topic 10 的所有子主題。這是一個非常深度的旅程,現在是時候停下來,將這些碎片化的知識點串聯成一個完整的地圖了。

在接下來的複習單元中,我們將透過一系列核心問題,檢驗你對 Hooks 底層機制的掌握程度。我們將會對比為什麼 useState 能取代 useReducer(或者說反過來),再次確認為什麼 Hook 的呼叫順序至關重要,並練習診斷那些因為閉包陷阱而產生的隱形 Bug。

準備好了嗎?讓我們進入 Topic 10 的總結複習,徹底鞏固這塊 React 開發者最重要的知識基石。