你可能不需要 Effect
Effect 是 React 范式的一个逃生舱。它们让你可以“跳出” React,并将你的组件与某些外部系统(如非 React 组件、网络或浏览器 DOM)同步。如果不涉及外部系统(例如,当某些 props 或 state 变化时,你想更新组件的状态),你就不应该使用 Effect。移除不必要的 Effect 将使你的代码更易于理解、运行更快且更不易出错。
你将学习
- 为什么以及如何从组件中移除不必要的 Effect
- 如何在不使用 Effect 的情况下缓存昂贵的计算
- 如何在不使用 Effect 的情况下重置和调整组件状态
- 如何在事件处理程序之间共享逻辑
- 哪些逻辑应该移到事件处理程序中
- 如何通知父组件有关更改
如何移除不必要的 Effect
有两种常见情况你不需要 Effect:
- 你不需要 Effect 来转换用于渲染的数据。例如,假设你想在显示列表之前对其进行筛选。你可能很想写一个 Effect,在列表变化时更新一个状态变量。然而,这是低效的。当你更新状态时,React 会首先调用你的组件函数来计算屏幕上应该显示什么。然后 React 会“提交”这些更改到 DOM,更新屏幕。然后 React 会运行你的 Effect。如果你的 Effect也立即更新状态,这将从头开始重启整个过程!为了避免不必要的渲染过程,请在组件的顶层转换所有数据。每当你的 props 或 state 变化时,该代码将自动重新运行。
- 你不需要 Effect 来处理用户事件。例如,假设你想在用户购买产品时发送一个
/api/buyPOST 请求并显示通知。在“购买”按钮的点击事件处理程序中,你确切地知道发生了什么。当 Effect 运行时,你不知道用户做了什么(例如,点击了哪个按钮)。这就是为什么你通常会在相应的事件处理程序中处理用户事件。
你确实需要 Effect 来同步外部系统。例如,你可以写一个 Effect 来保持 jQuery 组件与 React 状态同步。你也可以用 Effect 获取数据:例如,你可以将搜索结果与当前搜索查询同步。请记住,现代的框架提供了比直接在组件中编写 Effect 更高效的内置数据获取机制。
为了帮助你获得正确的直觉,让我们看一些常见的具体例子!
基于 props 或 state 更新状态
假设你有一个包含两个状态变量的组件:firstName和lastName。你想通过拼接它们来计算一个fullName。此外,你希望fullName在firstName或lastName变化时更新。你的第一反应可能是添加一个fullName状态变量并在 Effect 中更新它:
这比必要的更复杂。它也是低效的:它使用fullName的过时值进行了一次完整的渲染过程,然后立即使用更新后的值重新渲染。移除状态变量和 Effect:
当某些东西可以从现有的 props 或 state 计算出来时,不要将其放入状态。相反,在渲染期间计算它。这使你的代码更快(你避免了额外的“级联”更新)、更简单(你移除了一些代码)且更不易出错(你避免了不同状态变量彼此不同步导致的错误)。如果这种方法对你来说很陌生,Thinking in React解释了应该将什么放入状态。
缓存昂贵的计算
这个组件通过接收 props 中的todos并根据filterprop 对其进行筛选来计算visibleTodos。你可能很想将结果存储在状态中,并通过 Effect 更新它:
与前面的例子一样,这既没有必要,效率也不高。首先,移除状态和 Effect:
通常,这段代码没问题!但也许getFilteredTodos()很慢,或者你有大量的todos。在这种情况下,如果某个不相关的状态变量(如newTodo)发生了变化,你就不希望重新计算getFilteredTodos()。
你可以通过将昂贵的计算包装在“记忆化” 的 useMemoHook 中来缓存(或
注意
React Compiler可以自动为你记忆化昂贵的计算,从而在许多情况下无需手动使用useMemo。
或者,写成单行形式:
这告诉 React,除非 todos 或 filter发生变化,否则你不希望内部函数重新运行。React 会记住初始渲染时getFilteredTodos() 的返回值。在后续渲染中,它会检查 todos 或 filter是否不同。如果它们与上次相同,useMemo将返回它存储的上一个结果。但如果它们不同,React 将再次调用内部函数(并存储其结果)。
你包装在 useMemo中的函数在渲染期间运行,因此这只适用于纯计算。
当 prop 改变时重置所有状态
这个ProfilePage 组件接收一个 userIdprop。页面包含一个评论输入框,你使用一个comment状态变量来保存其值。有一天,你发现了一个问题:当你从一个个人资料导航到另一个时,comment状态不会重置。因此,很容易意外地将评论发布到错误的用户个人资料上。为了解决这个问题,你希望在userId 发生变化时清除 comment状态变量:
这是低效的,因为 ProfilePage 及其子组件会先用旧值渲染,然后再渲染一次。这也很复杂,因为你需要在 每个在 ProfilePage内部拥有某些状态的组件中都这样做。例如,如果评论 UI 是嵌套的,你还需要清除嵌套的评论状态。
相反,你可以通过给组件一个明确的 key 来告诉 React,每个用户的个人资料在概念上是不同的个人资料。将你的组件拆分为两个,并从外部组件向内部组件传递一个key 属性:
通常,当同一个组件在同一个位置渲染时,React 会保留其状态。通过将 userId作为key传递给Profile组件,你是在要求 React 将两个具有不同Profile 的 userId组件视为不应共享任何状态的两个不同组件。每当 key(你已将其设置为userId)发生变化时,React 将重新创建 DOM 并重置状态 的 Profile组件及其所有子组件。现在,当在不同个人资料之间导航时,comment 字段将自动清空。
请注意,在此示例中,只有外部的ProfilePage 组件被导出并对项目中的其他文件可见。渲染 ProfilePage的组件不需要向其传递 key:它们将userId作为常规 prop 传递。ProfilePage 将其作为 key传递给内部的Profile 组件是一个实现细节。
当 prop 改变时调整某些状态
有时,你可能希望在 prop 改变时重置或调整部分状态,但不是全部状态。
这个List 组件接收一个 items 列表作为 prop,并在 selection状态变量中维护选中的项。每当itemsprop 接收到一个不同的数组时,你希望将selection 重置为 null:
这同样不理想。每次 items改变时,List 及其子组件将首先使用过时的 selection值进行渲染。然后 React 将更新 DOM 并运行 Effect。最后,setSelection(null) 调用将导致 List及其子组件再次重新渲染,重新启动整个过程。
首先删除 Effect。相反,在渲染期间直接调整状态:
存储来自先前渲染的信息可能难以理解,但这比在 Effect 中更新同一状态要好。在上面的例子中,setSelection是在渲染期间直接调用的。React 将在List立即 退出其 return语句后重新渲染它。此时 React 尚未渲染List的子组件或更新 DOM,因此这可以让List的子组件跳过渲染过时的selection 值。
当你在渲染期间更新组件时,React 会丢弃返回的 JSX 并立即重试渲染。为了避免非常缓慢的级联重试,React 只允许你在渲染期间更新同一组件的状态。如果你在渲染期间更新另一个组件的状态,你会看到一个错误。像 items !== prevItems这样的条件是避免循环所必需的。你可以像这样调整状态,但任何其他副作用(如更改 DOM 或设置超时)应保留在事件处理程序或 Effect 中,以保持组件纯粹。
尽管这种模式比 Effect 更高效,但大多数组件也不需要它。无论你如何操作,基于 props 或其他状态来调整状态都会使你的数据流更难理解和调试。始终检查是否可以通过使用 key 重置所有状态或在渲染期间计算所有内容来实现。例如,与其存储(和重置)选中的项目,不如存储选中的项目 ID:
现在完全不需要“调整”状态了。如果具有选中 ID 的项在列表中,它就会保持选中状态。如果不在,那么在渲染期间计算出的selection 将是 null,因为没有找到匹配的项。这种行为是不同的,但可以说更好,因为对 items的大多数更改都会保留选中状态。
在事件处理函数之间共享逻辑
假设你有一个产品页面,上面有两个按钮(购买和结账),它们都允许你购买该产品。你希望在用户将产品放入购物车时显示通知。在两个按钮的点击处理函数中都调用showNotification() 感觉重复,因此你可能会想把这段逻辑放在 Effect 中:
这个 Effect 是不必要的。它也很可能会导致 bug。例如,假设你的应用在页面重新加载之间“记住”了购物车。如果你将产品添加到购物车一次并刷新页面,通知会再次出现。每次刷新该产品页面时,它都会不断出现。这是因为product.isInCart在页面加载时就已经是true,所以上面的 Effect 会调用showNotification()。
当你不确定某些代码应该放在 Effect 中还是事件处理函数中时,问问自己这段代码为什么需要运行。仅将 Effect 用于那些因为组件显示给用户而应该运行的代码。在这个例子中,通知应该出现是因为用户按下了按钮,而不是因为页面被显示了!删除 Effect,并将共享逻辑放入一个函数中,从两个事件处理函数中调用它:
这既移除了不必要的 Effect,又修复了 bug。
发送 POST 请求
这个Form 组件发送两种 POST 请求。它在挂载时发送一个分析事件。当你填写表单并点击提交按钮时,它会向 /api/register端点发送一个 POST 请求:
让我们应用与之前示例相同的标准。
分析 POST 请求应保留在 Effect 中。这是因为发送分析事件的原因是表单被显示了。(在开发环境中它会触发两次,但请参阅此处了解如何处理。)
然而,/api/register POST 请求并非由表单被显示所引起。你只想在某个特定时刻发送请求:即用户按下按钮时。它只应在那次特定的交互中发生。删除第二个 Effect 并将该 POST 请求移至事件处理程序中:
当你决定将某些逻辑放入事件处理程序还是 Effect 中时,你需要回答的主要问题是,从用户的角度来看,这是什么类型的逻辑。如果该逻辑是由特定交互引起的,请将其保留在事件处理程序中。如果它是由用户看到组件在屏幕上显示所引起的,请将其保留在 Effect 中。
计算链
有时你可能会忍不住想要链式调用多个 Effect,每个 Effect 都根据其他状态来调整一部分状态:
这段代码存在两个问题。
第一个问题是效率非常低下:组件(及其子组件)必须在链式调用中的每个 set调用之间重新渲染。在上面的例子中,最坏情况下(setCard→ 渲染 →setGoldCardCount→ 渲染 →setRound→ 渲染 →setIsGameOver→ 渲染),下方的树会有三次不必要的重新渲染。
第二个问题是,即使它不慢,随着代码的演进,你也会遇到编写的“链”不符合新需求的情况。假设你要添加一种逐步查看游戏历史走法的方式。你会通过将每个状态变量更新为过去的值来实现。然而,将 card状态设置为过去的值会再次触发 Effect 链,并改变你正在显示的数据。这样的代码通常僵化且脆弱。
在这种情况下,最好在渲染期间计算你能计算的内容,并在事件处理程序中调整状态:
这样效率高得多。此外,如果你实现了查看游戏历史的功能,现在你将能够将每个状态变量设置为过去的走法,而不会触发调整其他所有值的 Effect 链。如果需要在多个事件处理程序之间复用逻辑,你可以提取一个函数并从这些处理程序中调用它。
请记住,在事件处理程序内部,状态的行为类似于快照。例如,即使你调用了 setRound(round + 1),round 变量仍将反映用户点击按钮时的值。如果需要使用下一个值进行计算,请手动定义它,例如 const nextRound = round + 1。
在某些情况下,你无法直接在事件处理程序中计算下一个状态。例如,想象一个包含多个下拉菜单的表单,其中下一个下拉菜单的选项取决于前一个下拉菜单的选定值。那么,Effect 链是合适的,因为你正在与网络同步。
初始化应用程序
某些逻辑只应在应用加载时运行一次。
你可能会想把它放在顶层组件的 Effect 中:
然而,你很快会发现它在开发环境下会运行两次。这可能会导致问题——例如,也许它会因为函数设计时未考虑被调用两次而使认证令牌失效。通常,你的组件应该能够适应被重新挂载。这包括你的顶层App组件。
尽管在生产实践中它可能永远不会被重新挂载,但在所有组件中遵循相同的约束条件会使代码更容易移动和复用。如果某些逻辑必须在每次应用加载时运行一次,而不是每次组件挂载时运行一次,可以添加一个顶层变量来跟踪它是否已经执行过:
你也可以在模块初始化期间和应用渲染之前运行它:
顶层代码在你的组件被导入时会运行一次——即使它最终没有被渲染。为了避免在导入任意组件时出现性能下降或意外行为,不要过度使用这种模式。将应用范围的初始化逻辑保留在根组件模块中,例如App.js,或者放在应用的入口点。
向父组件通知状态变化
假设你正在编写一个带有内部状态Toggle的isOn组件,该状态可以是true或false。有几种不同的切换方式(通过点击或拖动)。你希望在Toggle内部状态发生变化时通知父组件,因此你暴露了一个onChange事件,并从 Effect 中调用它:
和之前一样,这并不理想。Toggle组件先更新其状态,然后 React 更新屏幕。接着 React 运行 Effect,该 Effect 会调用从父组件传递来的onChange 函数。现在父组件将更新其自身状态,从而开始另一次渲染过程。最好是在一次过程中完成所有事情。
删除 Effect,改为在同一个事件处理程序中更新两个组件的状态:
采用这种方法,Toggle组件及其父组件都会在事件期间更新其状态。React 会将来自不同组件的更新批量处理在一起,因此只会进行一次渲染过程。
您或许还可以完全移除状态,改为从父组件接收 isOn:
“状态提升”让父组件通过切换其自身状态来完全控制Toggle。这意味着父组件将包含更多逻辑,但总体上需要担心的状态会更少。每当您尝试保持两个不同的状态变量同步时,请尝试改为提升状态!
向父组件传递数据
这个Child组件获取一些数据,然后通过 Effect 将其传递给Parent 组件:
在 React 中,数据从父组件流向其子组件。当您在屏幕上看到错误时,可以通过向上遍历组件链来追踪信息的来源,直到找到传递错误 prop 或拥有错误状态的组件。当子组件在 Effect 中更新其父组件的状态时,数据流会变得非常难以追踪。由于子组件和父组件都需要相同的数据,可以让父组件获取该数据,然后向下传递给子组件:
这样更简单,并且保持了数据流的可预测性:数据从父组件向下流向子组件。
订阅外部存储
有时,您的组件可能需要订阅 React 状态之外的某些数据。这些数据可能来自第三方库或内置的浏览器 API。由于这些数据可能在 React 不知情的情况下发生变化,您需要手动让组件订阅它。这通常通过 Effect 来完成,例如:
这里,组件订阅了一个外部数据存储(在本例中是浏览器 navigator.onLineAPI)。由于此 API 在服务器上不存在(因此不能用于初始 HTML),初始状态被设置为true。每当该数据存储的值在浏览器中发生变化时,组件就会更新其状态。
虽然通常使用 Effect 来实现这一点,但 React 有一个专门用于订阅外部存储的 Hook,更推荐使用它。删除 Effect 并用对useSyncExternalStore的调用来替换它:
这种方法比使用 Effect 手动将可变数据同步到 React 状态更不容易出错。通常,您会编写一个像上面useOnlineStatus()这样的自定义 Hook,这样就不需要在各个组件中重复此代码。阅读更多关于从 React 组件订阅外部存储的信息。
获取数据
许多应用使用 Effect 来启动数据获取。编写像下面这样的数据获取 Effect 非常常见:
你不需要将这个获取操作移到事件处理函数中。
这似乎与之前需要将逻辑放入事件处理函数的例子相矛盾!然而,请考虑一下,主要的获取原因并不是输入事件。搜索输入框的内容通常是从 URL 预填充的,用户可能在不触碰输入框的情况下通过前进/后退按钮进行导航。
无论page和query来自哪里。只要这个组件可见,你就希望保持results与网络数据同步,以反映当前的page和query。这就是为什么它是一个 Effect。
然而,上面的代码有一个缺陷。假设你快速输入"hello"。那么query将从"h"变为"he"、"hel"、"hell"和"hello"。这将触发多个独立的获取请求,但无法保证响应到达的顺序。例如,"hell"的响应可能在之后才到达。由于它会最后调用setResults(),你将显示错误的搜索结果。这被称为“竞态条件”:两个不同的请求相互“竞争”,并以与你预期不同的顺序到达。
要修复竞态条件,你需要添加一个清理函数来忽略过时的响应:
这确保了当你的 Effect 获取数据时,除了最后一次请求的响应外,其他所有响应都将被忽略。
处理竞态条件并不是实现数据获取时唯一的难点。你可能还需要考虑缓存响应(以便用户可以点击返回并立即看到之前的屏幕)、如何在服务器上获取数据(以便初始服务器渲染的 HTML 包含获取的内容而不是加载指示器),以及如何避免网络瀑布流(以便子组件可以在不等待每个父组件的情况下获取数据)。
这些问题适用于任何 UI 库,而不仅仅是 React。解决它们并非易事,这就是为什么现代框架提供了比在 Effect 中获取数据更高效的内置数据获取机制。
如果你不使用框架(并且不想构建自己的框架),但又希望使 Effect 中的数据获取更加人性化,可以考虑将你的获取逻辑提取到一个自定义 Hook 中,如下例所示:
你可能还想添加一些错误处理逻辑以及跟踪内容是否正在加载。你可以自己构建这样的 Hook,或者使用 React 生态系统中已有的众多解决方案之一。虽然仅凭这一点不会像使用框架的内置数据获取机制那样高效,但将数据获取逻辑移入自定义 Hook 将使你以后更容易采用高效的数据获取策略。
一般来说,每当你不得不编写 Effect 时,请留意是否可以将一部分功能提取到一个自定义 Hook 中,该 Hook 具有更具声明性和针对性的 API,如上文的useData。你的组件中原始的useEffect 调用越少,你就越容易维护你的应用程序。
回顾
- 如果可以在渲染期间计算某些内容,则不需要使用 Effect。
- 要缓存昂贵的计算,请添加
useMemo而不是useEffect。 - 要重置整个组件树的状态,请向其传递一个不同的
key。 - 要响应 prop 的变化重置特定的状态位,请在渲染期间设置它。
- 因为组件 显示而运行的代码应该在 Effect 中,其余的应该在事件中。
- 如果需要更新多个组件的状态,最好在单个事件中进行。
- 每当尝试同步不同组件中的状态变量时,请考虑状态提升。
- 可以使用 Effect 获取数据,但需要实现清理以避免竞态条件。
Try out some challenges
Challenge 1 of 4:Transform data without Effects #
The TodoList below displays a list of todos. When the “Show only active todos” checkbox is ticked, completed todos are not displayed in the list. Regardless of which todos are visible, the footer displays the count of todos that are not yet completed.
Simplify this component by removing all the unnecessary state and Effects.
