通過自定義 Hooks 重用邏輯
React 帶有幾個內(nèi)置的 Hook,例如 useState、useContext 和 useEffect。 有時,您會希望有一個 Hook 用于某些更具體的目的:例如,獲取數(shù)據(jù)、跟蹤用戶是否在線或連接到聊天室。 您可能在 React 中找不到這些 Hooks,但您可以根據(jù)應用程序的需要創(chuàng)建自己的 Hooks。
你將學習
- 什么是自定義 Hooks,以及如何編寫自己的 Hooks
- 如何重用組件之間的邏輯
- 如何命名和構(gòu)造您的自定義 Hook
- 何時以及為何提取自定義 Hooks
自定義掛鉤:在組件之間共享邏輯
想象一下,您正在開發(fā)一個嚴重依賴網(wǎng)絡的應用程序(就像大多數(shù)應用程序一樣)。 如果用戶在使用您的應用程序時網(wǎng)絡連接意外斷開,您想警告他們。 你會怎么做?
看起來你的組件中需要兩件事:
跟蹤網(wǎng)絡是否在線的一種狀態(tài)。
訂閱全局在線和離線事件并更新該狀態(tài)的 Effect。
這將使您的組件與網(wǎng)絡狀態(tài)保持同步。 你可以從這樣的事情開始:
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>;
}
嘗試打開和關(guān)閉您的網(wǎng)絡,并注意此 StatusBar 如何響應您的操作而更新。
現(xiàn)在假設您還想在不同的組件中使用相同的邏輯。 你想實現(xiàn)一個保存按鈕,當網(wǎng)絡關(guān)閉時,該按鈕將被禁用并顯示“正在重新連接…”而不是“保存”。
首先,您可以將 isOnline 狀態(tài)和 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>
);
}
驗證如果您關(guān)閉網(wǎng)絡,按鈕將改變其外觀。
這兩個組件工作正常,但不幸的是它們之間的邏輯重復。 看起來即使它們具有不同的視覺外觀,您仍想重用它們之間的邏輯。
從組件中提取您自己的自定義 Hook
想象一下,類似于 useState 和 useEffect,有一個內(nèi)置的 useOnlineStatus Hook。 然后這兩個組件都可以簡化,您可以刪除它們之間的重復:
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>
);
}
雖然沒有這個內(nèi)置的Hook,但是你可以自己寫。 聲明一個名為 useOnlineStatus 的函數(shù),并將您之前編寫的組件中的所有重復代碼移至其中:
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;
}
在函數(shù)結(jié)束時,返回 isOnline。 這讓您的組件讀取該值:
useOnlineStatus.js
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;
}
App.js
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 />
</>
);
}
確認打開和關(guān)閉網(wǎng)絡會更新這兩個組件。
現(xiàn)在你的組件沒有那么多重復的邏輯。 更重要的是,它們內(nèi)部的代碼描述了它們想要做什么(使用在線狀態(tài)!),而不是如何做(通過訂閱瀏覽器事件)。
當您將邏輯提取到自定義 Hook 中時,您可以隱藏有關(guān)如何處理某些外部系統(tǒng)或瀏覽器 API 的粗糙細節(jié)。 您的組件的代碼表達了您的意圖,而不是實現(xiàn)。
鉤子名稱總是以 use 開頭
React 應用程序是從組件構(gòu)建的。 組件是從 Hooks 構(gòu)建的,無論是內(nèi)置的還是自定義的。 您可能會經(jīng)常使用其他人創(chuàng)建的自定義 Hooks,但有時您可能會自己編寫一個!
您必須遵循以下命名約定:
React 組件名稱必須以大寫字母開頭,例如 StatusBar 和 SaveButton。 React 組件還需要返回一些 React 知道如何顯示的東西,比如一段 JSX。
掛鉤名稱必須以 use 開頭,后跟大寫字母,例如 useState(內(nèi)置)或 useOnlineStatus(自定義,如頁面前面所示)。 鉤子可以返回任意值。
此約定保證您始終可以查看組件并了解其狀態(tài)、效果和其他 React 功能可能“隱藏”的位置。 例如,如果您在組件中看到一個 getColor() 函數(shù)調(diào)用,您可以確定它不可能在內(nèi)部包含 React 狀態(tài),因為它的名稱不是以 use 開頭的。 但是,像 useOnlineStatus() 這樣的函數(shù)調(diào)用很可能包含對內(nèi)部其他 Hook 的調(diào)用!
注意
如果您的 linter 是為 React 配置的,它將強制執(zhí)行此命名約定。 向上滾動到上面的沙盒并將 useOnlineStatus 重命名為 getOnlineStatus。 請注意,linter 將不再允許您在其中調(diào)用 useState 或 useEffect。 只有 Hooks 和組件才能調(diào)用其他 Hooks!
深度閱讀:渲染期間調(diào)用的所有函數(shù)是否都應以 use 前綴開頭?
不,不調(diào)用 Hooks 的函數(shù)不需要是 Hooks。
如果您的函數(shù)不調(diào)用任何 Hook,請避免使用前綴。 相反,將其編寫為不帶 use 前綴的常規(guī)函數(shù)。 例如,下面的 useSorted 不調(diào)用 Hooks,所以調(diào)用它 getSorted :
// ?? Avoid: A Hook that doesn't use Hooks function useSorted(items) { return items.slice().sort(); } // ? Good: A regular function that doesn't use Hooks function getSorted(items) { return items.slice().sort(); }這確保您的代碼可以在任何地方調(diào)用此常規(guī)函數(shù),包括以下條件:
function List({ items, shouldSort }) { let displayedItems = items; if (shouldSort) { // ? It's ok to call getSorted() conditionally because it's not a Hook displayedItems = getSorted(items); } // ... }如果函數(shù)內(nèi)部至少使用了一個 Hook,則應該為函數(shù)提供 use 前綴(從而使其成為 Hook):
// ? Good: A Hook that uses other Hooks function useAuth() { return useContext(Auth); }從技術(shù)上講,這不是由 React 強制執(zhí)行的。 原則上,您可以創(chuàng)建一個不調(diào)用其他 Hook 的 Hook。 這通常會造成混淆和限制,因此最好避免這種模式。 但是,在極少數(shù)情況下它可能會有幫助。 例如,也許你的函數(shù)現(xiàn)在沒有使用任何 Hooks,但你計劃在將來向它添加一些 Hook 調(diào)用。 然后用 use 前綴命名它是有意義的:
// ? Good: A Hook that will likely use some other Hooks later function useAuth() { // TODO: Replace with this line when authentication is implemented: // return useContext(Auth); return TEST_USER; }那么組件將無法有條件地調(diào)用它。 當您實際在其中添加 Hook 調(diào)用時,這將變得很重要。 如果您不打算在其中使用 Hooks(現(xiàn)在或以后),請不要將其設為 Hook。
自定義掛鉤讓您共享狀態(tài)邏輯,而不是狀態(tài)本身
在前面的示例中,當您打開和關(guān)閉網(wǎng)絡時,兩個組件會一起更新。 但是,認為它們之間共享單個 isOnline 狀態(tài)變量的想法是錯誤的。 看看這段代碼:
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(() => {
// ...
}, []);
// ...
}
這是兩個完全獨立的狀態(tài)變量和Effects! 它們只是碰巧同時具有相同的值,因為您將它們與相同的外部值同步(無論網(wǎng)絡是否打開)。
為了更好地說明這一點,我們需要一個不同的例子。 考慮這個 Form 組件:
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>
</>
);
}
每個表單字段都有一些重復的邏輯:
- 有一個狀態(tài)(firstName 和 lastName)。
- 有一個更改處理程序(handleFirstNameChange 和 handleLastNameChange)。
- 有一段 JSX 指定該輸入的 value 和 onChange 屬性。
您可以將重復邏輯提取到這個 useFormInput 自定義 Hook 中:
useFormInput.js
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;
}
App.js
import { useFormInput } from './useFormInput.js';
export default function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
return (
<>
<label>
First name:
<input {...firstNameProps} />
</label>
<label>
Last name:
<input {...lastNameProps} />
</label>
<p><b>Good morning, {firstNameProps.value} {lastNameProps.value}.</b></p>
</>
);
}
請注意,它只聲明了一個名為 value 的狀態(tài)變量。
但是,F(xiàn)orm 組件調(diào)用了兩次 useFormInput:
function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
// ...
這就是為什么它像聲明兩個獨立的狀態(tài)變量一樣工作!
自定義掛鉤讓您共享有狀態(tài)邏輯,但不能共享狀態(tài)本身。 對 Hook 的每次調(diào)用都完全獨立于對同一 Hook 的所有其他調(diào)用。 這就是為什么上面的兩個沙箱是完全等價的。 如果您愿意,請向上滾動并比較它們。 提取自定義 Hook 前后的行為是相同的。
當您需要在多個組件之間共享狀態(tài)本身時,請將其提升并向下傳遞。
在 Hook 之間傳遞反應值
自定義 Hooks 中的代碼將在每次重新渲染組件時重新運行。 這就是為什么像組件一樣,自定義 Hooks 需要是純的。 將自定義 Hooks 代碼視為組件主體的一部分!
因為自定義 Hooks 與您的組件一起重新渲染,所以它們總是收到最新的道具和狀態(tài)。 要了解這意味著什么,請考慮這個聊天室示例。 更改服務器 URL 或選定的聊天室:
App.js
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
export default function App() {
const [roomId, setRoomId] = useState('general');
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<hr />
<ChatRoom
roomId={roomId}
/>
</>
);
}
ChatRoom.js
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>
</>
);
}
chat.js
export function createConnection({ serverUrl, roomId }) {
// A real implementation would actually connect to the server
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('? Connecting to "' + roomId + '" room at ' + serverUrl + '...');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('? Disconnected from "' + roomId + '" room at ' + serverUrl + '');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
notifications.js
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme = 'dark') {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
當您更改 serverUrl 或 roomId 時,Effect 會對您的更改做出“反應”并重新同步。 您可以通過控制臺消息得知,每次您更改 Effect 的依賴項時,聊天都會重新連接。
現(xiàn)在將 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 組件可以調(diào)用您的自定義 Hook,而不必擔心它在內(nèi)部是如何工作的:
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>
</>
);
}
這看起來簡單多了! (但它做同樣的事情。)
請注意,邏輯仍然響應 prop 和狀態(tài)的變化。 嘗試編輯服務器 URL 或所選房間:
App,js
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
export default function App() {
const [roomId, setRoomId] = useState('general');
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<hr />
<ChatRoom
roomId={roomId}
/>
</>
);
}
ChatRoom.js
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>
</>
);
}
useChatRoom.js
import { useEffect } from 'react';
import { createConnection } from './chat.js';
import { showNotification } from './notifications.js';
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]);
}
chat.js
export function createConnection({ serverUrl, roomId }) {
// A real implementation would actually connect to the server
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('? Connecting to "' + roomId + '" room at ' + serverUrl + '...');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('? Disconnected from "' + roomId + '" room at ' + serverUrl + '');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
notifications.js
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme = 'dark') {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
注意你是如何獲取一個 Hook 的返回值的:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
并將其作為輸入傳遞給另一個 Hook:
export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://localhost:1234');
useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...
每次您的 ChatRoom 組件重新呈現(xiàn)時,它都會將最新的 roomId 和 serverUrl 傳遞給您的 Hook。 這就是為什么只要重新渲染后它們的值不同,您的 Effect 就會重新連接到聊天。 (如果你曾經(jīng)使用過音樂處理軟件,像這樣鏈接 Hooks 可能會讓你想起鏈接多個音頻效果,比如添加混響或合唱。就好像 useState 的輸出“饋入”useChatRoom 的輸入。)
將事件處理程序傳遞給自定義 Hooks
構(gòu)建中
本節(jié)描述了一個尚未添加到 React 中的實驗性 API,因此您還不能使用它。
當您開始在更多組件中使用 useChatRoom 時,您可能希望讓不同的組件自定義其行為。 例如,目前,消息到達時執(zhí)行的操作的邏輯被硬編碼在 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]);
}
假設您想將此邏輯移回您的組件:
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]); // ? All dependencies declared
}
這會起作用,但是當您的自定義 Hook 接受事件處理程序時,您還可以進行另一項改進。
添加對 onReceiveMessage 的依賴并不理想,因為每次組件重新呈現(xiàn)時都會導致聊天重新連接。 將此事件處理程序包裝到 Effect Event 中以將其從依賴項中刪除:
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]); // ? All dependencies declared
}
現(xiàn)在聊天不會在每次 ChatRoom 組件重新呈現(xiàn)時都重新連接。 這是將事件處理程序傳遞給您可以使用的自定義 Hook 的完整工作演示:
App.js
import { useState } from 'react';
import ChatRoom from './ChatRoom.js';
export default function App() {
const [roomId, setRoomId] = useState('general');
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<hr />
<ChatRoom
roomId={roomId}
/>
</>
);
}
ChatRoom.js
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.js
import { useEffect } from 'react';
import { experimental_useEffectEvent as useEffectEvent } from 'react';
import { createConnection } from './chat.js';
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]);
}
chat.js
export function createConnection({ serverUrl, roomId }) {
// A real implementation would actually connect to the server
if (typeof serverUrl !== 'string') {
throw Error('Expected serverUrl to be a string. Received: ' + serverUrl);
}
if (typeof roomId !== 'string') {
throw Error('Expected roomId to be a string. Received: ' + roomId);
}
let intervalId;
let messageCallback;
return {
connect() {
console.log('? Connecting to "' + roomId + '" room at ' + serverUrl + '...');
clearInterval(intervalId);
intervalId = setInterval(() => {
if (messageCallback) {
if (Math.random() > 0.5) {
messageCallback('hey')
} else {
messageCallback('lol');
}
}
}, 3000);
},
disconnect() {
clearInterval(intervalId);
messageCallback = null;
console.log('? Disconnected from "' + roomId + '" room at ' + serverUrl + '');
},
on(event, callback) {
if (messageCallback) {
throw Error('Cannot add the handler twice.');
}
if (event !== 'message') {
throw Error('Only "message" event is supported.');
}
messageCallback = callback;
},
};
}
notifications.js
import Toastify from 'toastify-js';
import 'toastify-js/src/toastify.css';
export function showNotification(message, theme = 'dark') {
Toastify({
text: message,
duration: 2000,
gravity: 'top',
position: 'right',
style: {
background: theme === 'dark' ? 'black' : 'white',
color: theme === 'dark' ? 'white' : 'black',
},
}).showToast();
}
請注意,您不再需要了解 useChatRoom 的工作原理即可使用它。 您可以將它添加到任何其他組件,傳遞任何其他選項,并且它會以相同的方式工作。 這就是自定義 Hooks 的力量。
何時使用自定義 Hooks
你不需要為每一小段重復的代碼提取自定義 Hook。 一些重復是好的。 例如,提取一個 useFormInput Hook 來包裝單個 useState 調(diào)用可能是不必要的。
但是,每當您編寫 Effect 時,請考慮將其包裝在自定義 Hook 中是否會更清晰。 你不應該經(jīng)常需要 Effects,所以如果你正在寫一個,這意味著你需要“走出 React”以與一些外部系統(tǒng)同步或做一些 React 沒有內(nèi)置 API 的事情 . 將 Effect 包裝到自定義 Hook 中可以讓您精確地傳達您的意圖以及數(shù)據(jù)如何流經(jīng)它。
例如,考慮顯示兩個下拉列表的 ShippingForm 組件:一個顯示城市列表,另一個顯示所選城市的區(qū)域列表。 您可能會從一些看起來像這樣的代碼開始:
function ShippingForm({ country }) {
const [cities, setCities] = useState(null);
// This Effect fetches cities for a country
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);
// This Effect fetches areas for the selected city
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。 相反,您可以通過將它們之間的通用邏輯提取到您自己的 useData Hook 中來簡化上面的 ShippingForm 組件:
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;
}
現(xiàn)在,您可以將 ShippingForm 組件中的兩個 Effects 替換為對 useData 的調(diào)用:
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 使數(shù)據(jù)流顯式。 您輸入 url,然后獲取數(shù)據(jù)。 通過將您的效果“隱藏”在 useData 中,您還可以防止處理 ShippingForm 組件的人員向其添加不必要的依賴項。 理想情況下,隨著時間的推移,您應用程序的大部分效果都將在自定義 Hooks 中。
深度閱讀:讓您的自定義 Hooks 專注于具體的高級用例
首先選擇您的自定義 Hook 的名稱。 如果您難以選擇一個清晰的名稱,則可能意味著您的 Effect 與組件邏輯的其余部分過于耦合,并且尚未準備好提取。
理想情況下,您的自定義 Hook 的名稱應該足夠清晰,即使是不經(jīng)常編寫代碼的人也可以很好地猜測您的自定義 Hook 的作用、獲取的內(nèi)容以及返回的內(nèi)容:
- ? useData(url)
- ? useImpressionLog(eventName, extraData)
- ? useChatRoom(options)
當您與外部系統(tǒng)同步時,您的自定義 Hook 名稱可能更具技術(shù)性并使用特定于該系統(tǒng)的行話。 只要熟悉該系統(tǒng)的人清楚就可以了:
- ? useMediaQuery(query)
- ? useSocket(url)
- ? useIntersectionObserver(ref, options)
讓自定義 Hooks 專注于具體的高級用例。 避免創(chuàng)建和使用自定義“生命周期”掛鉤,這些掛鉤充當 useEffect API 本身的替代和便利包裝器:
- ?? useMount(fn)
- ?? useEffectOnce(fn)
- ?? useUpdateEffect(fn)
例如,這個 useMount Hook 試圖確保一些代碼只在“掛載”時運行:
function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // ?? Avoid: using custom "lifecycle" Hooks useMount(() => { const connection = createConnection({ roomId, serverUrl }); connection.connect(); post('/analytics/event', { eventName: 'visit_chat' }); }); // ... } // ?? Avoid: creating custom "lifecycle" Hooks function useMount(fn) { useEffect(() => { fn(); }, []); // ?? React Hook useEffect has a missing dependency: 'fn' }像 useMount 這樣的自定義“生命周期”Hooks 不太適合 React 范例。 例如,此代碼示例有一個錯誤(它不會對 roomId 或 serverUrl 更改做出“反應”),但 linter 不會就此向您發(fā)出警告,因為 linter 僅檢查直接 useEffect 調(diào)用。 它不會知道你的 Hook。
如果您正在編寫 Effect,請直接使用 React API 開始:
function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // ? Good: two raw Effects separated by purpose useEffect(() => { const connection = createConnection({ serverUrl, roomId }); connection.connect(); return () => connection.disconnect(); }, [serverUrl, roomId]); useEffect(() => { post('/analytics/event', { eventName: 'visit_chat', roomId }); }, [roomId]); // ... }然后,您可以(但不必)為不同的高級用例提取自定義 Hooks:
function ChatRoom({ roomId }) { const [serverUrl, setServerUrl] = useState('https://localhost:1234'); // ? Great: custom Hooks named after their purpose useChatRoom({ serverUrl, roomId }); useImpressionLog('visit_chat', { roomId }); // ... }一個好的自定義 Hook 通過限制調(diào)用代碼的作用,使調(diào)用代碼更具聲明性。 例如,useChatRoom(options) 只能連接到聊天室,而 useImpressionLog(eventName, extraData) 只能將印象日志發(fā)送到分析。 如果您的自定義 Hook API 不限制用例并且非常抽象,那么從長遠來看,它可能會引入比解決的問題更多的問題。
自定義 Hooks 幫助您遷移到更好的模式
Effects 是一個“逃生艙口”:當你需要“走出 React”并且沒有更好的內(nèi)置解決方案適合你的用例時,你可以使用它們。 隨著時間的推移,React 團隊的目標是通過為更具體的問題提供更具體的解決方案,將應用程序中的效果數(shù)量減少到最少。 在這些解決方案可用時,在自定義 Hooks 中包裝 Effects 可以更輕松地升級您的代碼。 讓我們回到這個例子:
App.js
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 />
</>
);
}
useOnlineStatus.js
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 來實現(xiàn)的。 然而,這并不是最好的解決方案。 它沒有考慮許多邊緣情況。 例如,它假定當組件掛載時,isOnline 已經(jīng)為真,但如果網(wǎng)絡已經(jīng)離線,這可能是錯誤的。 您可以使用瀏覽器 navigator.onLine API 來檢查它,但如果您在服務器上運行 React 應用程序以生成初始 HTML,直接使用它會中斷。 簡而言之,這段代碼可以改進。
幸運的是,React 18 包含一個名為 useSyncExternalStore 的專用 API,它可以為您解決所有這些問題。 以下是您的 useOnlineStatus Hook 重寫以利用這個新 API 的方式:
App.js
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 />
</>
);
}
useOnlineStatus.js
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, // How to get the value on the client
() => true // How to get the value on the server
);
}
請注意您無需更改任何組件即可進行此遷移:
function StatusBar() {
const isOnline = useOnlineStatus();
// ...
}
function SaveButton() {
const isOnline = useOnlineStatus();
// ...
}
這是為什么在自定義 Hooks 中包裝 Effects 通常是有益的另一個原因:
- 您使流入和流出 Effects 的數(shù)據(jù)非常明確。
- 您讓您的組件專注于意圖而不是 Effects 的確切實現(xiàn)。
- 當 React 添加新功能時,您可以刪除這些 Effects 而無需更改任何組件。
與設計系統(tǒng)類似,您可能會發(fā)現(xiàn)開始將應用程序組件中的常用習語提取到自定義 Hook 中會很有幫助。 這將使您的組件代碼專注于意圖,并讓您避免經(jīng)常編寫原始效果。 React 社區(qū)也維護了很多優(yōu)秀的自定義 Hooks。
深度閱讀:React 會提供任何內(nèi)置的數(shù)據(jù)獲取解決方案嗎?
我們?nèi)栽谥贫毠?jié),但我們希望將來您可以像這樣編寫數(shù)據(jù)獲?。?/p>
import { use } from 'react'; // Not available yet! 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 這樣的自定義 Hooks,那么與您在每個組件中手動編寫原始 Effects 相比,遷移到最終推薦的方法所需的更改更少。 但是,舊方法仍然可以正常工作,所以如果您喜歡編寫原始效果,則可以繼續(xù)這樣做。
有不止一種方法可以做到
假設您想使用瀏覽器 requestAnimationFrame API 從頭開始實現(xiàn)淡入動畫。 您可以從設置動畫循環(huán)的 Effect 開始。 在動畫的每一幀中,您可以更改您在 ref 中保存的 DOM 節(jié)點的不透明度,直到它達到 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) {
// We still have more frames to paint
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 />}
</>
);
}
為了使組件更具可讀性,您可以將邏輯提取到 useFadeIn 自定義 Hook 中:
App.js
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.js
import { useEffect } from 'react';
export function useFadeIn(ref, duration) {
useEffect(() => {
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) {
// We still have more frames to paint
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();
}, [ref, duration]);
}
您可以保持 useFadeIn 代碼不變,但您也可以對其進行更多重構(gòu)。 例如,您可以將用于設置動畫循環(huán)的邏輯從 useFadeIn 中提取到一個名為 useAnimationLoop 的新自定義 Hook 中:
App.js
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.js
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]);
}
但是,您不必那樣做。 與常規(guī)函數(shù)一樣,最終您決定在何處劃定代碼不同部分之間的界限。 例如,您也可以采用非常不同的方法。 您可以將大部分命令式邏輯移動到 JavaScript 類中,而不是將邏輯保留在 Effect 中:
App.js
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.js
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]);
}
animation.js
export class FadeInAnimation {
constructor(node) {
this.node = node;
}
start(duration) {
this.duration = duration;
this.onProgress(0);
this.startTime = performance.now();
this.frameId = requestAnimationFrame(() => this.onFrame());
}
onFrame() {
const timePassed = performance.now() - this.startTime;
const progress = Math.min(timePassed / this.duration, 1);
this.onProgress(progress);
if (progress === 1) {
this.stop();
} else {
// We still have more frames to paint
this.frameId = requestAnimationFrame(() => this.onFrame());
}
}
onProgress(progress) {
this.node.style.opacity = progress;
}
stop() {
cancelAnimationFrame(this.frameId);
this.startTime = null;
this.frameId = null;
this.duration = 0;
}
}
Effects 讓你可以將 React 連接到外部系統(tǒng)。 Effects 之間需要的協(xié)調(diào)越多(例如,鏈接多個動畫),就像在上面的沙箱中一樣,完全從 Effects 和 Hooks 中提取邏輯就越有意義。 然后,您提取的代碼成為“外部系統(tǒng)”。 這讓您的 Effects 保持簡單,因為它們只需要將消息發(fā)送到您在 React 之外移動的系統(tǒng)。
上面的示例假設淡入邏輯需要用 JavaScript 編寫。 然而,這個特殊的淡入動畫使用純 CSS 動畫來實現(xiàn)既簡單又高效:
App.js
import { useState, useEffect, useRef } from 'react';
import './welcome.css';
function Welcome() {
return (
<h1 className="welcome">
Welcome
</h1>
);
}
export default function App() {
const [show, setShow] = useState(false);
return (
<>
<button onClick={() => setShow(!show)}>
{show ? 'Remove' : 'Show'}
</button>
<hr />
{show && <Welcome />}
</>
);
}
welcome.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!
- 自定義掛鉤讓您可以在組件之間共享邏輯。
- 自定義掛鉤的名稱必須以 use 開頭,后跟大寫字母。
- 自定義 Hooks 只共享狀態(tài)邏輯,而不是狀態(tài)本身。
- 您可以將反應值從一個 Hook 傳遞到另一個 Hook,并且它們會保持最新。
- 每次您的組件重新渲染時,所有 Hooks 都會重新運行。
- 你的自定義 Hooks 的代碼應該是純凈的,就像你的組件的代碼一樣。
- 將自定義 Hook 接收到的事件處理程序包裝到 Effect Events 中。
- 不要創(chuàng)建像 useMount 這樣的自定義 Hooks。 保持他們的目的明確。
- 如何以及在何處選擇代碼邊界取決于您。