响应式 Effect 的生命周期
Effect 的生命周期与组件不同。组件可能会挂载、更新或卸载。一个 Effect 只能做两件事:开始同步某些内容,然后在之后停止同步。如果你的 Effect 依赖于随时间变化的 props 和 state,这个循环可能会发生多次。React 提供了一个 linter 规则来检查你是否正确指定了 Effect 的依赖项。这可以确保你的 Effect 与最新的 props 和 state 保持同步。
您将学习
- Effect 的生命周期与组件的生命周期有何不同
- 如何独立地思考每个单独的 Effect
- 你的 Effect 何时需要重新同步,以及为什么
- 如何确定 Effect 的依赖项
- 值的响应式意味着什么
- 空依赖数组意味着什么
- React 如何通过 linter 验证你的依赖项是否正确
- 当你不同意 linter 的建议时该怎么办
Effect 的生命周期
每个 React 组件都经历相同的生命周期:
- 组件挂载时,它被添加到屏幕上。
- 组件更新时,它接收到新的 props 或 state,通常是为了响应用户交互。
- 组件卸载时,它从屏幕上被移除。
这是思考组件的好方法,但不适用于 Effect。相反,请尝试独立于组件的生命周期来思考每个 Effect。Effect 描述的是如何将外部系统与当前的 props 和 state 同步。随着代码的变化,同步可能需要更频繁或更少地发生。
为了说明这一点,考虑这个将你的组件连接到聊天服务器的 Effect:
你的 Effect 主体指定了如何开始同步:
你的 Effect 返回的清理函数指定了如何停止同步:
直观上,你可能会认为 React 会在组件挂载时开始同步,并在组件卸载时停止同步。然而,这并不是全部!有时,在组件保持挂载状态的同时,也可能需要多次开始和停止同步。
让我们来看看为什么这是必要的,何时会发生,以及如何控制这种行为。
注意
有些 Effect 根本不返回清理函数。大多数情况下,你会希望返回一个——但如果你不返回,React 会表现得好像你返回了一个空的清理函数。
为什么同步可能需要发生多次
想象一下这个ChatRoom组件接收一个roomId prop,用户在下拉菜单中选择。假设最初用户选择了"general"房间作为roomId。你的应用会显示"general"聊天室:
UI 显示后,React 将运行你的 Effect 来开始同步。它会连接到"general"房间:
到目前为止,一切顺利。
稍后,用户在下拉菜单中选择了一个不同的房间(例如,"travel")。首先,React 会更新 UI:
思考一下接下来应该发生什么。用户在 UI 中看到选中的聊天室是"travel"。然而,上一次运行的 Effect 仍然连接到 "general"房间。由于roomId属性已更改,你的 Effect 之前所做的操作(连接到"general"房间)不再与 UI 匹配。
此时,你希望 React 做两件事:
- 停止与旧的
roomId同步(断开与"general"房间的连接) - 开始与新的
roomId同步(连接到"travel"房间)
幸运的是,你已经教会了 React 如何做这两件事!你的 Effect 主体指定了如何开始同步,而你的清理函数指定了如何停止同步。现在 React 只需要以正确的顺序、使用正确的属性和状态来调用它们。让我们看看具体是如何发生的。
React 如何重新同步你的 Effect
回想一下,你的ChatRoom 组件已收到其 roomId属性的新值。它过去是"general",现在是 "travel"。React 需要重新同步你的 Effect,以便将你重新连接到不同的房间。
为了停止同步,React 将调用你的 Effect 在连接到 "general"房间后返回的清理函数。由于roomId 是 "general",清理函数会断开与 "general"房间的连接:
然后 React 将运行你在本次渲染期间提供的 Effect。这一次,roomId 是 "travel",因此它将开始同步到 "travel" 聊天室(直到其清理函数最终也被调用):
多亏了这一点,你现在连接到了用户在 UI 中选择的同一个房间。灾难得以避免!
每次你的组件以不同的 roomId重新渲染后,你的 Effect 都会重新同步。例如,假设用户将roomId 从 "travel"更改为"music"。React 将再次停止同步你的 Effect,通过调用其清理函数(断开你与"travel"房间的连接)。然后它将开始同步,通过使用新的 roomId属性运行其主体(将你连接到"music"房间)。
最后,当用户转到不同的屏幕时,ChatRoom会卸载。现在完全不需要保持连接。React 将最后一次停止同步你的 Effect,并断开你与 "music"聊天室的连接。
从 Effect 的角度思考
让我们从 ChatRoom组件的角度来回顾一下所发生的一切:
ChatRoom挂载时,roomId设置为"general"ChatRoom更新时,roomId设置为"travel"ChatRoom更新时,roomId设置为"music"ChatRoom卸载
在组件生命周期的这些时间点,你的 Effect 执行了不同的操作:
- 你的 Effect 连接到
"general"房间 - 你的 Effect 断开与
"general"房间的连接,并连接到"travel"房间 - 你的 Effect 断开与
"travel"房间的连接,并连接到"music"房间 - 你的 Effect 断开与
"music"房间的连接
现在,让我们从 Effect 自身的角度来思考发生了什么:
这段代码的结构可能会启发你,将发生的事情视为一系列不重叠的时间段:
- 你的 Effect 连接到
"general"房间(直到断开连接) - 你的 Effect 连接到
"travel"房间(直到断开连接) - 你的 Effect 连接到
"music"房间(直到断开连接)
之前,你是从组件的角度思考的。当你从组件的角度看时,很容易将 Effects 视为在特定时间(如“渲染后”或“卸载前”)触发的“回调”或“生命周期事件”。这种思维方式很快就会变得复杂,因此最好避免。
相反,始终专注于一次启动/停止周期。组件是挂载、更新还是卸载并不重要。你需要做的只是描述如何启动同步以及如何停止它。如果你做得好,你的 Effect 将能够根据需要被启动和停止任意多次。
这可能会让你想起,当你编写创建 JSX 的渲染逻辑时,你不会考虑组件是挂载还是更新。你描述屏幕上应该显示什么,然后 React会处理其余的事情。
React 如何验证你的 Effect 可以重新同步
这里有一个你可以试用的实时示例。点击“打开聊天”来挂载ChatRoom 组件:
请注意,当组件首次挂载时,你会看到三条日志:
✅ 正在连接到 https://localhost:1234 的 "general" 聊天室...(仅限开发环境)❌ 已断开与 https://localhost:1234 的 "general" 聊天室的连接。(仅限开发环境)✅ 正在连接到 https://localhost:1234 的 "general" 聊天室...
前两条日志仅出现在开发环境。在开发环境中,React 总是会重新挂载每个组件一次。
React 通过在开发环境中立即强制 Effect 重新同步来验证其是否能够重新同步。这可能会让你想起开门后再多关一次门来检查门锁是否正常工作。React 在开发环境中会额外启动和停止你的 Effect 一次,以检查你是否妥善实现了其清理函数。
在实践中,你的 Effect 需要重新同步的主要原因是它所使用的某些数据发生了变化。在上面的沙盒中,更改选中的聊天室。注意,当roomId改变时,你的 Effect 会重新同步。
然而,也存在一些更不寻常的情况需要重新同步。例如,尝试在上面的沙盒中编辑serverUrl,同时保持聊天窗口打开。注意 Effect 如何响应你对代码的编辑而重新同步。未来,React 可能会添加更多依赖于重新同步的功能。
React 如何知道需要重新同步 Effect
你可能想知道 React 是如何知道在 roomId改变后你的 Effect 需要重新同步的。这是因为你告诉了 React 其代码依赖于 roomId,方法是将它包含在依赖项列表中:
其工作原理如下:
- 你知道
roomId是一个 prop,这意味着它可能随时间改变。 - 你知道你的 Effect 读取了
roomId(因此其逻辑依赖于一个未来可能改变的值)。 - 这就是为什么你将其指定为 Effect 的依赖项(以便在
roomId改变时重新同步)。
每次你的组件重新渲染后,React 都会查看你传递的依赖项数组。如果数组中任何位置的值与上一次渲染时传递的同一位置的值不同,React 就会重新同步你的 Effect。
例如,如果你在初始渲染时传递了["general"],而在下一次渲染时传递了 ["travel"],React 会比较 "general" 和 "travel"。这些是不同的值(通过 Object.is比较),因此 React 会重新同步你的 Effect。另一方面,如果你的组件重新渲染但roomId没有改变,你的 Effect 将保持连接到同一个聊天室。
每个 Effect 代表一个独立的同步过程
请避免仅仅因为某些逻辑需要与你已编写的 Effect 同时运行,就将不相关的逻辑添加到该 Effect 中。例如,假设你想在用户访问聊天室时发送一个分析事件。你已经有一个依赖于roomId的 Effect,因此你可能会想把分析调用也加在那里:
但想象一下,你后来为此 Effect 添加了另一个需要重新建立连接的依赖项。如果这个 Effect 重新同步,它也会为同一个聊天室调用logVisit(roomId),而这并非你的本意。记录访问 是一个独立的过程,与连接不同。将它们写成两个独立的 Effect:
你代码中的每个 Effect 都应该代表一个独立且不同的同步过程。
在上面的例子中,删除一个 Effect 不会破坏另一个 Effect 的逻辑。这是一个很好的迹象,表明它们同步的是不同的事物,因此将它们分开是有意义的。另一方面,如果你将一个连贯的逻辑拆分成多个独立的 Effect,代码可能看起来更“整洁”,但会更难维护。这就是为什么你应该考虑过程是相同的还是独立的,而不是代码看起来是否更整洁。
Effect 对响应式值做出“反应”
你的 Effect 读取了两个变量(serverUrl 和 roomId),但你只将 roomId指定为依赖项:
为什么 serverUrl不需要作为依赖项?
这是因为 serverUrl不会因为重新渲染而改变。无论组件重新渲染多少次以及为什么重新渲染,它始终是相同的。既然serverUrl永远不会改变,将其指定为依赖项就没有意义。毕竟,依赖项只有在它们随时间变化时才会起作用!
另一方面,roomId在重新渲染时可能会不同。在组件内部声明的 props、state 和其他值是响应式的,因为它们是在渲染期间计算的,并参与了 React 的数据流。
如果serverUrl是一个状态变量,那么它就是响应式的。响应式的值必须包含在依赖项中:
通过将 serverUrl作为依赖项包含在内,你可以确保 Effect 在其发生变化后重新同步。
尝试更改选中的聊天室或在此沙盒中编辑服务器 URL:
每当你更改像 roomId 或 serverUrl这样的响应式值时,Effect 都会重新连接到聊天服务器。
具有空依赖项的 Effect 意味着什么
如果将 serverUrl 和 roomId都移到组件外部会发生什么?
现在你的 Effect 代码没有使用任何响应式值,因此它的依赖项可以为空([])。
从组件的角度思考,空的 []依赖数组意味着这个 Effect 仅在组件挂载时连接到聊天室,并在组件卸载时断开连接。(请记住,在开发环境中,React 仍然会额外重新同步一次,以对你的逻辑进行压力测试。)
然而,如果你从 Effect 的角度思考,你根本不需要考虑挂载和卸载。重要的是你已经指定了 Effect 开始和停止同步时需要做什么。目前,它没有响应式依赖项。但是,如果你希望用户随时间更改roomId 或 serverUrl(并且它们会变成响应式的),你的 Effect 代码不需要改变。你只需要将它们添加到依赖项中。
在组件体内声明的所有变量都是响应式的
Props 和 state 并不是唯一的响应式值。从它们计算出的值也是响应式的。如果 props 或 state 发生变化,你的组件将重新渲染,从它们计算出的值也会发生变化。这就是为什么组件体内所有被 Effect 使用的变量都应该在 Effect 的依赖列表中。
假设用户可以在下拉菜单中选择聊天服务器,但他们也可以在设置中配置默认服务器。假设你已经将设置状态放在一个上下文中,因此你从该上下文中读取settings。现在你根据 props 中选中的服务器和默认服务器来计算serverUrl:
在这个例子中,serverUrl不是一个 prop 或状态变量。它是一个你在渲染期间计算出的普通变量。但由于它是在渲染期间计算的,所以它可能因重新渲染而改变。这就是为什么它是响应式的。
组件内部的所有值(包括 props、状态以及组件体内的变量)都是响应式的。任何响应式值都可能在重新渲染时改变,因此你需要将响应式值包含为 Effect 的依赖项。
换句话说,Effect 会“响应”来自组件体的所有值。
React 验证你是否将每个响应式值都指定为依赖项
如果你的 linter 已配置为支持 React,它会检查你的 Effect 代码中使用的每个响应式值是否都声明为其依赖项。例如,这是一个 lint 错误,因为roomId 和 serverUrl都是响应式的:
这看起来可能像 React 错误,但实际上 React 是在指出你代码中的一个 bug。roomId 和 serverUrl都可能随时间改变,但你忘记了在它们改变时重新同步你的 Effect。即使用户在 UI 中选择了不同的值,你仍将保持连接到初始的roomId 和 serverUrl。
要修复这个 bug,请遵循 linter 的建议,将roomId 和 serverUrl指定为 Effect 的依赖项:
在上面的沙盒中尝试这个修复。验证 linter 错误是否消失,以及聊天是否在需要时重新连接。
当你不想重新同步时该怎么办
在前面的例子中,你通过列出 roomId 和 serverUrl作为依赖项来修复了 lint 错误。
然而,你也可以选择向 linter “证明”这些值不是响应式值, 即它们 不可能 因重新渲染而改变。例如,如果 serverUrl 和 roomId不依赖于渲染且始终具有相同的值,你可以将它们移到组件外部。现在它们就不需要作为依赖项了:
你也可以将它们移到 Effect 内部。它们不是在渲染期间计算的,因此不是响应式的:
Effect 是响应式的代码块。当你读取的 Effect 内部的值发生变化时,它们会重新同步。与事件处理程序(每次交互只运行一次)不同,Effect 在需要同步时就会运行。
你不能“选择”你的依赖项。你的依赖项必须包含 Effect 中读取的每一个响应式值。linter 会强制执行这一点。有时这可能会导致无限循环或 Effect 过于频繁地重新同步等问题。不要通过禁用 linter 来解决这些问题!可以尝试以下方法:
- 检查你的 Effect 是否代表一个独立的同步过程。如果你的 Effect 没有同步任何东西,它可能是不必要的。如果它同步了几个独立的东西,请将其拆分。
- 如果你想读取 props 或 state 的最新值,但又不想“响应”它并导致 Effect 重新同步,你可以将 Effect 拆分为响应式部分(保留在 Effect 中)和非响应式部分(提取到称为Effect Event的东西中)。阅读关于将事件与 Effect 分离的内容。
- 避免依赖对象和函数作为依赖项。如果你在渲染期间创建对象和函数,然后在 Effect 中读取它们,它们会在每次渲染时都不同。这将导致你的 Effect 每次都会重新同步。阅读更多关于从 Effect 中移除不必要依赖项的内容。
回顾
- 组件可以挂载、更新和卸载。
- 每个 Effect 都有独立于周围组件的生命周期。
- 每个 Effect 描述了一个独立的同步过程,可以启动和停止。
- 当你编写和读取 Effect 时,要从每个独立 Effect 的角度思考(如何启动和停止同步),而不是从组件的角度(它如何挂载、更新或卸载)。
- 在组件体内声明的值是“响应式”的。
- 响应式值应该重新同步 Effect,因为它们会随时间变化。
- linter 会验证 Effect 内部使用的所有响应式值是否都被指定为依赖项。
- linter 标记的所有错误都是合理的。总有办法修复代码而不违反规则。
Try out some challenges
Challenge 1 of 5:Fix reconnecting on every keystroke #
In this example, the ChatRoom component connects to the chat room when the component mounts, disconnects when it unmounts, and reconnects when you select a different chat room. This behavior is correct, so you need to keep it working.
However, there is a problem. Whenever you type into the message box input at the bottom, ChatRoom also reconnects to the chat. (You can notice this by clearing the console and typing into the input.) Fix the issue so that this doesn’t happen.
