你可能不需要React的Effect
你可能不需要React的Effect
守拙引言
在React的应用中,useEffect
钩子是一个让我们能够“跳出”React体系,与外部系统如非React组件、网络请求或浏览器DOM等同步的机制。如果没有任何外部系统参与(例如,当一些props或state变化时你想要更新组件的状态),你就不应该需要Effect。移除不必要的Effect将使你的代码更容易理解、运行更快,并且更少出错。
你将学到
- 为什么要以及如何从你的组件中移除不必要的Effect
- 如何在没有Effect的情况下缓存昂贵的计算
- 如何在没有Effect的情况下重置和调整组件状态
- 如何在事件处理器之间共享逻辑
- 哪些逻辑应该移动到事件处理器
- 如何通知父组件关于变化的信息
如何移除不必要的Effect
你不需要Effect的两种常见情况:
你不需要Effect来转换渲染数据。 例如,假设你想要在显示之前过滤一个列表。你可能会想要写一个Effect,在列表变化时更新状态变量。然而,这是低效的。当你更新状态时,React首先会调用你的组件函数来计算屏幕上应该显示什么。然后React会将这些变更“提交”到DOM,更新屏幕。然后React会运行你的Effect。如果你的Effect也立即更新状态,这将从头开始重启整个过程!为了避免不必要的渲染通过,在你的组件顶层转换所有数据。只要你的props或state发生变化,那段代码就会自动重新运行。
你不需要Effect来处理用户事件。 例如,假设当用户购买产品时,你想发送一个
/api/buy
POST请求并显示通知。在购买按钮的点击事件处理器中,你确切地知道发生了什么。到了Effect运行时,你不知道用户做了什么(例如,点击了哪个按钮)。这就是为什么你通常会在相应的事件处理器中处理用户事件。
你需要Effect来与外部系统同步。例如,你可以写一个Effect来保持一个jQuery组件与React状态的同步。你也可以使用Effect进行数据获取:例如,你可以同步搜索结果与当前的搜索查询。请记住,现代框架提供了比你直接在组件中写Effect更有效的内置数据获取机制。
为了帮助你获得正确的直觉,让我们看一些常见的具体示例!
基于props或state更新状态
假设你有一个组件,它有两个状态变量:firstName
和lastName
。你想要通过连接它们来计算fullName
。此外,你希望fullName
在firstName
或lastName
变化时更新。你的第一个直觉可能是添加一个fullName
状态变量并在Effect中更新它:
1 | function Form() { |
这比必要的更复杂。它也太低效了:它先用fullName
的旧值进行一次完整的渲染通过,然后立即用更新后的值重新渲染。移除状态变量和Effect:
1 | function Form() { |
当某件事可以从现有的props或state计算出来时,不要把它放在状态中。相反,在渲染期间计算它。 这使你的代码更快(你避免了额外的“级联”更新),更简单(你移除了一些代码),并且更少出错(你避免了由于不同的状态变量彼此不同步而引起的错误)。如果这种方法对你来说是新的,Thinking in React解释了什么应该放入状态。
缓存昂贵的计算
这个组件通过接收到的todos
props并根据filter
prop进行过滤来计算visibleTodos
。你可能会想要在状态中存储结果并从Effect中更新它:
1 | function TodoList({ todos, filter }) { |
就像前面的例子一样,这既不必要也不高效。首先,移除状态和Effect:
1 | function TodoList({ todos, filter }) { |
通常,这段代码就可以了!但也许getFilteredTodos()
很慢,或者你有很多todos
。在这种情况下,如果一些不相关的状态变量(如newTodo
)发生了变化,你不想重新计算getFilteredTodos()
。
你可以通过将useMemo
Hook包装在内来缓存(或“记忆”)昂贵的计算:
1 | import { useMemo, useState } from 'react'; |
或者,写成单行:
1 | import { useMemo, useState } from 'react'; |
这告诉React,你不希望内部函数在todos
或filter
变化时重新运行。 React将在初始渲染期间记住getFilteredTodos()
的返回值。在下一次渲染时,它将检查todos
或filter
是否不同。如果它们和上次一样,useMemo
将返回它存储的最后一个结果。但如果它们不同,React将再次调用内部函数(并存储其结果)。
你在useMemo
中包装的函数在渲染期间运行,所以这只适用于纯计算。
如何判断一个计算是否昂贵?
通常情况下,除非你正在创建或循环遍历成千上万个对象,否则它可能并不昂贵。如果你想获得更多的信心,你可以添加一个控制台日志来测量代码段花费的时间:
1 | console.time('filter array'); |
执行你正在测量的交互(例如,输入到输入框)。然后你将在控制台看到像filter array: 0.15ms
的日志。如果记录的总时间加起来达到了一个显著的数量(比如说,1ms
或更多),那么对那个计算进行记忆可能是有意义的。作为一个实验,你然后将计算包装在useMemo
中,以验证那次交互的总记录时间是否减少了:
1 | console.time('filter array'); |
useMemo
不会使第一次渲染更快。它只帮助你跳过更新时的不必要工作。
请记住,你的机器可能比你的用户快,所以使用人工减慢进行性能测试是一个好主意。例如,Chrome提供了CPU节流选项。
还请注意,在开发中测量性能不会给出最准确的结果。(例如,当严格模式开启时,你会看到每个组件渲染两次而不是一次。)要获得最准确的时间,构建你的应用程序进行生产并在像你的用户拥有的设备上进行测试。
当prop变化时重置所有状态
这个ProfilePage
组件接收一个userId
prop。页面包含一个注释输入,你使用一个comment
状态变量来保存它的值。有一天,你注意到一个问题:当你从一个配置文件导航到另一个时,comment
状态没有被重置。因此,很容易在错误的用户配置文件上意外发布评论。为了解决这个问题,你希望在userId
变化时清除comment
状态变量:
1 | export default function ProfilePage({ userId }) { |
这是低效的,因为ProfilePage
及其子组件将首先使用旧值渲染,然后再次渲染。这也很复杂,因为你会需要在ProfilePage
内部的每个组件中这样做。例如,如果注释UI是嵌套的,你也会想要清除嵌套的注释状态。
相反,你可以通过给它一个明确的key来告诉React,每个用户配置文件在概念上是不同的配置文件。将你的组件分成两部分,并将外部组件的key
属性传递到内部组件:
1 | export default function ProfilePage({ userId }) { |
通常,React会在相同的位置渲染相同的组件时保留状态。通过将userId
作为key
传递给Profile
组件,你是在要求React将具有不同userId
的两个Profile
组件视为两个不同的组件,它们不应该共享任何状态。每当你设置的key(这里是userId
)变化时,React将重新创建DOM并重置Profile
组件及其所有子组件的状态。现在,当你在配置文件之间导航时,comment
字段将自动清空。
注意,在本例中,只有外部的ProfilePage
组件被导出并对项目中的其他文件可见。渲染ProfilePage
的组件不需要将key传递给它:它们将userId
作为一个常规prop传递。ProfilePage
将它作为一个key
传递给内部的Profile
组件是一个实现细节。
当prop变化时调整部分状态
有时,你可能想在prop变化时重置或调整部分状态,而不是全部。
这个List
组件接收一个items
列表作为prop,并在selection
状态变量中维护选定的项目。当items
prop接收到不同的数组时,你想将selection
重置为null
:
1 | function List({ items }) { |
这也是不理想的。每次items
变化时,List
及其子组件将首先用旧的selection
值渲染。然后React将更新DOM并运行Effect。最后,setSelection(null)
调用将导致List
及其子组件再次渲染,重新开始整个过程。
首先删除Effect。相反,在渲染期间直接调整状态:
1 | function List({ items }) { |
像这样存储来自先前渲染的信息可能难以理解,但这比在Effect中更新相同的状态要好。在上面的例子中,setSelection
是在渲染期间直接调用的。React将在退出时立即重新渲染List
,并且React还没有渲染List
的子组件或更新DOM,这允许List
的子组件跳过渲染旧的selection
值。
当你在渲染期间更新组件时,React会丢弃返回的JSX并立即重试渲染。为了避免非常慢的级联重试,React只允许你在渲染期间更新同一个组件的状态。如果你在渲染期间更新另一个组件的状态,你会看到一个错误。像items !== prevItems
这样的条件是避免循环所必需的。你可以像这样调整状态,但任何其他副作用(如更改DOM或设置超时)应该保留在事件处理器或Effect中,以保持组件的纯净性。
尽管这种模式比Effect更有效率,但大多数组件也不应该需要它。 无论你怎么做,基于props或其他状态调整状态会使你的数据流更难以理解和调试。始终检查你是否能通过一个key重置所有状态或在渲染期间计算一切。例如,而不是存储(和重置)选定的_项_,你可以存储选定的_项ID_:
1 | function List({ items }) { |
现在没有必要“调整”状态了。如果列表中有选定ID的项,它将保持被选中。如果没有,由于没有找到匹配的项,渲染期间计算的selection
将是null
。这种行为是不同的,但可以说是更好的,因为大多数对items
的更改都会保留选择。
在事件处理器之间共享逻辑
假设你有一个产品页面,上面有两个按钮(购买和结账),它们都可以让你购买该产品。你希望每当用户将产品放入购物车时都显示通知。在两个按钮的点击处理器中调用showNotification()
感觉重复,所以你可能会被诱惑将这个逻辑放在Effect中:
1 | function ProductPage({ product, addToCart }) { |
这个Effect是不必要的。它也很可能导致错误。例如,假设你的应用程序在页面重新加载之间“记住”了购物车。如果你一次将产品添加到购物车并刷新页面,通知将再次出现。每次刷新该产品的页面时,它都会不断出现。这是因为product.isInCart
在页面加载时已经是true
,所以上面的Effect将调用showNotification()
。
当你不确定某些代码应该放在Effect还是事件处理器中时,问问自己为什么这个代码需要运行。只在因为组件被展示给用户而需要运行的代码使用Effect。 在这个例子中,通知应该因为用户_按下了按钮_而出现,而不是因为页面被展示了!删除Effect,并将共享逻辑放入一个从两个事件处理器调用的函数中:
1 | function ProductPage({ product, addToCart }) { |
这既去除了不必要的Effect,也修复了错误。
发送POST请求
这个Form
组件发送两种POST请求。当它挂载时发送一个分析事件。当你填写表单并点击提交按钮时,它将向/api/register
端点发送POST请求:
1 | function Form() { |
让我们应用前面例子中的相同标准。
分析POST请求应该保留在Effect中。这是因为发送分析事件的_原因_是表单被展示了。(它在开发中会触发两次,但看看这里如何处理。)
然而,/api/register
POST请求不是由表单被_展示_引起的。你只想在一个特定时刻发送请求:当用户按下按钮时。它应该只在那个特定的交互中发生。删除第二个Effect,并将那个POST请求移动到事件处理器中:
1 | function Form() { |
当你选择将某些逻辑放入事件处理器还是Effect时,你需要回答的主要问题是_从用户的角度来看这是什么类型的逻辑_。如果这个逻辑是由特定的交互引起的,就保留在事件处理器中。如果它是由用户_看到_屏幕上的组件引起的,就保留在Effect中。
计算链
有时你可能会想要链式Effect,每个Effect基于其他状态调整一部分状态:
1 | function Game() { |
这段代码有两个问题。
一个问题是它非常低效:组件(及其子组件)必须在链中的每个set
调用之间重新渲染。在上述例子中,最坏的情况(setCard
→ 渲染 → setGoldCardCount
→ 渲染 → setRound
→ 渲染 → setIsGameOver
→ 渲染)中有三次不必要的树下方重新渲染。
即使它不慢,随着你的代码发展,你会遇到你写的“链”不适应新要求的情况。想象一下,你正在添加一种通过游戏移动历史的方式。你将通过更新每个状态变量到过去值来实现。然而,将card
状态设置为过去的值将再次触发Effect链并更改你正在显示的数据。这样的代码通常很死板且脆弱。
在这种情况下,最好是在渲染期间计算你能计算的一切,并在事件处理器中调整状态:
1 | function Game() { |
这样更有效率。此外,如果你实现了查看游戏历史的方式,现在你将能够将每个状态变量设置为过去的移动,而不会触发调整每个其他值的Effect链。如果你需要在几个事件处理器之间重用逻辑,你可以提取一个函数并从这些处理器调用它。
记住,在事件处理器中,状态表现得像一个快照。例如,即使你调用了setRound(round + 1)
,round
变量也会反映用户点击按钮时的值。如果你需要使用下一个值进行计算,像const nextRound = round + 1
这样手动定义它。
在某些情况下,你不能直接在事件处理器中计算下一个状态。例如,想象一下一个表单有多个下拉菜单,下一个下拉菜单的选项依赖于前一个下拉菜单的选定值。然后,链式Effect是合适的,因为你正在与网络同步。
初始化应用程序
有些逻辑应该只在应用程序加载时运行一次。
你可能会想要把它放在顶层组件的Effect中:
1 | function App() { |
然而,你很快就会发现它在开发中运行了两次。这可能会导致问题 - 例如,也许它因为该函数没有设计成被调用两次而使认证令牌无效。通常,你的组件应该能够承受被重新挂载。这包括你的顶层App
组件。
虽然在生产实践中可能永远不会被重新挂载,但在所有组件中遵循相同的约束可以使代码更容易移动和重用。如果某些逻辑必须每个应用程序加载一次而不是每个组件挂载一次,添加一个顶层变量来跟踪它是否已经执行过:
1 | let didInit = false; |
你还可以在使用应用程序之前在模块初始化期间运行它:
1 | if (typeof window !== 'undefined') { // 检查我们是否在浏览器中运行。 |
顶层的代码在你的组件被导入时运行一次 - 即使它最终没有被渲染。为了避免在导入任意组件时减慢速度或出现意外行为,不要过度使用这种模式。将应用程序范围的初始化逻辑保留在像App.js
这样的根组件模块或你的应用程序入口点。
通知父组件状态变化
假设你正在编写一个Toggle
组件,它有一个内部的isOn
状态,可以是true
或false
。有几种不同的方法可以切换它(通过点击或拖动)。你想在Toggle
内部状态变化时通知父组件,所以你公开了一个onChange
事件,并在Effect中调用它:
1 | function Toggle({ onChange }) { |
像前面的例子一样,这是不理想的。Toggle
首先更新它的状态,然后React更新屏幕。然后React运行Effect,调用从父组件传递过来的onChange
函数。现在父组件将更新自己的状态,开始另一次渲染通过。最好是在单个通过中完成所有操作。
删除Effect,而是在同一个事件处理器中更新两个组件的状态:
1 | function Toggle({ onChange }) { |
这种方法,Toggle
组件和它的父组件在事件期间更新它们的状态。React将不同组件的更新批次在一起,所以只会有一次渲染通过。
你可能也可以完全移除状态,而是从父组件接收isOn
:
1 | // 好:组件完全由其父组件控制 |
“提升状态”让父组件通过切换它自己的状态来完全控制Toggle
。这意味着父组件将包含更多的逻辑,但总体上需要担心的状态较少。每当你尝试在不同组件中同步两个不同的状态变量时,尝试提升状态!
向父组件传递数据
这个Child
组件获取一些数据,然后在Effect中将其传递给Parent
组件:
1 | function Parent() { |
在React中,数据从父组件流向子组件。当你在屏幕上看到错误的东西时,你可以通过沿着组件链向上追溯,直到找到传递错误prop或有错误状态的组件。当子组件在Effect中更新其父组件的状态时,数据流变得非常难以追踪。由于子组件和父组件都需要相同的数据,让父组件获取数据,并将其_传递下去_给子组件:
1 | function Parent() { |
这更简单,并保持了数据流的可预测性:数据从父组件流向子组件。
订阅外部存储
有时,你的组件可能需要订阅React状态之外的某些数据。这些数据可能来自第三方库或内置的浏览器API。由于这些数据可能在React不知情的情况下发生变化,你需要手动订阅你的组件。这通常用Effect完成,例如:
1 | function use |
在这里,组件订阅了一个外部数据存储(在这个例子中,是浏览器的navigator.onLine
API)。由于这个API在服务器上不存在(所以它不能用于初始HTML),最初状态被设置为true
。每当浏览器中的那个数据存储的值发生变化时,组件就会更新它的状态。
尽管使用Effect进行此操作很常见,但React有一个专门用于订阅外部存储的Hook,这是首选。删除Effect,用调用useSyncExternalStore
替换:
1 | function subscribe(callback) { |
这种方法比手动使用Effect将可变数据与React状态同步的错误更少。通常,你会编写一个自定义Hook,如上面的useOnlineStatus()
,这样你就不需要在各个组件中重复此代码。阅读更多关于从React组件订阅外部存储的信息。
获取数据
许多应用程序使用Effect来启动数据获取。像这样编写数据获取Effect是很常见的:
1 | function SearchResults({ query }) { |
你不需要将这个获取移动到事件处理器。
这可能看起来与你之前的例子相矛盾,在那里你需要将逻辑放入事件处理器!然而,考虑到不是打字事件是获取的主要原因。搜索输入通常从URL预填充,用户可能在没有触摸输入的情况下导航后退和前进。
page
和query
来自哪里并不重要。只要这个组件可见,你就想保持results
与当前page
和query
的网络数据同步。这就是为什么它是一个Effect。
然而,上述代码有一个错误。想象一下你快速地输入”hello”。然后query
将从”h”变为”he”,”hel”,”hell”和”hello”。这将启动单独的获取,但没有保证响应将按什么顺序到达。例如,”hell”的响应可能在”hello”响应之后到达。由于它将最后一次调用setResults()
,你将显示错误的搜索结果。这被称为“竞态条件”:两个不同的请求“竞争”并以你预期之外的顺序到达。
为了修复竞态条件,你需要添加一个清理函数来忽略过时的响应:
1 | function SearchResults({ query }) { |
这确保了当你的Effect获取数据时,除了最后一个请求之外的所有响应都将被忽略。
处理竞态条件并不是实现数据获取的唯一困难。你可能还想考虑缓存响应(以便用户可以立即点击后退并看到上一个屏幕),如何在服务器上获取数据(以便初始服务器渲染的HTML包含获取的内容而不是旋转器),以及如何避免网络瀑布(以便子级可以在不等待每个父级的情况下获取数据)。
这些问题适用于任何UI库,而不仅仅是React。解决它们并不简单,这就是为什么现代框架提供了比在Effect中获取数据更有效的内置数据获取机制。
如果你不使用框架(并且不想自己构建)但又想让从Effect中获取数据更加符合人体工程学,考虑将你的获取逻辑提取到自定义Hook中,如本例所示:
1 | function SearchResults({ query }) { |
你也可能想要添加一些逻辑来处理错误并跟踪内容是否正在加载。你可以自己构建像这样的Hook,或者使用React生态系统中已经可用的许多解决方案。虽然这本身不会像使用框架的内置数据获取机制那样高效,但将数据获取逻辑移动到自定义Hook中将使以后更容易采用高效的数据获取策略。
总的来说,每当你不得不使用Effect时,要留意何时可以将某个功能提取到自定义Hook中,自定义Hook具有更声明性和专门构建的API,如上面的useData
。你的组件中原始useEffect
调用越少,你就会发现应用程序越容易维护。
总结
- 如果你可以在渲染期间计算某件事,你就不需要Effect。
- 要缓存昂贵的计算,请添加
useMemo
而不是useEffect
。 - 要重置整个组件树的状态,传递不同的
key
。 - 要响应prop变化重置特定状态位,请在渲染期间设置它。
- 因为组件被_显示_而运行的代码应该在Effect中,其余的应该在事件中。
- 如果你需要更新几个组件的状态,最好在单个事件中进行。
- 每当你尝试在不同组件中同步状态变量时,考虑提升状态。
- 你可以使用Effect获取数据,但你需要实现清理以避免竞态条件。