使用自訂 Hook 重複使用邏輯

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

你將學到

  • 什麼是自訂 Hook,以及如何編寫你自己的 Hook
  • 如何在元件之間重複使用邏輯
  • 如何命名和構建你的自訂 Hook
  • 何時以及為何要提取自訂 Hook

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

想像你正在開發一個嚴重依賴網路的應用程式(大多數應用程式都是如此)。如果使用者在使用你的應用程式時網路連線意外斷開,你想要警告他們。你會怎麼做?你的元件中似乎需要兩件事

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

這將使你的元件與網路狀態 同步。你可以從以下程式碼開始

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 如何響應你的動作而更新。

現在想像你*也*想在不同的元件中使用相同的邏輯。你想要實作一個儲存按鈕,當網路關閉時,該按鈕將會停用並顯示「重新連線中...」而不是「儲存」。

首先,你可以將 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>
  );
}

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

這兩個元件運作良好,但它們之間的邏輯重複很可惜。即使它們具有不同的*視覺外觀*,你似乎還是希望在它們之間重複使用邏輯。

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

想像一下,類似於 useStateuseEffect,有一個內建的 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>
);
}

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

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。這讓你的元件可以讀取該值

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 />
    </>
  );
}

驗證開啟和關閉網路是否會更新這兩個元件。

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

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

鉤子名稱 (Hook names) 一律以 use 開頭

React 應用程式是由元件 (Components) 構成的。元件是由鉤子 (Hooks) 構成的,無論是內建的還是自訂的。您可能會經常使用其他人建立的自訂鉤子,但偶爾您也可能需要自己編寫一個!

您必須遵循這些命名慣例

  1. React 元件名稱必須以大寫字母開頭,例如 StatusBarSaveButton。React 元件也需要回傳 React 能夠顯示的內容,例如一段 JSX。
  2. 鉤子名稱必須以 use 開頭,後接一個大寫字母,例如 useState(內建)或 useOnlineStatus(自訂,如頁面稍早所示)。鉤子可以回傳任意值。

此慣例確保您可以隨時查看元件並知道其狀態、副作用 (Effects) 和其他 React 功能可能「隱藏」的位置。例如,如果您在元件內看到 getColor() 函式呼叫,您可以確定它不可能包含 React 狀態,因為它的名稱不是以 use 開頭。然而,像 useOnlineStatus() 這樣的函式呼叫很可能在其內部包含對其他鉤子的呼叫!

備註

如果您的程式碼檢查器 (linter) 已針對 React 進行設定,它將強制執行此命名慣例。向上捲動到上面的沙盒,並將 useOnlineStatus 重新命名為 getOnlineStatus。請注意,程式碼檢查器將不允許您在其中呼叫 useStateuseEffect。只有鉤子和元件可以呼叫其他鉤子!

深入探討

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

不。不*呼叫*鉤子的函式不需要*是*鉤子。

如果您的函式沒有呼叫任何鉤子,請避免使用 use 前綴。相反,將其編寫為*沒有* use 前綴的普通函式。例如,下面的 useSorted 沒有呼叫鉤子,所以應該將其稱為 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();
}

這確保您的程式碼可以在任何地方呼叫此普通函式,包括在條件式中

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);
}
// ...
}

如果函式在其內部使用至少一個鉤子,則應為該函式加上 use 前綴(並使其成為鉤子)

// ✅ Good: A Hook that uses other Hooks
function useAuth() {
return useContext(Auth);
}

技術上來說,React 並未強制執行此規定。原則上,您可以建立一個不呼叫其他鉤子的鉤子。這通常會造成混淆並有限制,因此最好避免這種模式。然而,在極少數情況下,它可能會有幫助。例如,也許您的函式目前沒有使用任何鉤子,但您計劃在將來新增一些鉤子呼叫。那麼,使用 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;
}

那麼元件將無法有條件地呼叫它。當您實際新增鉤子呼叫時,這將變得重要。如果您不打算在其內部使用鉤子(現在或以後),請不要將其設為鉤子。

自訂鉤子讓您可以共用狀態邏輯,而不是狀態本身

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

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(() => {
// ...
}, []);
// ...
}

這是兩個完全獨立的狀態變數和副作用!它們碰巧在同一時間具有相同的值,因為您使用相同外部值(網路是否開啟)同步它們。

為了更好地說明這一點,我們需要一個不同的範例。考慮這個 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>
    </>
  );
}

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

  1. 有一段狀態(firstNamelastName)。
  2. 有一個變更處理器(handleFirstNameChangehandleLastNameChange)。
  3. 有一段 JSX 指定該輸入的 valueonChange 屬性。

您可以將重複的邏輯擷取到這個 useFormInput 自訂鉤子中

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 的狀態變數。

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

function Form() {
const firstNameProps = useFormInput('Mary');
const lastNameProps = useFormInput('Poppins');
// ...

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

自定義 Hook 允許您共享 *狀態邏輯*,但不共享 *狀態本身*。每次呼叫 Hook 都與其他任何對同一個 Hook 的呼叫完全獨立。這就是為什麼上面兩個沙盒完全等效的原因。如果您願意,可以向上捲動並比較它們。提取自定義 Hook 前後的行為是相同的。

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

在 Hook 之間傳遞反應值

自定義 Hook 內的程式碼會在元件每次重新渲染時重新執行。這就是為什麼,像元件一樣,自定義 Hook 需要是純粹的。將自定義 Hook 的程式碼視為元件主體的一部分!

因為自定義 Hook 會與您的元件一起重新渲染,所以它們總是會收到最新的 props 和狀態。要了解這意味著什麼,請考慮這個聊天室範例。更改伺服器 URL 或聊天室

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://127.0.0.1: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>
    </>
  );
}

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

現在將 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 元件可以呼叫您的自定義 Hook,而無需擔心它內部的運作方式

export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://127.0.0.1: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 和狀態的更改。嘗試編輯伺服器 URL 或選定的房間

import { useState } from 'react';
import { useChatRoom } from './useChatRoom.js';

export default function ChatRoom({ roomId }) {
  const [serverUrl, setServerUrl] = useState('https://127.0.0.1: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://127.0.0.1:1234');

useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...

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

export default function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://127.0.0.1:1234');

useChatRoom({
roomId: roomId,
serverUrl: serverUrl
});
// ...

每次您的 ChatRoom 元件重新渲染時,它都會將最新的 roomIdserverUrl 傳遞給您的 Hook。這就是為什麼每當重新渲染後它們的值不同時,您的 Effect 就會重新連線到聊天的原因。(如果您曾經使用過音訊或影片處理軟體,那麼像這樣鏈式 Hook 可能會讓您想起鏈式視覺或音訊效果。就好像 useState 的輸出「饋入」 useChatRoom 的輸入一樣。)

將事件處理程式傳遞給自定義 Hook

建構中

本節描述 **尚未在 React 穩定版本中發布的實驗性 API**。

當您開始在更多元件中使用 useChatRoom 時,您可能希望允許元件自定義其行為。例如,目前,收到訊息時該怎麼做的邏輯被硬編碼在 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://127.0.0.1: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 的依賴性並不理想,因為每次元件重新渲染時都會導致聊天重新連線。將此事件處理程式封裝到 Effect 事件中以將其從依賴項中移除:

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
}

現在,每次 ChatRoom 元件重新渲染時,聊天都不會重新連線。這裡有一個您可以試用的將事件處理程式傳遞給自定義 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://127.0.0.1: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 *如何* 運作才能使用它。您可以將其新增到任何其他元件,傳遞任何其他選項,它都會以相同的方式運作。這就是自定義 Hook 的強大之處。

何時使用自定義 Hook

您不需要為每一點重複的程式碼都提取自定義 Hook。一些重複是可以的。例如,提取一個 useFormInput Hook 來包裝一個單一的 useState 呼叫(如先前所示)可能是不必要的。

但是,每當您編寫 Effect 時,請考慮將其也包裝在自定義 Hook 中是否會更清晰。您不應該經常需要 Effects,因此,如果您正在編寫一個 Effect,則意味著您需要「跳出 React」才能與某些外部系統同步或執行 React 沒有內建 API 的操作。將其包裝到自定義 Hook 中可以讓您精確地傳達您的意圖以及資料如何流經它。

舉例來說,考慮一個顯示兩個下拉選單的 ShippingForm 元件:一個顯示城市列表,另一個顯示所選城市的區域列表。您可能會從如下所示的程式碼開始

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;
}

現在,您可以使用對 useData 的呼叫來取代 ShippingForm 元件中的兩個 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 輸出。透過將您的 Effect「隱藏」在 useData 內,您還可以防止處理 ShippingForm 元件的人向其添加不必要的依賴項。隨著時間的推移,您應用程式的大多數 Effect 將會在自定義 Hook 中。

深入探討

讓您的自定義 Hook 專注於具體的高階用例

首先選擇您的自定義 Hook 的名稱。如果您難以選擇一個清晰的名稱,則可能意味著您的 Effect 與元件其餘邏輯的耦合度過高,尚不適合提取。

理想情況下,您的自定義 Hook 的名稱應該足夠清晰,即使是不常編寫程式碼的人也能很好地猜測您的自定義 Hook 的作用、它需要什麼以及它返回什麼

  • useData(url)
  • useImpressionLog(eventName, extraData)
  • useChatRoom(options)

當您與外部系統同步時,您的自定義 Hook 名稱可能更具技術性,並使用特定於該系統的術語。只要熟悉該系統的人能夠理解,這就很好

  • useMediaQuery(query)
  • useSocket(url)
  • useIntersectionObserver(ref, options)

讓自定義 Hook 專注於具體的高階用例。 避免建立和使用自定義「生命週期」Hook,這些 Hook 作為 useEffect API 本身的替代品和便捷包裝器

  • 🔴 useMount(fn)
  • 🔴 useEffectOnce(fn)
  • 🔴 useUpdateEffect(fn)

例如,這個 useMount Hook 試圖確保某些程式碼僅在「掛載時」運行

function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://127.0.0.1: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 這樣的自定義「生命週期」Hook 並不適合 React 的範例。 例如,這段程式碼範例有一個錯誤(它沒有對 roomIdserverUrl 的更改做出「反應」),但程式碼檢查器不會警告您,因為程式碼檢查器只檢查直接的 useEffect 呼叫。它不會知道您的 Hook。

如果您正在編寫 Effect,請先直接使用 React API

function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://127.0.0.1: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]);

// ...
}

然後,您可以(但不必)針對不同的高階用例提取自定義 Hook

function ChatRoom({ roomId }) {
const [serverUrl, setServerUrl] = useState('https://127.0.0.1:1234');

// ✅ Great: custom Hooks named after their purpose
useChatRoom({ serverUrl, roomId });
useImpressionLog('visit_chat', { roomId });
// ...
}

一個好的自定義 Hook 可以透過限制其功能,使呼叫程式碼更具宣告性。 例如,useChatRoom(options) 只能連接到聊天室,而 useImpressionLog(eventName, extraData) 只能將展示記錄發送到分析系統。如果您的自定義 Hook API 沒有限制用例並且非常抽象,從長遠來看,它可能會引入比解決更多問題。

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

Effects 是一個 「逃生艙口」:當您需要「跳出 React」並且沒有更好的內建解決方案適用於您的用例時,您可以使用它們。隨著時間的推移,React 團隊的目標是透過為更具體的問題提供更具體的解決方案,將應用程式中 Effects 的數量降至最低。將 Effects 封裝在自定義 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 是用一對 useStateuseEffect 實現的。然而,這並不是最佳解決方案。它沒有考慮到許多邊緣情況。例如,它假設當元件掛載時,isOnline 已經是 true,但如果網路已經離線,這可能是錯誤的。您可以使用瀏覽器 navigator.onLine API 來檢查這一點,但直接使用它在伺服器上產生初始 HTML 時將無法正常工作。簡而言之,這段程式碼可以改進。

幸運的是,React 18 包含一個專用的 API,稱為 useSyncExternalStore,它可以幫你解決所有這些問題。以下是如何使用這個新的 API 重寫你的 useOnlineStatus Hook。

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();
// ...
}

這是將 Effects 封裝在自定義 Hooks 中通常很有益的另一個原因。

  1. 你可以讓資料進出 Effects 的流程非常明確。
  2. 你可以讓你的組件專注於意圖,而不是 Effects 的確切實現方式。
  3. 當 React 添加新功能時,你可以移除這些 Effects,而無需更改任何組件。

類似於 設計系統,你可能會發現將應用程式組件中的常用 idioms 提取到自定義 Hooks 中很有幫助。這將使你的組件程式碼保持專注於意圖,並讓你避免經常編寫原始 Effects。React 社區維護著許多優秀的自定義 Hooks。

深入探討

React 會提供任何內建的資料提取解決方案嗎?

我們仍在研究細節,但我們預計在未來,你將會像這樣編寫資料提取程式碼:

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 更少。但是,舊方法仍然可以正常工作,因此如果你覺得編寫原始 Effects 很舒服,你可以繼續這樣做。

條條大路通羅馬

假設你想使用瀏覽器的 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) {
        // 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 中。

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]);
}

但是,你**不必**這樣做。與常規函數一樣,最終由你決定在程式碼的不同部分之間劃分界限。你也可以採取截然不同的方法。與其將邏輯保留在 Effect 中,不如將大部分指令式邏輯移到 JavaScript 類別中:

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]);
}

Effects 讓你將 React 連接到外部系統。Effects 之間需要的協調越多(例如,要鏈接多個動畫),就越有必要像上面的沙盒一樣將該邏輯**完全**從 Effects 和 Hooks 中提取出來。然後,你提取的程式碼就**變成**了「外部系統」。這讓你的 Effects 保持簡單,因為它們只需要向你移到 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!

總結

  • 自定義 Hooks 讓你可以在組件之間共享邏輯。
  • 自定義 Hooks 的名稱必須以 use 開頭,後跟一個大寫字母。
  • 自定義 Hooks 只共享狀態邏輯,而不共享狀態本身。
  • 你可以將反應值從一個 Hook 傳遞到另一個 Hook,它們會保持最新狀態。
  • 每當你的組件重新渲染時,所有 Hooks 都會重新執行。
  • 你的自定義 Hooks 的程式碼應該是純粹的,就像你的組件程式碼一樣。
  • 將自定義 Hooks 接收到的事件處理程式封裝到 Effect 事件中。
  • 不要建立像 useMount 這樣的自定義 Hooks。保持它們的用途明確。
  • 由你決定如何以及在哪裡選擇程式碼的界限。

挑戰 1 5:
提取一個 useCounter Hook

此元件使用一個狀態變數和一個 Effect 來顯示每秒遞增的數字。將此邏輯提取到一個名為 useCounter 的自定義 Hook 中。您的目標是使 Counter 元件的實作看起來完全像這樣

export default function Counter() {
const count = useCounter();
return <h1>Seconds passed: {count}</h1>;
}

您需要在 useCounter.js 中編寫您的自定義 Hook,並將其導入到 App.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>;
}