使用自定义 Hook 复用逻辑
React 内置了多个 Hook,例如useState、useContext 和 useEffect。有时,你可能希望有一个用于更特定目的的 Hook:例如,用于获取数据、跟踪用户是否在线或连接到聊天室。你可能在 React 中找不到这些 Hook,但你可以为自己的应用需求创建自定义 Hook。
你将学习
- 什么是自定义 Hook,以及如何编写自己的自定义 Hook
- 如何在组件之间复用逻辑
- 如何命名和构建你的自定义 Hook
- 何时以及为何要提取自定义 Hook
自定义 Hook:在组件之间共享逻辑
假设你正在开发一个严重依赖网络的应用(就像大多数应用一样)。你希望当用户在使用你的应用时网络连接意外断开时,能够警告用户。你会如何处理?看起来你的组件中需要两样东西:
这将使你的组件与网络状态保持同步。你可能会从类似这样的代码开始:
尝试打开和关闭你的网络,并注意这个 StatusBar如何响应你的操作而更新。
现在假设你还想在另一个组件中使用相同的逻辑。你想实现一个保存按钮,当网络断开时,该按钮将被禁用并显示“重新连接中...”而不是“保存”。
首先,你可以将 isOnline状态和 Effect 复制粘贴到SaveButton中:
验证一下,如果你关闭网络,按钮的外观将会改变。
这两个组件运行良好,但它们之间的逻辑重复令人遗憾。看起来,尽管它们具有不同的 视觉外观,你仍希望在它们之间复用逻辑。
从组件中提取你自己的自定义 Hook
想象一下,如果像 useState 和 useEffect一样,有一个内置的useOnlineStatusHook。那么这两个组件都可以简化,并且你可以消除它们之间的重复:
虽然没有这样的内置 Hook,但你可以自己编写。声明一个名为useOnlineStatus的函数,并将之前编写的组件中的所有重复代码移入其中:
在函数末尾,返回 isOnline。这可以让你的组件读取该值:
验证切换网络开关是否更新了两个组件。
现在你的组件中没有那么多重复的逻辑了。更重要的是,组件内部的代码描述的是它们想要做什么(使用在线状态!)而不是如何去做(通过订阅浏览器事件)。
当你将逻辑提取到自定义 Hook 中时,你可以隐藏处理某些外部系统或浏览器 API 的复杂细节。你的组件代码表达的是意图,而不是实现。
Hook 名称总是以use
React 应用由组件构建。组件由 Hook 构建,无论是内置的还是自定义的。你可能会经常使用他人创建的自定义 Hook,但偶尔你也可能需要自己编写一个!
你必须遵循以下命名约定:
- React 组件名称必须以大写字母开头,例如
StatusBar和SaveButton。React 组件还需要返回 React 知道如何显示的内容,例如一段 JSX。 - Hook 名称必须以
use开头,后跟一个大写字母,例如useState(内置的)或useOnlineStatus(自定义的,如本页前面所示)。Hook 可以返回任意值。
这个约定保证了你可以随时查看一个组件,并知道它的状态、Effect 和其他 React 功能可能“隐藏”在哪里。例如,如果你在组件内部看到一个getColor() 函数调用,你可以确定它内部不可能包含 React 状态,因为它的名称不是以 use 开头。然而,像 useOnlineStatus()这样的函数调用很可能内部包含了对其他 Hook 的调用!
注意
如果你的代码检查器已为 React 配置,它将强制执行此命名约定。向上滚动到上面的沙箱,将 useOnlineStatus重命名为getOnlineStatus。注意,代码检查器将不允许你在其中调用useState 或 useEffect。只有 Hook 和组件才能调用其他 Hook!
自定义 Hook 让你共享有状态的逻辑,而非状态本身
在前面的例子中,当你打开和关闭网络时,两个组件会一起更新。然而,认为它们之间共享了一个单一的isOnline 状态变量是错误的。看看这段代码:
它的工作方式与你提取重复代码之前相同:
这是两个完全独立的状态变量和 Effect!它们恰好在同一时间具有相同的值,因为你用相同的外部值(网络是否开启)同步了它们。
为了更好地说明这一点,我们需要一个不同的例子。考虑这个Form 组件:
每个表单字段都有一些重复的逻辑:
- 有一个状态片段(
firstName和lastName)。 - 有一个变更处理函数(
handleFirstNameChange和handleLastNameChange)。 - 有一段 JSX 为该输入指定了
value和onChange属性。
你可以将重复的逻辑提取到这个 useFormInput自定义 Hook 中:
请注意,它只声明了一个名为 value的状态变量。
然而,Form 组件两次调用 useFormInput:
这就是为什么它的工作原理类似于声明两个独立的状态变量!
自定义 Hook 让你可以共享状态逻辑,但不能共享状态本身。每次调用 Hook 都完全独立于对同一 Hook 的任何其他调用。这就是为什么上面的两个沙盒是完全等价的。如果你愿意,可以向上滚动并比较它们。提取自定义 Hook 前后的行为是相同的。
当你需要在多个组件之间共享状态本身时,请 将其提升并向下传递。
在 Hook 之间传递响应式值
自定义 Hook 内部的代码会在组件的每次重新渲染时重新运行。这就是为什么,和组件一样,自定义 Hook必须是纯函数。将自定义 Hook 的代码视为组件主体的一部分!
因为自定义 Hook 与你的组件一起重新渲染,它们总是接收到最新的 props 和 state。要理解这意味着什么,请看这个聊天室示例。更改服务器 URL 或聊天室:
当你更改 serverUrl 或 roomId时,Effect 会“响应”你的更改并重新同步。你可以通过控制台消息看出,每次更改 Effect 的依赖项时,聊天都会重新连接。
现在将 Effect 的代码移入自定义 Hook:
这可以让你的ChatRoom组件调用你的自定义 Hook,而无需担心其内部工作原理:
这看起来简单多了!(但它做的事情是一样的。)
注意,逻辑 仍然会响应props 和 state 的变化。尝试编辑服务器 URL 或选中的聊天室:
请注意,您是如何将一个 Hook 的返回值:
作为输入传递给另一个 Hook 的:
每当您的 ChatRoom组件重新渲染时,它都会将最新的roomId 和 serverUrl传递给您的 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 中。
自定义 Hook 帮助你迁移到更好的模式
Effect 是一个“逃生舱口”:当你需要“跳出 React”并且没有更好的内置解决方案来满足你的用例时,你会使用它们。随着时间的推移,React 团队的目标是通过为更具体的问题提供更具体的解决方案,将你应用中的 Effect 数量减少到最低限度。将你的 Effect 包装在自定义 Hook 中,使得在这些解决方案可用时更容易升级你的代码。
让我们回到这个例子:
在上面的例子中,useOnlineStatus 是通过一对 useState 和 useEffect 实现的。然而,这并不是最佳的解决方案。它没有考虑许多边界情况。例如,它假设组件挂载时,isOnline 已经是 true,但如果网络已经离线,这可能是错误的。你可以使用浏览器的navigator.onLineAPI 来检查,但直接使用它无法在服务器上生成初始 HTML。简而言之,这段代码可以改进。
React 包含一个名为useSyncExternalStore的专用 API,它可以为你处理所有这些问题。以下是你的useOnlineStatusHook,重写后利用了这项新 API:
请注意,您无需更改任何组件即可完成此迁移:
这也是为什么将 Effect 封装在自定义 Hook 中通常是有益的另一个原因:
- 您可以非常明确地表达进出 Effect 的数据流。
- 您可以让组件专注于意图,而不是 Effect 的具体实现。
- 当 React 添加新功能时,您可以移除这些 Effect 而无需更改任何组件。
类似于设计系统,您可能会发现,将应用程序组件中的常见用法提取到自定义 Hook 中是很有帮助的。这将使您的组件代码专注于意图,并让您避免经常编写原始的 Effect。许多优秀的自定义 Hook 由 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.
