将事件与副作用分离
事件处理函数只在你再次执行相同交互时重新运行。与事件处理函数不同,如果副作用读取的某些值(例如属性或状态变量)与上一次渲染时的值不同,副作用会重新同步。有时,你也希望混合这两种行为:一个副作用响应某些值而重新运行,但不响应其他值。本页将教你如何做到这一点。
你将学习
- 如何在事件处理函数和副作用之间做出选择
- 为什么副作用是响应式的,而事件处理函数不是
- 当你希望副作用中的部分代码不具响应性时该怎么办
- 什么是副作用事件,以及如何从副作用中提取它们
- 如何使用副作用事件从副作用中读取最新的属性和状态
在事件处理函数和副作用之间做出选择
让我们回顾一下事件处理函数和副作用之间的区别。
假设你正在实现一个聊天室组件。你的需求如下:
- 你的组件应自动连接到选定的聊天室。
- 当你点击“发送”按钮时,它应向聊天室发送一条消息。
假设你已经为它们实现了代码,但不确定应该放在哪里。你应该使用事件处理函数还是副作用?每次你需要回答这个问题时,请考虑代码为何需要运行。
事件处理函数响应特定的交互而运行
从用户的角度来看,发送消息应该因为用户点击了特定的“发送”按钮而发生。如果你在其他时间或出于其他原因发送他们的消息,用户会相当不满。这就是为什么发送消息应该是一个事件处理函数。事件处理函数让你处理特定的交互:
使用事件处理函数,你可以确保sendMessage(message)只在用户按下按钮时运行。
副作用在需要同步时运行
回想一下,你还需要保持组件连接到聊天室。那段代码应该放在哪里?
运行这段代码的原因并非某个特定的交互。用户为何或如何导航到聊天室屏幕并不重要。既然他们正在查看它并可能与之交互,组件就需要保持与所选聊天服务器的连接。即使聊天室组件是你应用的初始屏幕,并且用户根本没有执行任何交互,你仍然需要建立连接。这就是为什么它是一个 Effect:
有了这段代码,你可以确保始终有一个到当前所选聊天服务器的活跃连接,无论用户执行了何种具体交互。无论用户是刚刚打开你的应用、选择了不同的房间,还是导航到另一个屏幕后又返回,你的 Effect 都能确保组件将保持同步于当前选中的房间,并且会在必要时重新连接。
响应式值与响应式逻辑
直观地说,你可以认为事件处理函数总是被“手动”触发的,例如通过点击按钮。而 Effect 则是“自动”的:它们会根据需要运行和重新运行以保持同步。
有一种更精确的方式来思考这个问题。
Props、state 以及在你组件体内声明的变量被称为响应式值。在这个例子中,serverUrl不是响应式值,但roomId和message是。它们参与了渲染数据流:
像这样的响应式值可能会因为重新渲染而改变。例如,用户可能会编辑message或在下拉菜单中选择不同的roomId。事件处理函数和 Effect 对变化的响应方式不同:
- 事件处理函数内部的逻辑是非响应式的。除非用户再次执行相同的交互(例如点击),否则它不会再次运行。事件处理函数可以读取响应式值,但不会“响应”它们的变化。
- Effect 内部的逻辑是响应式的。如果你的 Effect 读取了一个响应式值,你必须将其指定为依赖项。然后,如果重新渲染导致该值发生变化,React 将使用新值重新运行你的 Effect 逻辑。
让我们回顾一下前面的例子来说明这种差异。
事件处理函数内部的逻辑是非响应式的
看看这行代码。这个逻辑应该是响应式的还是非响应式的?
从用户的角度来看,更改 message 并不意味着他们想要发送消息。这只是意味着用户正在输入。换句话说,发送消息的逻辑不应该是响应式的。它不应该仅仅因为响应式值发生了变化就再次运行。这就是为什么它属于事件处理函数:
事件处理函数不是响应式的,所以sendMessage(message) 只会在用户点击发送按钮时运行。
Effect 内部的逻辑是响应式的
现在让我们回到这几行代码:
从用户的角度来看,更改 roomId确实意味着他们想要连接到不同的房间。换句话说,连接到房间的逻辑应该是响应式的。你希望这几行代码能够“跟上”响应式值的变化,并在该值不同时再次运行。这就是为什么它属于 Effect:
Effect 是响应式的,所以createConnection(serverUrl, roomId) 和 connection.connect() 会为每个不同的 roomId值运行。你的 Effect 使聊天连接与当前选中的房间保持同步。
从 Effect 中提取非响应式逻辑
当你想要混合响应式逻辑和非响应式逻辑时,事情会变得更加棘手。
例如,假设你想在用户连接到聊天时显示一个通知。你从 props 中读取当前主题(深色或浅色),以便以正确的颜色显示通知:
然而,theme是一个响应式值(它可能因重新渲染而改变),并且Effect 读取的每个响应式值都必须声明为其依赖项。现在你必须将 theme指定为 Effect 的依赖项:
试试这个例子,看看你是否能发现这个用户体验的问题:
当 roomId变化时,聊天会重新连接,这符合预期。但由于theme也是一个依赖项,每次你在深色和浅色主题之间切换时,聊天也 会重新连接。这并不理想!
换句话说,你不希望这行代码具有响应性,即使它位于一个 Effect(它是响应性的)内部:
你需要一种方法,将这段非响应式逻辑与它周围的响应式 Effect 分离开来。
声明 Effect Event
使用一个名为 useEffectEvent的特殊 Hook,将这段非响应式逻辑从你的 Effect 中提取出来:
这里的onConnected 被称为 Effect Event。它是你 Effect 逻辑的一部分,但其行为更像一个事件处理函数。其内部的逻辑不是响应式的,并且它总是能“看到”你的 props 和 state 的最新值。
现在你可以在 Effect 内部调用onConnected这个 Effect Event:
这样就解决了问题。注意,你必须从theme Effect 的依赖项列表中 移除,因为它不再在 Effect 中使用。你也不需要添加onConnected 到依赖项中,因为 Effect Events 不是响应式的,必须从依赖项中省略。
验证新行为是否符合你的预期:
你可以将 Effect Events 视为与事件处理函数非常相似。主要区别在于,事件处理函数在响应用户交互时运行,而 Effect Events 则由你从 Effects 中触发。Effect Events 让你可以“打破” Effects 的响应性与不应具有响应性的代码之间的链条。
使用 Effect Events 读取最新的 props 和 state
Effect Events 让你可以修复许多你可能想要抑制依赖项检查器的模式。
例如,假设你有一个用于记录页面访问的 Effect:
之后,您为网站添加了多个路由。现在您的Page组件接收一个包含当前路径的url 属性。您希望将 url 作为 logVisit调用的一部分传递,但依赖项检查器报出警告:
思考一下您希望代码做什么。您希望为不同的 URL 记录独立的访问,因为每个 URL 代表一个不同的页面。换句话说,这个logVisit调用应该 对 url具有响应性。因此,在这种情况下,遵循依赖项检查器的建议,将url添加为依赖项是合理的:
现在假设您希望在每次页面访问时,连同购物车中的商品数量一起记录:
您在 Effect 内部使用了numberOfItems,因此检查器要求您将其添加为依赖项。然而,您 并不希望 logVisit 调用对 numberOfItems具有响应性。如果用户将商品放入购物车,并且numberOfItems发生变化,这并不意味着 用户再次访问了该页面。换句话说,访问页面在某种意义上是一个“事件”。它发生在某个精确的时间点。
将代码拆分为两部分:
在这里,onVisit是一个 Effect Event。其内部的代码不具有响应性。这就是为什么您可以使用numberOfItems(或任何其他响应式值!)而不用担心它会导致周围的代码在变化时重新执行。
另一方面,Effect 本身仍然是响应式的。Effect 内部的代码使用了url 属性,因此 Effect 会在每次使用不同 url重新渲染后重新运行。这反过来又会调用onVisitEffect Event。
因此,您将在每次 url 发生变化时调用 logVisit,并且始终读取最新的numberOfItems。然而,如果numberOfItems自行发生变化,这不会导致任何代码重新运行。
注意
您可能想知道是否可以不带参数调用onVisit(),并在其内部读取 url:
Effect Events 的局限性
Effect Events 在使用方式上非常受限:
- 只能在 Effect 内部调用它们。
- 切勿将它们传递给其他组件或 Hook。
例如,不要像这样声明并传递一个 Effect Event:
相反,应始终在需要使用它们的 Effect 旁边直接声明 Effect Events:
Effect Events 是 Effect 代码中非响应式的“片段”。它们应该紧邻使用它们的 Effect。
回顾
- 事件处理程序在响应特定交互时运行。
- Effect 在需要同步时运行。
- 事件处理程序内部的逻辑是非响应式的。
- Effect 内部的逻辑是响应式的。
- 你可以将 Effect 中的非响应式逻辑移动到 Effect Events 中。
- 只能在 Effect 内部调用 Effect Events。
- 不要将 Effect Events 传递给其他组件或 Hook。
Try out some challenges
Challenge 1 of 4:Fix a variable that doesn’t update #
This Timer component keeps a count state variable which increases every second. The value by which it’s increasing is stored in the increment state variable. You can control the increment variable with the plus and minus buttons.
However, no matter how many times you click the plus button, the counter is still incremented by one every second. What’s wrong with this code? Why is increment always equal to 1 inside the Effect’s code? Find the mistake and fix it.
