v19.2Latest

使用自定义 Hook 复用逻辑

React 内置了多个 Hook,例如useStateuseContextuseEffect。有时,你可能希望有一个用于更特定目的的 Hook:例如,用于获取数据、跟踪用户是否在线或连接到聊天室。你可能在 React 中找不到这些 Hook,但你可以为自己的应用需求创建自定义 Hook。

你将学习
  • 什么是自定义 Hook,以及如何编写自己的自定义 Hook
  • 如何在组件之间复用逻辑
  • 如何命名和构建你的自定义 Hook
  • 何时以及为何要提取自定义 Hook

自定义 Hook:在组件之间共享逻辑

假设你正在开发一个严重依赖网络的应用(就像大多数应用一样)。你希望当用户在使用你的应用时网络连接意外断开时,能够警告用户。你会如何处理?看起来你的组件中需要两样东西:

  1. 一个用于跟踪网络是否在线的状态片段。
  2. 一个订阅全局 onlineoffline事件并更新该状态的 Effect。

这将使你的组件与网络状态保持同步。你可能会从类似这样的代码开始:

尝试打开和关闭你的网络,并注意这个 StatusBar如何响应你的操作而更新。

现在假设你想在另一个组件中使用相同的逻辑。你想实现一个保存按钮,当网络断开时,该按钮将被禁用并显示“重新连接中...”而不是“保存”。

首先,你可以将 isOnline状态和 Effect 复制粘贴到SaveButton中:

验证一下,如果你关闭网络,按钮的外观将会改变。

这两个组件运行良好,但它们之间的逻辑重复令人遗憾。看起来,尽管它们具有不同的 视觉外观,你仍希望在它们之间复用逻辑。

从组件中提取你自己的自定义 Hook

想象一下,如果像 useStateuseEffect一样,有一个内置的useOnlineStatusHook。那么这两个组件都可以简化,并且你可以消除它们之间的重复:

虽然没有这样的内置 Hook,但你可以自己编写。声明一个名为useOnlineStatus的函数,并将之前编写的组件中的所有重复代码移入其中:

在函数末尾,返回 isOnline。这可以让你的组件读取该值:

验证切换网络开关是否更新了两个组件。

现在你的组件中没有那么多重复的逻辑了。更重要的是,组件内部的代码描述的是它们想要做什么(使用在线状态!)而不是如何去做(通过订阅浏览器事件)。

当你将逻辑提取到自定义 Hook 中时,你可以隐藏处理某些外部系统或浏览器 API 的复杂细节。你的组件代码表达的是意图,而不是实现。

Hook 名称总是以use

React 应用由组件构建。组件由 Hook 构建,无论是内置的还是自定义的。你可能会经常使用他人创建的自定义 Hook,但偶尔你也可能需要自己编写一个!

你必须遵循以下命名约定:

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

这个约定保证了你可以随时查看一个组件,并知道它的状态、Effect 和其他 React 功能可能“隐藏”在哪里。例如,如果你在组件内部看到一个getColor() 函数调用,你可以确定它内部不可能包含 React 状态,因为它的名称不是以 use 开头。然而,像 useOnlineStatus()这样的函数调用很可能内部包含了对其他 Hook 的调用!

注意

如果你的代码检查器已为 React 配置,它将强制执行此命名约定。向上滚动到上面的沙箱,将 useOnlineStatus重命名为getOnlineStatus。注意,代码检查器将不允许你在其中调用useStateuseEffect。只有 Hook 和组件才能调用其他 Hook!

Deep Dive
所有在渲染期间调用的函数都必须以 use 开头吗?

自定义 Hook 让你共享有状态的逻辑,而非状态本身

在前面的例子中,当你打开和关闭网络时,两个组件会一起更新。然而,认为它们之间共享了一个单一的isOnline 状态变量是错误的。看看这段代码:

它的工作方式与你提取重复代码之前相同:

这是两个完全独立的状态变量和 Effect!它们恰好在同一时间具有相同的值,因为你用相同的外部值(网络是否开启)同步了它们。

为了更好地说明这一点,我们需要一个不同的例子。考虑这个Form 组件:

每个表单字段都有一些重复的逻辑:

  1. 有一个状态片段(firstNamelastName)。
  2. 有一个变更处理函数(handleFirstNameChangehandleLastNameChange)。
  3. 有一段 JSX 为该输入指定了valueonChange 属性。

你可以将重复的逻辑提取到这个 useFormInput自定义 Hook 中:

请注意,它只声明了一个名为 value的状态变量。

然而,Form 组件两次调用 useFormInput

这就是为什么它的工作原理类似于声明两个独立的状态变量!

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

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

在 Hook 之间传递响应式值

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

因为自定义 Hook 与你的组件一起重新渲染,它们总是接收到最新的 props 和 state。要理解这意味着什么,请看这个聊天室示例。更改服务器 URL 或聊天室:

当你更改 serverUrlroomId时,Effect 会“响应”你的更改并重新同步。你可以通过控制台消息看出,每次更改 Effect 的依赖项时,聊天都会重新连接。

现在将 Effect 的代码移入自定义 Hook:

这可以让你的ChatRoom组件调用你的自定义 Hook,而无需担心其内部工作原理:

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

注意,逻辑 仍然会响应props 和 state 的变化。尝试编辑服务器 URL 或选中的聊天室:

请注意,您是如何将一个 Hook 的返回值:

作为输入传递给另一个 Hook 的:

每当您的 ChatRoom组件重新渲染时,它都会将最新的roomIdserverUrl传递给您的 Hook。这就是为什么当它们的值在重新渲染后发生变化时,您的 Effect 会重新连接到聊天室。(如果您曾经使用过音频或视频处理软件,像这样链式调用 Hook 可能会让您想起链式视觉或音频效果。就好像useState 的输出“馈入”了 useChatRoom的输入。)

向自定义 Hook 传递事件处理函数

当您开始在更多组件中使用useChatRoom 时,您可能希望让组件自定义其行为。例如,目前,当消息到达时执行什么逻辑是在 Hook 内部硬编码的:

假设您希望将此逻辑移回您的组件:

为了实现这一点,请修改您的自定义 Hook,使其接受onReceiveMessage作为其命名选项之一:

这可以工作,但当您的自定义 Hook 接受事件处理函数时,您还可以进行一项改进。

onReceiveMessage添加为依赖项并不理想,因为它会导致每次组件重新渲染时聊天室都会重新连接。将此事件处理函数包装到 Effect Event 中,以将其从依赖项中移除:

现在,每次 ChatRoom组件重新渲染时,聊天室都不会重新连接。这是一个可以向自定义 Hook 传递事件处理函数的完整工作示例,您可以尝试一下:

请注意,你不再需要了解 如何useChatRoom的工作原理就能使用它。你可以将其添加到任何其他组件中,传递任何其他选项,它都会以相同的方式工作。这就是自定义 Hook 的强大之处。

何时使用自定义 Hook

你不需要为每一小段重复的代码都提取一个自定义 Hook。有些重复是可以接受的。例如,像之前那样提取一个useFormInputHook 来包装单个useState 调用可能是不必要的。

然而,每当你编写一个 Effect 时,都应该考虑将其包装在自定义 Hook 中是否会更加清晰。你并不需要经常使用 Effect,所以如果你正在编写一个,就意味着你需要“跳出 React”来与某个外部系统同步,或者做一些 React 没有内置 API 的事情。将其包装到自定义 Hook 中可以让你精确地传达你的意图以及数据如何流经它。

例如,考虑一个 ShippingForm 组件,它显示两个下拉菜单:一个显示城市列表,另一个显示所选城市的区域列表。你可能会从类似这样的代码开始:

尽管这段代码相当重复,但将这些 Effect 彼此分开是正确的。它们同步的是两个不同的东西,所以你不应该将它们合并到一个 Effect 中。相反,你可以通过提取它们之间的通用逻辑到你自己的useData Hook 中来简化上面的 ShippingForm组件:

现在,你可以将 ShippingForm组件中的两个 Effect 都替换为对useData的调用:

提取自定义 Hook 使得数据流变得明确。你传入url,然后得到 data。通过将你的 Effect “隐藏”在useData 内部,你还可以防止处理 ShippingForm组件的人为其添加不必要的依赖项。随着时间的推移,你应用中的大多数 Effect 都将位于自定义 Hook 中。

Deep Dive
保持自定义 Hook 专注于具体的高级用例

自定义 Hook 帮助你迁移到更好的模式

Effect 是一个“逃生舱口”:当你需要“跳出 React”并且没有更好的内置解决方案来满足你的用例时,你会使用它们。随着时间的推移,React 团队的目标是通过为更具体的问题提供更具体的解决方案,将你应用中的 Effect 数量减少到最低限度。将你的 Effect 包装在自定义 Hook 中,使得在这些解决方案可用时更容易升级你的代码。

让我们回到这个例子:

在上面的例子中,useOnlineStatus 是通过一对 useStateuseEffect 实现的。然而,这并不是最佳的解决方案。它没有考虑许多边界情况。例如,它假设组件挂载时,isOnline 已经是 true,但如果网络已经离线,这可能是错误的。你可以使用浏览器的navigator.onLineAPI 来检查,但直接使用它无法在服务器上生成初始 HTML。简而言之,这段代码可以改进。

React 包含一个名为useSyncExternalStore的专用 API,它可以为你处理所有这些问题。以下是你的useOnlineStatusHook,重写后利用了这项新 API:

请注意,您无需更改任何组件即可完成此迁移:

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

  1. 您可以非常明确地表达进出 Effect 的数据流。
  2. 您可以让组件专注于意图,而不是 Effect 的具体实现。
  3. 当 React 添加新功能时,您可以移除这些 Effect 而无需更改任何组件。

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

Deep Dive
React 会为数据获取提供内置解决方案吗?

实现方式不止一种

假设您想使用浏览器从头开始您可以从一个设置动画循环的 Effect 开始requestAnimationFrame API保存在 ref 中的DOM 节点的透明度,直到它达到1。您的代码可能像这样开始:

为了使组件更具可读性,您可以将逻辑提取到一个useFadeIn自定义 Hook 中:

您可以保持useFadeIn代码不变,但也可以进一步重构它。例如,您可以将设置动画循环的逻辑从useFadeIn中提取到一个自定义的useAnimationLoopHook 中:

然而,你并不需要这样做。就像普通函数一样,最终由你决定代码不同部分之间的边界在哪里。你也可以采用完全不同的方法。与其将逻辑保留在 Effect 中,不如将大部分命令式逻辑移入一个 JavaScript类中:

Effect 让你能够将 React 与外部系统连接起来。Effect 之间需要的协调越多(例如,为了链接多个动画),就越应该将逻辑从 Effect 和 Hook 中完全提取出来,就像上面的沙盒示例那样。然后,你提取的代码就变成了“外部系统”。这能让你的 Effect 保持简单,因为它们只需要向你移到 React 外部的系统发送消息。

上面的示例假设淡入逻辑需要用 JavaScript 编写。然而,这种特定的淡入动画如果用普通的 CSS 动画来实现会更简单且高效得多:

有时,你甚至不需要 Hook!

回顾

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

Try out some challenges

Challenge 1 of 5:Extract a useCounter Hook #

This component uses a state variable and an Effect to display a number that increments every second. Extract this logic into a custom Hook called useCounter. Your goal is to make the Counter component implementation look exactly like this:

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

You’ll need to write your custom Hook in useCounter.js and import it into the App.js file.