v19.2Latest

与 Effect 同步

有些组件需要与外部系统同步。例如,你可能希望基于 React 状态来控制一个非 React 组件、建立服务器连接,或者在组件出现在屏幕上时发送分析日志。Effect让你可以在渲染之后运行一些代码,以便将你的组件与 React 外部的某个系统同步。

你将学习
  • 什么是 Effect
  • Effect 与事件有何不同
  • 如何在组件中声明一个 Effect
  • 如何避免不必要的 Effect 重复运行
  • 为什么 Effect 在开发环境中会运行两次以及如何修复

什么是 Effect?它们与事件有何不同?

在了解 Effect 之前,你需要熟悉 React 组件内部的两种逻辑:

  • 渲染代码(在描述 UI中介绍)位于组件的顶层。在这里,你接收 props 和 state,转换它们,并返回你想在屏幕上看到的 JSX。渲染代码必须是纯函数。就像数学公式一样,它应该只计算结果,而不做其他任何事情。
  • 事件处理函数(在添加交互性中介绍)是组件内部的嵌套函数,它们执行操作而不仅仅是计算。事件处理函数可能会更新输入字段、提交 HTTP POST 请求以购买产品,或将用户导航到另一个屏幕。事件处理函数包含由特定用户操作(例如,按钮点击或键入)引起的“副作用”(它们会改变程序的状态)。

有时这还不够。考虑一个ChatRoom组件,每当它在屏幕上可见时都必须连接到聊天服务器。连接到服务器不是纯计算(它是一个副作用),因此不能在渲染期间发生。然而,并没有像点击这样的单一特定事件会导致ChatRoom被显示。

Effect让你可以指定由渲染本身引起的副作用,而不是由特定事件引起的。在聊天中发送消息是一个事件,因为它是由用户点击特定按钮直接引起的。然而,建立服务器连接是一个Effect,因为无论哪种交互导致组件出现,它都应该发生。Effect 在屏幕更新后的提交阶段结束时运行。这是将 React 组件与某些外部系统(如网络或第三方库)同步的好时机。

注意

在本文此处及后续内容中,首字母大写的“Effect”指的是上述 React 特定的定义,即由渲染引起的副作用。为了指代更广泛的编程概念,我们会说“副作用”。

你可能不需要 Effect

不要急于向你的组件添加 Effect。请记住,Effect 通常用于“跳出”你的 React 代码并与某些外部系统同步。这包括浏览器 API、第三方小部件、网络等等。如果你的 Effect 仅根据其他状态来调整某些状态,你可能不需要 Effect。

如何编写一个 Effect

要编写一个 Effect,请遵循以下三个步骤:

  1. 声明一个 Effect。默认情况下,你的 Effect 会在每次提交后运行。
  2. 指定 Effect 的依赖项。大多数 Effect 应该只在需要时重新运行,而不是在每次渲染后都运行。例如,淡入动画应该只在组件出现时触发。连接到聊天室和断开连接应该只在组件出现和消失时,或者聊天室发生变化时发生。你将学习如何通过指定依赖项来控制这一点。
  3. 如果需要,添加清理函数。有些 Effect 需要指定如何停止、撤销或清理它们所做的任何事情。例如,“连接”需要“断开连接”,“订阅”需要“取消订阅”,“获取”需要“取消”或“忽略”。你将学习如何通过返回一个清理函数来实现这一点。

让我们详细看看这些步骤中的每一步。

步骤 1:声明一个 Effect

要在组件中声明一个 Effect,请从 React 导入useEffect Hook

然后,在你的组件顶层调用它,并将一些代码放入你的 Effect 中:

每次你的组件渲染时,React 都会更新屏幕然后运行useEffect内部的代码。换句话说,useEffect将一段代码的执行“延迟”到该次渲染反映到屏幕上之后。

让我们看看如何使用 Effect 来与外部系统同步。考虑一个<VideoPlayer> React 组件。通过向其传递一个isPlaying属性来控制它是播放还是暂停会很方便:

你的自定义VideoPlayer组件渲染了浏览器内置的<video>标签:

然而,浏览器<video>标签没有isPlaying属性。控制它的唯一方法是手动调用 DOM 元素上的play()pause()方法。你需要同步isPlaying属性的值(它指示视频当前应该播放)与play()pause()这类调用。

我们首先需要获取一个指向DOM 节点的引用。<video>

你可能会想在渲染期间尝试调用play()pause(),但这是不正确的:

这段代码不正确的原因在于它试图在渲染期间对 DOM 节点进行操作。在 React 中,渲染应该是一个纯粹的计算JSX 的过程,不应包含像修改 DOM 这样的副作用。

此外,当 VideoPlayer首次被调用时,其 DOM 还不存在!还没有一个 DOM 节点可供调用play()pause(),因为 React 在你返回 JSX 之前并不知道要创建什么 DOM。

这里的解决方案是useEffect包裹副作用,将其移出渲染计算:

通过将 DOM 更新包裹在 Effect 中,你让 React 先更新屏幕。然后你的 Effect 才会运行。

当你的VideoPlayer组件渲染时(无论是首次渲染还是重新渲染),会发生几件事。首先,React 会更新屏幕,确保<video>标签以正确的属性存在于 DOM 中。然后 React 会运行你的 Effect。最后,你的 Effect 会根据isPlaying的值调用play()pause()

多次点击播放/暂停,观察视频播放器如何与 isPlaying值保持同步:

在这个例子中,你同步到 React 状态的“外部系统”是浏览器媒体 API。你可以使用类似的方法将遗留的非 React 代码(如 jQuery 插件)包装成声明式的 React 组件。

请注意,在实际应用中控制视频播放器要复杂得多。调用play()可能会失败,用户可能会使用内置的浏览器控件进行播放或暂停,等等。这个例子非常简化且不完整。

陷阱

默认情况下,Effect 会在每次渲染后运行。这就是为什么像这样的代码会导致无限循环:

Effect 是作为渲染的结果而运行的。设置状态会触发渲染。在 Effect 中立即设置状态就像把电源插座插到它自己身上。Effect 运行,它设置状态,这导致重新渲染,重新渲染又导致 Effect 运行,它再次设置状态,这导致另一次重新渲染,如此循环往复。

Effect 通常应该将你的组件与一个外部系统同步。如果没有外部系统,你只想基于其他状态来调整某些状态,你可能不需要 Effect。

步骤 2:指定 Effect 依赖项

默认情况下,Effect 会在每次渲染后运行。通常,这不是你想要的:

  • 有时,这很慢。与外部系统同步并不总是即时的,因此你可能希望只在必要时才进行同步。例如,你不想在每次按键时都重新连接到聊天服务器。
  • 有时,这是错误的。例如,你不想在每次按键时都触发组件淡入动画。动画应该只在组件首次出现时播放一次。

为了演示这个问题,下面是之前的例子,添加了几个console.log调用和一个更新父组件状态的文本输入框。请注意输入是如何导致 Effect 重新运行的:

你可以通过将依赖项数组作为第二个参数传递给useEffect调用,来告诉 React跳过不必要的 Effect 重新运行。首先,在上面的示例第 14 行添加一个空的[]数组:

你应该会看到一个错误,提示React Hook useEffect has a missing dependency: 'isPlaying'

问题在于,你的 Effect 内部的代码依赖于 isPlayingprop 来决定要做什么,但这个依赖项没有被显式声明。要解决这个问题,请将isPlaying添加到依赖项数组中:

现在所有依赖项都已声明,因此没有错误。将[isPlaying]指定为依赖项数组,告诉 React 如果isPlaying与上一次渲染时相同,则应跳过重新运行你的 Effect。经过此更改,在输入框中键入内容不会导致 Effect 重新运行,但按下播放/暂停按钮则会:

依赖数组可以包含多个依赖项。只有当您指定的所有依赖项的值与上一次渲染时的值完全相同时,React 才会跳过重新运行 Effect。React 使用Object.is比较来比较依赖值。详情请参阅useEffect 参考

请注意,您不能“选择”您的依赖项。如果您指定的依赖项与 React 根据您 Effect 内部的代码所预期的依赖项不匹配,您将收到一个 lint 错误。这有助于捕获代码中的许多错误。如果您不希望某些代码重新运行,请编辑 Effect 代码本身,使其不“需要”该依赖项。

陷阱

没有依赖数组和拥有一个空的[]依赖数组的行为是不同的:

我们将在下一步中仔细研究“挂载”的含义。

Deep Dive
为什么 ref 被排除在依赖数组之外?

步骤 3:如果需要,添加清理函数

考虑另一个例子。你正在编写一个ChatRoom 组件,它需要在显示时连接到聊天服务器。你获得了一个 createConnection()API,它返回一个包含connect()disconnect() 方法的对象。如何在组件显示给用户时保持其连接状态?

首先编写 Effect 逻辑:

如果在每次重新渲染后都连接到聊天室会很慢,所以你添加了依赖数组:

Effect 内部的代码没有使用任何 props 或 state,所以你的依赖数组是[](空数组)。这告诉 React 只在组件“挂载”时(即首次出现在屏幕上时)运行这段代码。

让我们尝试运行这段代码:

这个 Effect 仅在挂载时运行,因此你可能期望控制台只打印一次"✅ Connecting..."但是,如果你检查控制台,"✅ Connecting..."会被打印两次。为什么会这样?

假设 ChatRoom 组件是一个包含许多不同屏幕的大型应用的一部分。用户从 ChatRoom页面开始他们的旅程。组件挂载并调用connection.connect()。然后假设用户导航到另一个屏幕——例如,导航到设置页面。ChatRoom组件会卸载。最后,用户点击返回按钮,ChatRoom 会再次挂载。这将建立第二个连接——但第一个连接从未被销毁!随着用户在应用中导航,连接会不断累积。

如果没有大量的手动测试,很容易遗漏此类错误。为了帮助你快速发现它们,在开发环境中,React 会在每个组件初始挂载后立即重新挂载一次。

看到 "✅ Connecting..."日志打印两次有助于你注意到真正的问题:你的代码在组件卸载时没有关闭连接。

要修复此问题,请从你的 Effect 中返回一个清理函数

每次在 Effect 再次运行之前,以及组件卸载(被移除)时最后一次,React 都会调用你的清理函数。让我们看看实现清理函数后会发生什么:

现在你在开发环境中会得到三条控制台日志:

  1. "✅ Connecting..."
  2. "❌ Disconnected."
  3. "✅ Connecting..."

这是开发环境中的正确行为。通过重新挂载你的组件,React 验证了导航离开再返回不会破坏你的代码。断开连接然后重新连接正是应该发生的情况!当你很好地实现了清理功能时,Effect 运行一次与运行一次、清理它、再运行一次之间,用户应该看不出任何区别。之所以多了一对连接/断开调用,是因为 React 在开发环境中探测你的代码是否存在错误。这是正常的——不要试图让它消失!

在生产环境中,你只会看到"✅ Connecting..."被打印一次。重新挂载组件只发生在开发环境中,以帮助你找到需要清理的 Effect。你可以关闭严格模式来选择退出开发环境行为,但我们建议保持开启。这可以让你发现许多像上面那样的错误。

如何处理开发环境中 Effect 触发两次的问题?

React 有意在开发环境中重新挂载你的组件,以发现像上一个例子中的错误。正确的问题不是“如何让 Effect 只运行一次”,而是“如何修复我的 Effect,使其在重新挂载后仍能正常工作”。

通常,答案是实现清理函数。清理函数应该停止或撤销 Effect 所做的任何事情。经验法则是,用户不应该能够区分 Effect 运行一次(如在生产环境中)与设置 → 清理 → 设置序列(如你在开发环境中看到的那样)。

你将编写的大多数 Effect 都会符合下面的一种常见模式。

陷阱

不要使用 ref 来阻止 Effect 触发

在开发环境中防止 Effect 触发两次的一个常见陷阱是使用ref 来阻止 Effect 运行超过一次。例如,你可以用 useRef来“修复”上述错误:

这使得你在开发环境中只能看到一次"✅ Connecting...",但它并没有修复错误。

当用户导航离开时,连接仍然没有关闭,当他们导航回来时,会创建一个新的连接。随着用户在应用中导航,连接会不断累积,这与“修复”之前的情况相同。

要修复这个错误,仅仅让 Effect 运行一次是不够的。Effect 需要在重新挂载后正常工作,这意味着连接需要像上面的解决方案那样被清理。

请参阅下面的示例,了解如何处理常见模式。

控制非 React 组件

有时你需要添加不是用 React 编写的 UI 组件。例如,假设你正在向页面添加一个地图组件。它有一个setZoomLevel()方法,并且你希望将缩放级别与你 React 代码中的zoomLevel状态变量保持同步。你的 Effect 看起来会类似于这样:

请注意,在这种情况下不需要清理。在开发环境中,React 会调用 Effect 两次,但这没有问题,因为用相同的值调用两次setZoomLevel不会产生任何效果。它可能会稍微慢一点,但这无关紧要,因为在生产环境中它不会不必要地重新挂载。

有些 API 可能不允许你连续调用两次。例如,内置showModal 方法的 <dialog>元素,如果你调用它两次就会抛出错误。实现清理函数并让它关闭对话框:

在开发环境中,你的 Effect 会调用showModal(),然后立即调用close(),接着再次调用showModal()。这与在生产环境中调用一次 showModal()具有相同的用户可见行为。

订阅事件

如果你的 Effect 订阅了某些内容,清理函数应该取消订阅:

在开发环境中,你的 Effect 会调用addEventListener(),然后立即调用removeEventListener(),接着再次用相同的处理函数调用addEventListener()。因此,一次只会有一个活跃的订阅。这与在生产环境中调用一次 addEventListener()具有相同的用户可见行为。

触发动画

如果你的 Effect 将某些内容动画化进入,清理函数应该将动画重置为初始值:

在开发环境中,不透明度将被设置为1,然后设置为0,接着再次设置为1。这应该与直接将其设置为 1具有相同的用户可见行为,而这正是生产环境中会发生的情况。如果你使用支持补间动画的第三方动画库,你的清理函数应该将时间线重置为其初始状态。

获取数据

如果你的 Effect 获取某些数据,清理函数应该中止获取或忽略其结果:

你无法“撤销”已经发生的网络请求,但你的清理函数应确保那些 不再相关的获取操作不会继续影响你的应用程序。如果userId'Alice'变为'Bob',清理操作确保即使'Alice' 的响应在 'Bob'之后到达,也会被忽略。

在开发环境中,你会在网络选项卡中看到两次获取请求。 这没有问题。使用上述方法,第一个 Effect 会立即被清理,因此其 ignore变量的副本将被设置为true。所以,即使有一个额外的请求,由于 if (!ignore)检查,它也不会影响状态。

在生产环境中,只会有一个请求。如果开发环境中的第二个请求让你感到困扰,最好的方法是使用一种解决方案,该方案可以消除重复请求并在组件之间缓存它们的响应:

这不仅会改善开发体验,还会让你的应用程序感觉更快。例如,用户按下后退按钮时不必等待某些数据再次加载,因为它会被缓存。你可以自己构建这样的缓存,也可以在 Effect 中使用许多替代手动获取的方法之一。

Deep Dive
在 Effect 中获取数据有哪些好的替代方案?

发送分析

考虑这段在页面访问时发送分析事件的代码:

在开发环境中,logVisit会为每个 URL 调用两次,因此你可能会想尝试修复这个问题。我们建议保持这段代码不变。与前面的示例一样,运行一次和运行两次之间没有用户可见的 行为差异。从实际角度来看,logVisit在开发环境中不应该做任何事情,因为你不想让开发机器的日志影响生产环境指标。每次保存组件文件时,你的组件都会重新挂载,因此在开发环境中它无论如何都会记录额外的访问。

在生产环境中,不会有重复的访问日志。

要调试你发送的分析事件,你可以将应用部署到暂存环境(该环境以生产模式运行),或者暂时选择退出严格模式及其仅在开发环境中进行的重新挂载检查。你也可以从路由变更事件处理程序中发送分析,而不是在 Effect 中。为了更精确的分析,交叉观察器可以帮助跟踪哪些组件在视口中以及它们保持可见的时间。

不是 Effect:初始化应用

有些逻辑只应在应用启动时运行一次。你可以将其放在组件外部:

这确保了此类逻辑仅在浏览器加载页面后运行一次。

不是 Effect:购买产品

有时,即使你编写了清理函数,也无法防止 Effect 运行两次所带来的用户可见后果。例如,也许你的 Effect 发送了一个 POST 请求,比如购买产品:

你不会想购买两次产品。然而,这也是为什么你不应该将此逻辑放在 Effect 中的原因。如果用户转到另一个页面然后按返回键呢?你的 Effect 会再次运行。你不想在用户访问页面时购买产品;你希望在用户点击购买按钮时购买。

购买不是由渲染引起的;它是由特定的交互引起的。它应该只在用户按下按钮时运行。删除 Effect 并将你的/api/buy请求移到购买按钮的事件处理程序中

这说明如果重新挂载破坏了你的应用逻辑,这通常揭示了现有的错误。从用户的角度来看,访问一个页面应该与访问它、点击一个链接、然后按返回键再次查看该页面没有区别。React 通过在开发环境中重新挂载组件一次来验证你的组件是否遵守这一原则。

总结

这个演练场可以帮助你“感受”一下 Effect 在实际中是如何工作的。

这个例子使用setTimeout来安排一个控制台日志,在 Effect 运行三秒后显示输入的文本。清理函数会取消待处理的超时。首先点击“挂载组件”:

你最初会看到三条日志:Schedule "a" logCancel "a" logSchedule "a" log。三秒后还会有一条显示 a的日志。正如你之前学到的,额外的调度/取消对是因为 React 在开发环境中会重新挂载组件一次,以验证你是否很好地实现了清理。

现在将输入框的内容编辑为abc。如果你操作得足够快,你会立即看到Schedule "ab" log,紧接着是 Cancel "ab" logSchedule "abc" logReact 总是在下一次渲染的 Effect 之前清理上一次渲染的 Effect。这就是为什么即使你快速输入,也最多只有一个超时被调度。多次编辑输入框并观察控制台,感受一下 Effect 是如何被清理的。

在输入框中输入一些内容,然后立即点击“卸载组件”。注意卸载是如何清理最后一次渲染的 Effect 的。在这里,它会在最后一次超时触发之前将其清除。

最后,编辑上面的组件并注释掉清理函数,这样超时就不会被取消。尝试快速输入 abcde。你预计三秒后会发生什么?超时内的 console.log(text) 会打印 最新的text 并产生五个 abcde日志吗?试一试来验证你的直觉!

三秒后,你应该会看到一系列日志(aababcabcdabcde),而不是五个abcde日志。每个 Effect 都会“捕获”其对应渲染时的text 值。 无论 text 状态如何变化:来自 text = 'ab'那次渲染的 Effect 将始终看到'ab'。换句话说,每次渲染的 Effect 是相互隔离的。如果你好奇这是如何工作的,可以阅读关于闭包的内容。

Deep Dive
每次渲染都有其自己的 Effect

摘要

  • 与事件不同,Effect 是由渲染本身而非特定的交互触发的。
  • Effect 让你可以将组件与某些外部系统(第三方 API、网络等)同步。
  • 默认情况下,Effect 在每次渲染(包括初始渲染)后运行。
  • 如果 Effect 的所有依赖项的值与上一次渲染时相同,React 将跳过该 Effect。
  • 你无法“选择”依赖项。它们由 Effect 内部的代码决定。
  • 空的依赖数组([])对应于组件的“挂载”,即被添加到屏幕上。
  • 在严格模式下,React 会挂载组件两次(仅在开发环境中!)以对你的 Effect 进行压力测试。
  • 如果你的 Effect 因重新挂载而中断,你需要实现一个清理函数。
  • React 会在 Effect 下次运行之前以及组件卸载期间调用你的清理函数。

Try out some challenges

Challenge 1 of 4:Focus a field on mount #

In this example, the form renders a <MyInput /> component.

Use the input’s focus() method to make MyInput automatically focus when it appears on the screen. There is already a commented out implementation, but it doesn’t quite work. Figure out why it doesn’t work, and fix it. (If you’re familiar with the autoFocus attribute, pretend that it does not exist: we are reimplementing the same functionality from scratch.)

To verify that your solution works, press “Show form” and verify that the input receives focus (becomes highlighted and the cursor is placed inside). Press “Hide form” and “Show form” again. Verify the input is highlighted again.

MyInput should only focus on mount rather than after every render. To verify that the behavior is right, press “Show form” and then repeatedly press the “Make it uppercase” checkbox. Clicking the checkbox should not focus the input above it.