v19.2Latest

移除 Effect 依赖

当你编写 Effect 时,linter 会验证你是否已将 Effect 读取的每个响应式值(如 props 和 state)包含在 Effect 的依赖列表中。这确保了你的 Effect 与组件的最新 props 和 state 保持同步。不必要的依赖可能会导致 Effect 运行过于频繁,甚至产生无限循环。请遵循本指南来审查并移除 Effect 中不必要的依赖。

你将学习
  • 如何修复 Effect 依赖的无限循环
  • 当你想移除一个依赖时该怎么做
  • 如何从 Effect 中读取值而不对其产生“响应”
  • 如何以及为何避免对象和函数依赖
  • 为何抑制依赖 linter 是危险的,以及替代方案

依赖应该与代码匹配

当你编写 Effect 时,首先需要指定如何启动和停止你想要 Effect 执行的任何操作:

然后,如果你将 Effect 依赖留空([]),linter 会建议正确的依赖:

根据 linter 的提示填入依赖:

Effect 会“响应”响应式值。由于 roomId是一个响应式值(它可能因重新渲染而改变),linter 会验证你是否已将其指定为依赖。如果roomId接收到不同的值,React 将重新同步你的 Effect。这确保了聊天室保持连接到所选房间,并对下拉菜单的变化做出“响应”:

要移除依赖项,请证明它不是依赖项

请注意,你不能“选择” Effect 的依赖项。Effect 代码中使用的每个响应式值都必须声明在依赖项列表中。依赖项列表由周围的代码决定:

响应式值包括 props 以及直接在组件内部声明的所有变量和函数。由于roomId是一个响应式值,你不能将其从依赖项列表中移除。代码检查工具不会允许这样做:

代码检查工具是正确的!由于roomId可能随时间变化,这会在你的代码中引入一个错误。

要移除一个依赖项,请向代码检查工具“证明”它不需要成为依赖项。例如,你可以将roomId移出组件,以证明它不是响应式的,并且不会在重新渲染时改变:

现在roomId不是一个响应式值(并且不会在重新渲染时改变),它就不需要成为依赖项:

这就是为什么你现在可以指定一个空([])依赖列表。你的 Effect确实不再依赖于任何响应式值,因此当组件的 props 或 state 发生变化时,它也确实不需要重新运行。

要更改依赖项,请先更改代码

你可能已经注意到工作流程中的一个模式:

  1. 首先,你更改 Effect 的代码或响应式值的声明方式。
  2. 然后,遵循 linter 的提示,调整依赖项以匹配你更改后的代码。
  3. 如果你对依赖项列表不满意,就回到第一步(再次更改代码)。

最后一部分很重要。如果你想更改依赖项,请先更改周围的代码。你可以将依赖项列表视为Effect 代码中使用的所有响应式值的列表。你不需要选择将什么放入该列表。该列表描述了你的代码。要更改依赖项列表,请更改代码。

这可能感觉像是在解方程。你可能从一个目标开始(例如,移除一个依赖项),然后需要“找到”与该目标匹配的代码。并非每个人都觉得解方程有趣,编写 Effect 也是如此!幸运的是,下面有一系列常用技巧可供你尝试。

陷阱

如果你有一个现有的代码库,可能会有一些 Effect 像这样抑制 linter:

当依赖项与代码不匹配时,引入 bug 的风险非常高。通过抑制 linter,你是在向 React “撒谎”,隐瞒了 Effect 所依赖的值。

请改用下面的技巧。

移除不必要的依赖项

每次你调整 Effect 的依赖项以反映代码时,请查看依赖项列表。当其中任何一个依赖项发生变化时,Effect 重新运行是否有意义?有时,答案是“否”:

  • 你可能希望在不同的条件下重新执行 Effect 的不同部分
  • 你可能希望只读取某个依赖项的最新值,而不是对其变化做出“反应”。
  • 某个依赖项可能因为它是对象或函数而非预期地频繁变化。

要找到正确的解决方案,你需要回答几个关于你的 Effect 的问题。让我们逐一探讨。

这段代码应该移到事件处理函数中吗?

你首先应该考虑的是,这段代码是否应该是一个 Effect。

想象一个表单。提交时,你将状态变量submitted 设置为 true。你需要发送一个 POST 请求并显示通知。你将这段逻辑放在一个对 submittedtrue做出“反应”的 Effect 中:

之后,你想根据当前主题样式化通知消息,因此你读取当前主题。由于 theme是在组件体内声明的,它是一个响应式值,所以你将其添加为依赖项:

这样做,你就引入了一个 bug。想象一下,你先提交表单,然后在深色和浅色主题之间切换。theme会改变,Effect 会重新运行,因此它会再次显示相同的通知!

这里的问题在于,这首先就不应该是一个 Effect。 你希望在提交表单这一特定交互发生时发送 POST 请求并显示通知。要在特定交互发生时运行某些代码,请将该逻辑直接放入相应的事件处理函数中:

现在代码位于事件处理函数中,它不是响应式的——因此只会在用户提交表单时运行。阅读更多关于在事件处理函数和 Effect 之间做出选择以及如何删除不必要的 Effect的内容。

你的 Effect 是否在做几件不相关的事情?

你应该问自己的下一个问题是,你的 Effect 是否在做几件不相关的事情。

假设你正在创建一个运输表单,用户需要选择其城市和区域。你根据选定的country从服务器获取cities列表,以便在下拉菜单中显示:

这是一个在 Effect 中获取数据的好例子。你正在根据country属性将cities状态与网络同步。你不能在事件处理函数中完成此操作,因为你需要ShippingForm一显示就获取数据,并且每当country改变时(无论是什么交互引起的)都需要获取。

现在假设你正在为城市区域添加第二个选择框,它应该为当前选定的city获取areas。你可能会首先在同一个 Effect 中添加第二个fetch调用来获取区域列表:

然而,由于 Effect 现在使用了city 状态变量,你不得不将 city添加到依赖列表中。这反过来又引入了一个问题:当用户选择不同的城市时,Effect 将重新运行并调用fetchCities(country)。结果,你将不必要地多次重新获取城市列表。

这段代码的问题在于,你正在同步两个互不相关的事物:

  1. 你希望基于 country 属性将 cities状态与网络同步。
  2. 你希望基于 city 状态将 areas状态与网络同步。

将逻辑拆分为两个 Effect,每个 Effect 只响应其需要同步的属性:

现在,第一个 Effect 仅在country发生变化时重新运行,而第二个 Effect 在city发生变化时重新运行。你已经按目的将它们分开了:两个不同的事物由两个独立的 Effect 同步。两个独立的 Effect 拥有两个独立的依赖列表,因此它们不会无意中触发彼此。

最终的代码比原始代码更长,但拆分这些 Effect 仍然是正确的。每个 Effect 都应代表一个独立的同步过程。在这个例子中,删除一个 Effect 不会破坏另一个 Effect 的逻辑。这意味着它们同步的是不同的事物, 将它们分开是好的。如果你担心重复,可以通过 将重复逻辑提取到自定义 Hook 中 来改进这段代码。

你是否正在读取某些状态来计算下一个状态?

这个 Effect 在每次新消息到达时,用一个新创建的数组更新messages 状态变量:

它使用 messages 变量来 创建一个新数组,该数组以所有现有消息开头,并在末尾添加新消息。然而,由于messages是 Effect 读取的响应式值,它必须是一个依赖项:

而将 messages设为依赖项会引入一个问题。

每次收到消息时,setMessages()会导致组件重新渲染,并使用一个包含接收到的消息的新messages 数组。然而,由于这个 Effect 现在依赖于 messages,这 也会重新同步 Effect。因此,每条新消息都会使聊天重新连接。用户不会喜欢这样!

要解决这个问题,不要在 Effect 内部读取messages。相反,向 更新函数传递一个setMessages

请注意,你的 Effect 现在完全不读取messages 变量。你只需要传递一个像 msgs => [...msgs, receivedMessage]这样的更新函数。React 会将你的更新函数放入队列,并在下一次渲染时将msgs参数提供给它。这就是为什么 Effect 本身不再需要依赖于messages。修复之后,接收聊天消息将不再导致聊天重新连接。

你是否想读取一个值而不“响应”其变化?

假设你希望在用户收到新消息时播放声音,除非 isMutedtrue

由于你的 Effect 现在在其代码中使用了isMuted,你必须将其添加到依赖项中:

问题是,每次 isMuted发生变化时(例如,当用户按下“静音”切换按钮),Effect 将重新同步,并重新连接到聊天室。这不是期望的用户体验!(在这个例子中,即使禁用 linter 也不起作用——如果你这样做,isMuted 将“卡住”其旧值。)

为了解决这个问题,你需要将不应具有响应性的逻辑从 Effect 中提取出来。你不希望这个 Effect“响应”isMuted的变化。将这段非响应性逻辑移入一个 Effect Event:

Effect Events 允许你将 Effect 拆分为响应性部分(应该“响应”像roomId这样的响应性值及其变化)和非响应性部分(只读取其最新值,例如onMessage 读取 isMuted)。现在你在 Effect Event 内部读取isMuted,它就不再需要成为 Effect 的依赖项。因此,当你切换“静音”设置时,聊天将不会重新连接,从而解决了最初的问题!

包装来自 props 的事件处理器

当你的组件接收一个事件处理器作为 prop 时,可能会遇到类似的问题:

假设父组件在每次渲染时都传递一个不同的onReceiveMessage 函数:

由于onReceiveMessage是一个依赖项,它会导致 Effect 在每次父组件重新渲染后重新同步。这将使其重新连接到聊天室。为了解决这个问题,将调用包装在一个 Effect Event 中:

Effect Events 不是响应性的,因此你不需要将它们指定为依赖项。因此,即使父组件传递的函数在每次重新渲染时都不同,聊天室也不会再重新连接。

分离响应性和非响应性代码

在这个例子中,你希望在每次 roomId变化时记录一次访问。你希望每次日志都包含当前的notificationCount,但你希望 notificationCount的变化触发日志事件。

解决方案同样是,将非响应性代码提取到一个 Effect Event 中:

你希望你的逻辑能响应 roomId的变化,所以你在 Effect 内部读取了roomId。然而,你不希望 notificationCount的变化导致记录额外的访问,所以你在 Effect Event 内部读取了notificationCount了解如何使用 Effect Event 从 Effect 中读取最新的 props 和 state。

是否有某些响应式值在无意中发生了变化?

有时,你确实希望你的 Effect 能“响应”某个特定的值,但这个值的变化频率可能比你期望的要高——并且可能并不反映用户视角下的任何实际变化。例如,假设你在组件的函数体中创建了一个options 对象,然后在 Effect 内部读取该对象:

这个对象在组件函数体中声明,因此它是一个响应式值。当你在 Effect 内部读取这样的响应式值时,你需要将其声明为依赖项。这确保了你的 Effect 能“响应”它的变化:

将其声明为依赖项很重要!例如,这确保了如果 roomId发生变化,你的 Effect 将使用新的options重新连接到聊天室。然而,上面的代码也存在一个问题。要看到这个问题,请尝试在下面的沙箱中输入内容,并观察控制台中发生的情况:

在上面的沙箱中,输入框仅更新 message 状态变量。从用户的角度来看,这不应该影响聊天连接。然而,每次你更新 message时,你的组件都会重新渲染。当你的组件重新渲染时,其内部的代码会从头开始重新运行。

每次ChatRoom组件重新渲染时,都会从头创建一个新的options对象。React 会认为这个options对象与上一次渲染时创建的options对象是不同的对象。这就是为什么它会重新同步你的 Effect(该 Effect 依赖于options),导致你在输入时聊天会重新连接。

这个问题只影响对象和函数。在 JavaScript 中,每个新创建的对象和函数都被认为是与其他所有对象和函数不同的。即使它们内部的内容相同,也无关紧要!

对象和函数依赖项可能导致你的 Effect 比所需更频繁地重新同步。

因此,只要有可能,你应该尽量避免将对象和函数作为 Effect 的依赖项。相反,尝试将它们移到组件外部、移到 Effect 内部,或者从中提取出原始值。

将静态对象和函数移到组件外部

如果对象不依赖于任何 props 和 state,你可以将该对象移到组件外部:

这样,你就向 linter证明了它不是响应式的。它不会因为重新渲染而改变,因此不需要成为依赖项。现在重新渲染 ChatRoom不会导致你的 Effect 重新同步。

这对函数也适用:

由于createOptions是在组件外部声明的,它不是响应式值。这就是为什么它不需要在 Effect 的依赖项中指定,也永远不会导致你的 Effect 重新同步。

将动态对象和函数移到 Effect 内部

如果你的对象依赖于某些可能因重新渲染而改变的响应式值,例如 roomIdprop,你就不能将其移到组件外部。但是,你可以将其创建过程移到Effect 代码内部:

现在 options是在 Effect 内部声明的,它不再是 Effect 的依赖项。相反,Effect 使用的唯一响应式值是roomId。由于roomId 不是对象或函数,你可以确信它不会 无意中变得不同。在 JavaScript 中,数字和字符串是按内容比较的:

由于这个修复,当你编辑输入框时,聊天不再重新连接:

然而,当你更改roomId下拉菜单时,它确实会重新连接,正如你所预期的那样。

这对函数也适用:

你可以编写自己的函数来在 Effect 内部组织逻辑片段。只要你也将它们声明在 Effect内部,它们就不是响应式值,因此不需要成为 Effect 的依赖项。

从对象中读取原始值

有时,你可能从 props 接收到一个对象:

这里的风险在于父组件会在渲染期间创建对象:

这会导致你的 Effect 在父组件每次重新渲染时都重新连接。要解决这个问题,请在 Effect外部从对象中读取信息,并避免使用对象和函数作为依赖项:

逻辑变得有点重复(你在 Effect 外部从对象中读取一些值,然后在 Effect 内部创建一个具有相同值的对象)。但这使得你的 Effect实际依赖哪些信息变得非常明确。如果父组件无意中重新创建了对象,聊天室将不会重新连接。然而,如果options.roomIdoptions.serverUrl确实不同,聊天室将会重新连接。

从函数中计算原始值

同样的方法也适用于函数。例如,假设父组件传递了一个函数:

为了避免使其成为依赖项(并导致在重新渲染时重新连接),请在 Effect 外部调用它。这会给你提供roomIdserverUrl这些不是对象的值,你可以在 Effect 内部读取它们:

这只适用于 函数,因为它们在渲染期间调用是安全的。如果你的函数是一个事件处理函数,但你不希望它的变化重新同步你的 Effect,请改用 Effect Event 包装它。

回顾

  • 依赖项应始终与代码匹配。
  • 当您对依赖项不满意时,您需要编辑的是代码。
  • 抑制 linter 会导致非常令人困惑的错误,您应始终避免这样做。
  • 要移除一个依赖项,您需要向 linter “证明”它是不必要的。
  • 如果某些代码应在特定交互发生时运行,请将该代码移至事件处理程序中。
  • 如果 Effect 的不同部分因不同原因需要重新运行,请将其拆分为多个 Effect。
  • 如果您想基于先前的状态更新某些状态,请传递一个更新函数。
  • 如果您想读取最新值而不“响应”它,请从 Effect 中提取一个 Effect Event。
  • 在 JavaScript 中,如果对象和函数是在不同时间创建的,则它们被视为不同的。
  • 尽量避免对象和函数依赖项。将它们移到组件外部或 Effect 内部。

Try out some challenges

Challenge 1 of 4:Fix a resetting interval #

This Effect sets up an interval that ticks every second. You’ve noticed something strange happening: it seems like the interval gets destroyed and re-created every time it ticks. Fix the code so that the interval doesn’t get constantly re-created.