使用自定义 Hook 复用逻辑

React 自带了一些内置的 Hook,例如 useStateuseContextuseEffect。有时,你可能希望有一个 Hook 能用于更具体的用途:例如,获取数据、跟踪用户是否在线或连接到聊天室。你可能无法在 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 的繁琐细节。你的组件代码表达了你的意图,而不是实现。

钩子名称始终以 use 开头。

React 应用程序由组件构成。组件由钩子构成,无论是内置钩子还是自定义钩子。你可能会经常使用其他人创建的自定义钩子,但偶尔你可能也会自己写一个!

你必须遵循以下命名约定

  1. React 组件名称必须以大写字母开头, 例如 StatusBarSaveButton。React 组件还需要返回 React 知道如何显示的内容,例如一段 JSX。
  2. 钩子名称必须以 use 开头,后跟一个大写字母, 例如 useState(内置)或 useOnlineStatus(自定义,如本页前面所示)。钩子可以返回任意值。

此约定可确保你始终可以通过查看组件来了解其状态、副作用和其他 React 功能可能“隐藏”的位置。例如,如果你在组件中看到 getColor() 函数调用,则可以确定它内部不可能包含 React 状态,因为其名称不是以 use 开头的。但是,像 useOnlineStatus() 这样的函数调用很可能包含对内部其他钩子的调用!

注意

如果你的 linter 已 针对 React 进行配置,它将强制执行此命名约定。向上滚动到上面的沙盒,并将 useOnlineStatus 重命名为 getOnlineStatus。请注意,linter 将不允许你在其中调用 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');
// ...

这就是为什么它像声明两个独立的状态变量一样工作!

自定义钩子允许你共享有状态逻辑,但不能共享状态本身。对钩子的每次调用都完全独立于对同一钩子的所有其他调用。 这就是为什么上面的两个沙盒完全等效的原因。如果你愿意,可以向上滚动并比较它们。提取自定义钩子之前和之后的行为是相同的。

当你需要在多个组件之间共享状态本身时,请 将其提升并传递下去

在 Hooks 之间传递响应式值

自定义 Hook 中的代码会在组件每次重新渲染时重新运行。这就是为什么,像组件一样,自定义 Hook 需要是纯函数。 将自定义 Hook 的代码视为组件主体的一部分!

因为自定义 Hook 会与组件一起重新渲染,所以它们总是接收最新的 props 和 state。要了解这意味着什么,请考虑这个聊天室示例。更改服务器 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>
</>
);
}

这看起来简单多了!(但它做的事情是一样的。)

请注意,逻辑仍然会响应 props 和 state 的更改。尝试编辑服务器 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 中是否会更清晰。你不应该经常需要 Effect, 因此,如果你正在编写一个 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 的变化做出“反应”),但 linter 不会警告你,因为它只检查直接的 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 帮助你迁移到更好的模式

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 是用一对 useStateuseEffect 实现的。然而,这不是最好的解决方案。它没有考虑到许多边缘情况。例如,它假设当组件挂载时,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, // 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();
// ...
}

这是为什么将 Effect 封装在自定义 Hook 中通常是有益的另一个原因。

  1. 你可以使进出 Effect 的数据流非常明确。
  2. 你可以让你的组件专注于意图,而不是专注于 Effect 的确切实现。
  3. 当 React 添加新功能时,你可以删除那些 Effect,而无需更改任何组件。

类似于 设计系统,你可能会发现将应用程序组件中的常见习惯用法提取到自定义 Hook 中很有帮助。这将使你的组件代码专注于意图,并让你避免经常编写原始的 Effect。React 社区维护着许多优秀的自定义 Hook。

深入探讨

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 这样的自定义 Hook,那么与在每个组件中手动编写原始 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) {
        // 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 />}
    </>
  );
}

为了使组件更具可读性,你可以将逻辑提取到自定义 Hook useFadeIn

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 中提取到自定义 Hook useAnimationLoop

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 中,而不是将其保留在 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]);
}

Effects 允许你将 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!

回顾

  • 自定义 Hook 允许你在组件之间共享逻辑。
  • 自定义 Hook 的名称必须以 use 开头,后跟一个大写字母。
  • 自定义 Hook 仅共享有状态逻辑,而不共享状态本身。
  • 你可以在 Hook 之间传递响应式值,并且它们会保持最新状态。
  • 每次组件重新渲染时,所有 Hook 都会重新运行。
  • 你的自定义 Hook 的代码应该是纯净的,就像你的组件代码一样。
  • 将自定义 Hook 接收到的事件处理程序包装到 Effect 事件中。
  • 不要创建像 useMount 这样的自定义 Hook。 保持它们的用途特定。
  • 如何以及在哪里选择代码的边界取决于你。

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