React 伴隨一些像 useState 、 useContext 和 useEffect 的內建 Hook 。有時候,你會希望有個 Hook 可以提供更多特定的目的:例如,抓取資料、保持追蹤使用者是否在線上、或是連線到聊天室;你可能無法在 React 中找到這些 Hook ,但你可以自行建立應用程式所需的 Hook 。
You will learn
- 什麼是客製化 Hook ,與如何自行編寫
- 如何在 component 間重複使用邏輯
- 如何命名與建構客製化的 Hook
- 提取客製化 Hook 的時機與原因
客製化 Hooks :在 Component 間共享邏輯
想像正在開發一個大量依賴網路的應用程式(像是大部分的應用程式),你想在使用者無法使用應用程式時,警告他們網路連線意外中斷,你會怎麼做呢?也許需要在 component 中做兩件事:
這會讓 component 保持同步網路狀態,你也許會像這樣開始:
import { useState, useEffect } from 'react'; export default function StatusBar() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>; }
嘗試打開與關閉網路,並注意該 StatusBar
如何根據你的動作而更新。
現在,想像你也想在不同的 component 中使用相同的邏輯。你想完成一個 Save 按鈕,它會在網路關閉時無法使用,並且顯示「 Reconnecting… 」,而非「 Save 」。
首先,你可以將 isOnline
、 Effect 複製及貼至 SaveButton
內部:
import { useState, useEffect } from 'react'; export default function SaveButton() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); function handleSaveClick() { console.log('✅ Progress saved'); } return ( <button disabled={!isOnline} onClick={handleSaveClick}> {isOnline ? 'Save progress' : 'Reconnecting...'} </button> ); }
驗證如果關閉網路時,按鈕是否會改變外觀。
這兩個 component 會正常運作,但不幸的是它們的邏輯重複;即使它們有不同的視覺外觀,但你會想重複使用它們的邏輯。
從 Component 中提取你的客製化 Hook
想像一下,有個類似 useState
及 useEffect
的內建 useOnlineStatus
Hook ,兩者都可以簡化這些 component 的程式,並可從中移除重複的部分:
function StatusBar() {
const isOnline = useOnlineStatus();
return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>;
}
function SaveButton() {
const isOnline = useOnlineStatus();
function handleSaveClick() {
console.log('✅ Progress saved');
}
return (
<button disabled={!isOnline} onClick={handleSaveClick}>
{isOnline ? 'Save progress' : 'Reconnecting...'}
</button>
);
}
雖然沒有這種內建的 Hook ,但你可以自行編寫。宣告一個函數命名為 useOnlineStatus
,將全部重複的程式從 component 內移動到其內部:
function useOnlineStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function handleOnline() {
setIsOnline(true);
}
function handleOffline() {
setIsOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return isOnline;
}
在函數的最後回傳 isOnline
,讓 component 可讀取到這個值:
import { useOnlineStatus } from './useOnlineStatus.js'; function StatusBar() { const isOnline = useOnlineStatus(); return <h1>{isOnline ? '✅ Online' : '❌ Disconnected'}</h1>; } function SaveButton() { const isOnline = useOnlineStatus(); function handleSaveClick() { console.log('✅ Progress saved'); } return ( <button disabled={!isOnline} onClick={handleSaveClick}> {isOnline ? 'Save progress' : 'Reconnecting...'} </button> ); } export default function App() { return ( <> <SaveButton /> <StatusBar /> </> ); }
驗證切換網路開關時,是否更新兩個元件。
現在 component 內不再有重複的邏輯,更重要的是,它們內部的程式描述它們要做的事情(使用線上的狀態!),而非它們如何做(透過訂閱瀏覽器事件)。
當你提取邏輯到客製化的 Hook 時,可以隱藏如何處理一些外部系統或瀏覽器 API 的粗糙細節; component 內的程式表達你的意圖,而非實作方式。
Hook 名稱總是起始於 use
React 應用程式由 component 所構成; component 由 Hook 構成,無論是內建或客製化。你可能會經常使用其他人建立的客製化 Hook ,但偶爾需要自己寫!
你必須遵循這些命名慣例:
- React component 名稱的開頭必須是大寫,像是
StatusBar
和SavaButton
。 React component 也需要回傳一些東西,讓 React 知道如何顯示,像是一段 JSX 。 - Hook 名稱的必須起始於
use
,接續是大寫,像是 useState (內建)或useOnlineStatus
(客製化,像這頁之前的); Hook 可以回傳任意的值。
這些慣例確保你可以總是看到 component 就知道它的 state 、 Effect 和其他可能「隱藏」的 React 功能;例如,如果你看到 component 內部呼叫一個 getColor()
,可以知道它內部不可能包含 React state ,因為名稱開頭沒有 use
;然而,像是 useOnlineStatus()
的函數,內部很有可能包含呼叫其他 Hook !
Deep Dive
不需要,函數不會呼叫不需要是 Hook 的 Hook。
如果函數沒有呼叫任何 Hook ,避免使用前綴 use
;反之,將它編寫成一般沒有前綴 use
的函數,例如,下方的 useSorted
沒有呼叫 Hook ,因此將它改成 getSorted
:
// 🔴 避免:一個沒有使用 Hook 的 Hook
function useSorted(items) {
return items.slice().sort();
}
// ✅ 好的:一個不使用 Hook 的普通函數
function getSorted(items) {
return items.slice().sort();
}
這確保程式可在任何地方呼叫普通函數,且包含條件:
function List({ items, shouldSort }) {
let displayedItems = items;
if (shouldSort) {
// ✅ 可以有條件地呼叫 getSorted() ,因為它不是 Hook
displayedItems = getSorted(items);
}
// ...
}
如果函數內部至少使用一個 Hook (因此讓它成為 Hook),你應該加上前綴 use
:
// ✅ 好的:一個使用其他 Hook 的 Hook
function useAuth() {
return useContext(Auth);
}
技術方面而言,這不是 React 所強調的;原則上,你可以建立一個不會呼叫其他 Hook 的 Hook ,但這經常會令人感到困擾與限制,因此最好避免這種模式。然而,這可能對某些罕見的情況有所幫助;例如,也許函數不會馬上使用到任何 Hook ,但你計畫在未來加入一些 Hook 的呼叫,加上前綴的 use
便是合理的:
// ✅ 好的:一個稍後很可能會使用其他 Hook 的 Hook
function useAuth() {
// 該做的: 在實作驗證時更新這一行
// 回傳 useContext(Auth);
return TEST_USER;
}
如此一來, component 就不可能有條件地被呼叫,這在內部實際加入呼叫 Hooks 時變得重要;如果沒有預計在內部(現在或稍後)使用 Hook ,不要將它變成 Hook 。
客製化 Hook 讓你共享有狀態的邏輯,而非 State 本身
稍早的案例中,切換網路的開關時會同時更新兩個 component ,但認為它們共享單一 isOnline
state 變數是錯誤的。看這段程式:
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
它使用與之前提取重複部分相同的方式:
function StatusBar() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}
function SaveButton() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// ...
}, []);
// ...
}
這是兩個完全獨立的 state 變數和 Effect !它們在發生時正好擁有相同的值,因為你使用相同的外部值將它們同步(無論網路是否開啟)。
為了更好地說明,我們會需要不同的案例。想像這個 Form
component :
import { useState } from 'react'; export default function Form() { const [firstName, setFirstName] = useState('Mary'); const [lastName, setLastName] = useState('Poppins'); function handleFirstNameChange(e) { setFirstName(e.target.value); } function handleLastNameChange(e) { setLastName(e.target.value); } return ( <> <label> First name: <input value={firstName} onChange={handleFirstNameChange} /> </label> <label> Last name: <input value={lastName} onChange={handleLastNameChange} /> </label> <p><b>Good morning, {firstName} {lastName}.</b></p> </> ); }
每個表格的欄位有一些重複的邏輯:
- 有部分的 state (
firsrName
與lastName
) - 有改變的處理器(
handleFirstNameChange
與handleLastNameChange
) - 有部分的 JSX 為 input 指定
value
與onChange
屬性
你可以將重複的邏輯提取到 useFormInput
的客製化 Hook 中:
import { useState } from 'react'; export function useFormInput(initialValue) { const [value, setValue] = useState(initialValue); function handleChange(e) { setValue(e.target.value); } const inputProps = { value: value, onChange: handleChange }; return inputProps; }
留意它只宣告一個稱為 value
的 state 變數。
然而, Form
component 呼叫兩次 useFormInput
:
function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
// ...
這是為什麼它的運作像是宣告兩個個別的 state 變數!
客製化 Hook 讓你共享有狀態的邏輯,但不是 state 本身。每次的 Hook 呼叫是完全獨立於其他相同的 Hook 呼叫,這是為什麼上方兩個沙盒是完全相等的。如果你願意,往上滑並比較它們,提取客製化 Hook 的前後行為是一致的。
當你需要在複數 component 間共享 state 本身時,請使用狀態提升替代。
在 Hook 間傳遞回應的值
在客製化 Hook 內部的程式會在每次 component re-render 期間重新執行,這是為什麼像是 component 或客製化的 Hook 需要保持單純,將客製化 Hook 的程式當成 component 的主要部分!
因為客製化 Hook 會與 component 共同 re-render ,它們會總是接收到最新的 props 與 state 。要知道其意涵,想像下方的聊天室範例,改變伺服器的網址或聊天室:
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; import { showNotification } from './notifications.js'; export default function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); useEffect(() => { const options = { serverUrl: serverUrl, roomId: roomId }; const connection = createConnection(options); connection.on('message', (msg) => { showNotification('New message: ' + msg); }); connection.connect(); return () => connection.disconnect(); }, [roomId, serverUrl]); return ( <> <label> Server URL: <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} /> </label> <h1>Welcome to the {roomId} room!</h1> </> ); }
當改變 serverUrl
或 roomId
時, Effect 「回應」你的改變,並且重新同步。你可以透過 console 的訊息得知,每當 Effect 的 dependency 改變時,聊天室會重新連線。
現在將 Effect 的程式移到客製化 Hook 中:
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
這讓 ChatRoom
component 呼叫客製化 Hook 時不需擔心內部的運作:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
return (
<>
<label>
Server URL:
<input value={serverUrl} onChange={e => setServerUrl(e.target.value)} />
</label>
<h1>Welcome to the {roomId} room!</h1>
</>
);
}
這看起來會更簡潔!(但它做相同的事情。)
注意邏輯仍回應 props 與 state 的變化;嘗試編輯伺服器的網址或選擇的房間:
import { useState } from 'react'; import { useChatRoom } from './useChatRoom.js'; export default function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); useChatRoom({ roomId: roomId, serverUrl: serverUrl }); return ( <> <label> Server URL: <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} /> </label> <h1>Welcome to the {roomId} room!</h1> </> ); }
注意你如何從一個 Hook 中取得回傳值:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
以及將它當成 input 向另一個 Hook 傳遞:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
每次 ChatRoom
component re-render 時,它傳遞最新的 roomId
與 serverUrl
到 Hook 內,這是為什麼在 re-render 後,無論它們的值是否改變, Effect 都會重現連線至聊天室。(如果你曾經使用聲音或影片處理軟體,連鎖 Hook 會讓你想起串連視覺與聲音效果,就像 useState
的輸出「輸入」 useChatRoom
的輸入。)
傳遞事件處理器至客製化 Hook
當你在更多 component 內開始使用 useChatRoom
時,可能會想讓 component 客製化它的行為;例如,現在寫死在 Hook 內的邏輯是在收到訊息時要執行的:
export function useChatRoom({ serverUrl, roomId }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
showNotification('New message: ' + msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]);
}
你想要將該邏輯移回 component 中:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl,
onReceiveMessage(msg) {
showNotification('New message: ' + msg);
}
});
// ...
改變客製化 Hook 使它運作,以將取得的 onReceiveMessage
當成其中一個命名的選項:
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onReceiveMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl, onReceiveMessage]); // ✅ 宣告所有的 dependency
}
這會執行,但客製化 Hook 接收事件處理器時,還可更進一步改善。
在 onReceiveMessage
加入 dependency 並不理想,因為它會導致聊天室在每次 component re-render 時重新連線,將該事件處理器包裝到 Effect 事件內,並從 dependency 中移除:
import { useEffect, useEffectEvent } from 'react';
// ...
export function useChatRoom({ serverUrl, roomId, onReceiveMessage }) {
const onMessage = useEffectEvent(onReceiveMessage);
useEffect(() => {
const options = {
serverUrl: serverUrl,
roomId: roomId
};
const connection = createConnection(options);
connection.connect();
connection.on('message', (msg) => {
onMessage(msg);
});
return () => connection.disconnect();
}, [roomId, serverUrl]); // ✅ 宣告所有的 dependency
}
現在,聊天室不會在每次 ChatRoom
component re-render 時重新連線。以下示範事件處理器傳入客製化 Hook 後,可以操作的完整動作:
import { useState } from 'react'; import { useChatRoom } from './useChatRoom.js'; import { showNotification } from './notifications.js'; export default function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); useChatRoom({ roomId: roomId, serverUrl: serverUrl, onReceiveMessage(msg) { showNotification('New message: ' + msg); } }); return ( <> <label> Server URL: <input value={serverUrl} onChange={e => setServerUrl(e.target.value)} /> </label> <h1>Welcome to the {roomId} room!</h1> </> ); }
留意你不再需要為了使用 useChatRoom
而了解它如何執行,你可以將它加入到其他任何 component 、傳入任何選項,它會以相同的方式執行;這就是客製化 Hook 的力量。
使用客製化 Hook 的時機
你不需要為每個稍為有重複的程式提取客製化 Hook ,有些重複是可以的;例如,像之前提取一個 useFormInput
Hook ,用以包裝一個 useState
呼叫可能會不太必要。
但每當你在編寫 Effect 時,思考它如果也被包裝到自訂 Hook 內是否會更清楚。你不應該經常需要 Effect ;如果你正在編寫一個 Effect ,這代表你需要「向 React 談判」一些外部系統同步、或執行一些非 React 內建 API 的事情。將它包裝到客製化 Hook 內,令你更簡潔地溝通你的意圖與如何使用資料流。
例如,假設有個顯示兩個下拉式選單的 ShippingForm
component:一個顯示城市的列表、另一個顯示所選城市的區域列表。你可能會從一些像這樣的程式開始:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
// 此 Effect 為國家抓取城市資料
useEffect(() => {
let ignore = false;
fetch(`/api/cities?country=${country}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setCities(json);
}
});
return () => {
ignore = true;
};
}, [country]);
const [city, setCity] = useState(null);
const [areas, setAreas] = useState(null);
// 此 Effect 為所選城市抓取區域資料
useEffect(() => {
if (city) {
let ignore = false;
fetch(`/api/areas?city=${city}`)
.then(response => response.json())
.then(json => {
if (!ignore) {
setAreas(json);
}
});
return () => {
ignore = true;
};
}
}, [city]);
// ...
雖然這段程式幾乎是重複的,但保持這些 Effect 互相分離是正確的。它們同步兩件不同的事情,因此不應該將它們合併成一個 Effect ;反之,你可以透過提取上方 ShippingForm
component 內的共同邏輯到 useData
Hook 中:
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
if (url) {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}
}, [url]);
return data;
}
現在,你可以透過呼叫 useData
更新 ShippingForm
component 內的兩個 Effect :
function ShippingForm({ country }) {
const cities = useData(`/api/cities?country=${country}`);
const [city, setCity] = useState(null);
const areas = useData(city ? `/api/areas?city=${city}` : null);
// ...
提取一個客製化 Hook 會使資料流明確,給予 url
會得到 data
;透過在 useData
內「隱藏」 Effect ,你也預防有些處理 ShippingForm
component 的人加入不必要的 dependency 。隨著時間推進,大部分應用程式內的 Effect 會在 Hook 中。
Deep Dive
先從選擇客製化 Hook 的名稱開始,如果你選擇清楚的名稱時遇到困難,這可能表示 Effect 和 component 邏輯的剩餘部分過於耦合,它還沒準備好要被提取。
理想上,客製化 Hook 的名稱需要清楚到不常寫程式的人也可以猜到客製化 Hook 要做什麼、要取得什麼、它會回傳什麼:
- ✅
useData(url)
- ✅
useImpressionLog(eventName, extraData)
- ✅
useChatRoom(options)
當你和外部系統同步時,客製化 Hook 的名稱可能會更具技術性,且使用該系統的特定術語,只要對熟悉該系統的人是清楚的即可:
- ✅
useMediaQuery(query)
- ✅
useSocket(url)
- ✅
useIntersectionObserver(ref, options)
保持客製化 Hook 聚焦於具體的高層級使用情境,避免建立與使用客製化的「生命週期」 Hook ,作為 useEffect
API 本身的替代方案與便利的包裝器:
- 🔴
useMount(fn)
- 🔴
useEffectOnce(fn)
- 🔴
useUpdateEffect(fn)
例如,該 useMount
Hook 嘗試確保一些程式只在「 on mount 」時執行:
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// 🔴 避免:使用客製化的「生命週期」 Hook
useMount(() => {
const connection = createConnection({ roomId, serverUrl });
connection.connect();
post('/analytics/event', { eventName: 'visit_chat' });
});
// ...
}
// 🔴 避免:建立客製化的「生命週期」 Hook
function useMount(fn) {
useEffect(() => {
fn();
}, []); // 🔴 React Hook useEffect 失去一個 dependency : 'fn'
}
如同 useMount
的客製化「生命週期」 Hook 無法符合 React 的範例;例如,此程式範例有一個錯誤(它沒有「回應」 roomId
或 serverUrl
的改變),但 linter 沒有警告,因為 linter 只會直接確認 useEffect
的呼叫,它不知道 Hook 。
如果你正在編寫 Effect ,先直接使用 React API :
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// ✅ 好的:兩個原本的 Effect 因為目的而分開
useEffect(() => {
const connection = createConnection({ serverUrl, roomId });
connection.connect();
return () => connection.disconnect();
}, [serverUrl, roomId]);
useEffect(() => {
post('/analytics/event', { eventName: 'visit_chat', roomId });
}, [roomId]);
// ...
}
接著,你可以(但也可以不用)為不同的高層級使用情境提取客製化 Hook :
function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
// ✅ 好的:客製化 Hook 依用途命名
useChatRoom({ serverUrl, roomId });
useImpressionLog('visit_chat', { roomId });
// ...
}
一個好的客製化 Hook 透過限制它的運作,讓呼叫程式更加宣告式;例如 useChatRoom(option)
只會連線到聊天室, useImpressionLog(eventName, extraData)
則只會為分析傳送曝光紀錄;如果客製化 Hook API 無法限制使用情境且非常抽象,長期下來,可能會帶來遠比解決的問題還多的問題。
客製化 Hook 協助你轉移到更好的模式
Effect 是一個「逃脫出口」:在你的使用情境中,沒有更好的內建解決辦法而向「 React 談判」時使用。隨著時間經過, React 團隊的目標是為更多特定的問題提供更多的特定解決辦法,減少 Effect 在應用程式中的最少數量。將 Effect 包裝至客製化 Hook 中,讓它在這些解決辦法變得有效時,容易更新你的程式。
讓我們回到此案例:
import { useState, useEffect } from 'react'; export function useOnlineStatus() { const [isOnline, setIsOnline] = useState(true); useEffect(() => { function handleOnline() { setIsOnline(true); } function handleOffline() { setIsOnline(false); } window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); return () => { window.removeEventListener('online', handleOnline); window.removeEventListener('offline', handleOffline); }; }, []); return isOnline; }
在上方的案例中, useOnlineStatus
由一對 useState
和 useEffect
實作,但這不是最好的解決辦法,沒有考慮到一些危險的情況;例如,它假設 component mount 時, isOnline
總會是 true
,但這在網路已經中斷時可能會出錯。你可以使用瀏覽器的 navigator.onLine
API 確認,但直接使用會讓伺服器無法產生初始的 HTML ;簡單來說,這段程式需要改善。
幸運地, React 18 包含一個稱為 useSyncExternalStore
的專用 API ,它可以為你處理這類型的問題。以下是 useOnlineStatus
Hook 如何重新改寫以使用此新 API :
import { useSyncExternalStore } from 'react'; function subscribe(callback) { window.addEventListener('online', callback); window.addEventListener('offline', callback); return () => { window.removeEventListener('online', callback); window.removeEventListener('offline', callback); }; } export function useOnlineStatus() { return useSyncExternalStore( subscribe, () => navigator.onLine, //如何從使用者端取得值 () => true // 如何在伺服器取得值 ); }
注意你如何不需要改變任何 component 將它們轉移:
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
這是另一個為什麼將 Effect 包裝到客製化 Hook 總是有利的理由:
- 你讓出入 Effect 的資料流非常明確
- 你讓 component 聚焦在意圖,而非準確的 Effect 執行步驟
- 當 React 增加新功能時,你可以不需要改變任何 component 就移除這些 Effect
與設計系統相似,你可能會發現它有助於從應用程式的 component 提取共同片段到客製化 Hook 中,它會讓 component 的程式聚焦在意圖上,讓你避免頻繁編寫原本的 Effect ;許多優秀的客製化 Hook 由 React 社群維護。
Deep Dive
我們持續在處理細節,但預期未來會有,你會像這樣編寫資料抓取:
import { use } from 'react'; // 還不能使用!
function ShippingForm({ country }) {
const cities = use(fetch(`/api/cities?country=${country}`));
const [city, setCity] = useState(null);
const areas = city ? use(fetch(`/api/areas?city=${city}`)) : null;
// ...
如果你在應用程式中使用像是上方 useData
的客製化 Hook ,它會比在每個 component 中手動編寫原生的 Effect ,還需要更多的改變以轉移到最後推薦的方法。但舊方法仍可以持續運作,因此你就快樂地編寫原生的 Effect ,你可以繼續這麼做。
有多於一種的執行方法嗎?
想要使用瀏覽器 requestAnimationFrame
API 從頭實作一個淡入動畫,首先你可能會使用 Effect 設定動畫的迴圈;在每個動畫的關鍵影格中,你會改變你在 ref 中持有的 DOM 節點透明度,直到它變成: 1
。你的程式可能會先像這樣:
import { useState, useEffect, useRef } from 'react'; function Welcome() { const ref = useRef(null); useEffect(() => { const duration = 1000; const node = ref.current; let startTime = performance.now(); let frameId = null; function onFrame(now) { const timePassed = now - startTime; const progress = Math.min(timePassed / duration, 1); onProgress(progress); if (progress < 1) { // 我們仍需要繪製更多影格 frameId = requestAnimationFrame(onFrame); } } function onProgress(progress) { node.style.opacity = progress; } function start() { onProgress(0); startTime = performance.now(); frameId = requestAnimationFrame(onFrame); } function stop() { cancelAnimationFrame(frameId); startTime = null; frameId = null; } start(); return () => stop(); }, []); return ( <h1 className="welcome" ref={ref}> Welcome </h1> ); } export default function App() { const [show, setShow] = useState(false); return ( <> <button onClick={() => setShow(!show)}> {show ? 'Remove' : 'Show'} </button> <hr /> {show && <Welcome />} </> ); }
為了讓 component 更容易閱讀,你可能會將邏輯提取到 useFadeIn
的客製化 Hook 中:
import { useState, useEffect, useRef } from 'react'; import { useFadeIn } from './useFadeIn.js'; function Welcome() { const ref = useRef(null); useFadeIn(ref, 1000); return ( <h1 className="welcome" ref={ref}> Welcome </h1> ); } export default function App() { const [show, setShow] = useState(false); return ( <> <button onClick={() => setShow(!show)}> {show ? 'Remove' : 'Show'} </button> <hr /> {show && <Welcome />} </> ); }
你可以讓 useFadeIn
維持原狀,但你也會重構它更多;例如你會需要將動畫迴圈的設定邏輯從 useFadeIn
的外面,提取到客製化 useAnimationLoop
的 Hook 內:
import { useState, useEffect } from 'react'; import { experimental_useEffectEvent as useEffectEvent } from 'react'; export function useFadeIn(ref, duration) { const [isRunning, setIsRunning] = useState(true); useAnimationLoop(isRunning, (timePassed) => { const progress = Math.min(timePassed / duration, 1); ref.current.style.opacity = progress; if (progress === 1) { setIsRunning(false); } }); } function useAnimationLoop(isRunning, drawFrame) { const onFrame = useEffectEvent(drawFrame); useEffect(() => { if (!isRunning) { return; } const startTime = performance.now(); let frameId = null; function tick(now) { const timePassed = now - startTime; onFrame(timePassed); frameId = requestAnimationFrame(tick); } tick(); return () => cancelAnimationFrame(frameId); }, [isRunning]); }
然而,你不需要這麼做。使用一般函數時,你最後會決定要在什麼地方畫上不同程式之間的界線;你也可以使用非常困難的方法,將多數命令式的邏輯移動到 Javascript 的 class 內,而非將邏輯保留在 Effect 中:
import { useState, useEffect } from 'react'; import { FadeInAnimation } from './animation.js'; export function useFadeIn(ref, duration) { useEffect(() => { const animation = new FadeInAnimation(ref.current); animation.start(duration); return () => { animation.stop(); }; }, [ref, duration]); }
Effect 讓你將 React 連接到外面的系統。 Effect 之間需要越多的協調(例如串連複數的動畫),像上方的沙盒將邏輯完全提取到 Effect 和 Hook 外面就越合理;接著,你提取的程式會變成「外部的系統」,這讓 Effect 保持簡潔,因為你只需要傳送訊息到你移動到 React 外面的系統。
上方的案例假設淡入邏輯需要被寫在 Javascript 中,但這種特定的淡入動畫使用簡單的 CSS 動畫會更簡潔且更有效率:
.welcome { color: white; padding: 50px; text-align: center; font-size: 50px; background-image: radial-gradient(circle, rgba(63,94,251,1) 0%, rgba(252,70,107,1) 100%); animation: fadeIn 1000ms; } @keyframes fadeIn { 0% { opacity: 0; } 100% { opacity: 1; } }
有時候,你甚至不需要 Hook !
Recap
- 客製化 Hook 讓你在 component 間共享邏輯
- 客製化 Hook 必須使用
use
命名,後面需接上大寫字母 - 客製化 Hook 只會共享有狀態的邏輯,而非 state 本身
- 你可以將回應的值從一個 Hook 傳給另一個,它們會保持最新
- 所有的 Hook 會在 component re-render 時重新執行
- 客製化 Hook 的程式需要保持單純,像是 component 的程式
- 將接收客製化 Hook 的事件處理器包裝到 Effect 事件內
- 不要建立像是
useMount
的客製化 Hook ,保持它們的特殊目的 - 你可以決定如何選擇程式的邊界與地方
Challenge 1 of 5: 提取 useCounter
Hook
這 component 使用 state 變數與 Effect 顯示每秒增加的數字。將該邏輯提取至名為 useCounter
的客製化 Hook 中,你的目標是讓 Counter
component 完成後像這樣:
export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}
你會需要在 useCounter.js
中編寫客製化 Hook ,並匯入到 Counter.js
檔案內。
import { useState, useEffect } from 'react'; export default function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>Seconds passed: {count}</h1>; }