React 使用 Effect 同外部系统协作
使用 Effect 同步
什么是 Effect
React 组件中常见的两种逻辑类型:
- 渲染逻辑代码。 位于组件的顶层,在这里接收 props 和 state,并对它们进行转换,最终返回你想在屏幕上看到的 JSX。渲染的代码必须是纯粹的——就像数学公式一样,它只应该“计算”结果,而不做其他任何事情。
- 事件处理程序。 是嵌套在组件内部的函数,而不仅仅是计算函数。事件处理程序可能会更新输入字段、提交 HTTP POST 请求以购买产品,或者将用户导航到另一个屏幕。事件处理程序包含由特定用户操作(例如按钮点击或键入)引起的“副作用”(它们改变了程序的状态)。
另外一种就是Effect,与前述类型不一样的是,Effect 允许指定由渲染本身,而不是特定事件引起的副作用。在聊天中发送消息是一个“事件”,因为它直接由用户点击特定按钮引起。然而,建立服务器连接是 Effect,因为它应该发生无论哪种交互导致组件出现。Effect 在屏幕更新后的 提交阶段 运行。这是一个很好的时机,可以将 React 组件与某个外部系统(如网络或第三方库)同步。
如何编写 Effect
编写 Effect 需要遵循以下三个规则:
- 声明 Effect。默认情况下,Effect 会在每次渲染后都会执行。
- 指定 Effect 依赖。大多数 Effect 应该按需执行,而不是在每次渲染后都执行。例如,淡入动画应该只在组件出现时触发。连接和断开服务器的操作只应在组件出现和消失时,或者切换聊天室时执行。
- 必要时添加清理(cleanup)函数。有时 Effect 需要指定如何停止、撤销,或者清除它的效果。例如,“连接”操作需要“断连”,“订阅”需要“退订”,“获取”既需要“取消”也需要“忽略”。
声明 Effect
在 React 中引入 useEffect
Hook,并在组件顶部调用它,传入在每次渲染时都需要执行的代码:
1 | import { useEffect } from 'react'; |
这样一来,useEffect
会把相应的代码放到每次屏幕更新渲染之后执行。以官方例子为例,考虑一个 <VideoPlayer>
React 组件。通过传递布尔类型的 isPlaying
prop 以控制是播放还是暂停,VideoPlayer
组件渲染了内置的 <video>
标签,由于浏览器的 <video>
标签没有 isPlaying
属性,而控制它的唯一方式是在 DOM 元素上调用 play()
和 pause()
方法。因此,需要将 isPlaying
prop 的值与 play()
和 pause()
等函数的调用进行同步,该属性用于告知当前视频是否应该播放,在这过程中就需要获取 <video>
DOM 节点的引用。
如果不使用 Effect,试图在渲染期间通过 ref 调用 play()
或 pause()
是达不到目的的:
1 | import { useState, useRef, useEffect } from 'react'; |
解决办法是 使用 useEffect
包裹副作用,把相应逻辑分离到渲染逻辑的计算过程之外:
1 | import { useEffect, useRef } from 'react'; |
将调用 DOM 方法的操作封装在 Effect 中之后,就可以让 React 先更新屏幕,确定相关 DOM 创建好了以后然后再运行 Effect。这样一来,当 VideoPlayer
组件渲染时(无论是否为首次渲染),React 都会刷新屏幕,确保 <video>
元素已经正确地出现在 DOM 中,然后,运行 Effect,最后,Effect 将根据 isPlaying
的值调用 play()
或 pause()
。
指定 Effect 依赖
一般来说,Effect 会在 每次 渲染时执行。但更多时候,并不需要每次渲染的时候都执行 Effect。
- 有时这会拖慢运行速度。因为与外部系统的同步操作总是有一定时耗,在非必要时可能希望跳过它。
- 有时这会导致程序逻辑错误。例如,组件的淡入动画只需要在第一轮渲染出现时播放一次,而不是每次触发新一轮渲染后都播放。
为了达到这个目的,可以将 依赖数组 传入 useEffect
的第二个参数,以告诉 React 跳过不必要地重新运行 Effect。比如:
1 | useEffect(() => { |
在上述示例中,指定 [isPlaying]
会告诉 React,如果 isPlaying
在上一次渲染时与当前相同,它应该跳过重新运行 Effect。通过这个改变,输入框的输入不会导致 Effect 重新运行,但是按下播放/暂停按钮会重新运行 Effect。
依赖数组可以包含多个依赖项。当指定的所有依赖项在上一次渲染期间的值与当前值完全相同时,React 会跳过重新运行该 Effect。React 使用 Object.is
比较依赖项的值。但也 不能随意选择依赖项 。
值得注意的是,没有依赖数组作为第二个参数,与依赖数组位空数组 []
的行为是不一致的:
1 | useEffect(() => { |
按需添加清理(cleanup)函数
假设想编写一个 ChatRoom
组件,该组件出现时需要连接到聊天服务器。现在已经提供了 createConnection()
API,该 API 返回一个包含 connect()
与 disconnection()
方法的对象。这时可以通过如下方式建立链接:
1 | useEffect(() => { |
Effect 中的代码没有使用任何 props 或 state,此时指定依赖数组为空数组 []
。这告诉 React 仅在组件“挂载”时运行此代码,即首次出现在屏幕上这一阶段。
但是这样会有个问题:在页面切换后,每次返回聊天室页面,聊天组件都会重新挂载,因此,同一个聊天室将会被重复挂载,中间却从未卸载。为了解决这个问题,可以在 Effect 中返回一个 清理(cleanup) 函数:
1 | useEffect(() => { |
这样,每次重新执行 Effect 之前,React 都会调用清理函数;组件被卸载时,也会调用清理函数。具体例子如下:
1 | import { useState, useEffect } from 'react'; |
为了避免一些不必要的Bug,需要注意使用清理函数机制,使得操作成对出现,例如:
- 如果 Effect 订阅了某些事件,清理函数应该退订这些事件;
- 如果 Effect 对某些内容加入了动画,清理函数应将动画重置;
- 如果 Effect 将会获取数据,清理函数应该要么中止该数据获取操作,要么忽略其结果;
初始化应用时不需要使用 Effect 的情形
某些逻辑应该只在应用程序启动时运行一次。比如,验证登陆状态和加载本地程序数据。可以将其放在组件之外:
1 | if (typeof window !== 'undefined') { // 检查是否在浏览器中运行 |
此外,不要在 Effect 中执行购买商品一类的操作,因为,有时即使编写了一个清理函数,也不能避免执行两次 Effect。一方面,开发环境下,Effect 会执行两次,这意味着购买操作执行了两次,但是这并非是预期的结果,所以不应该把这个业务逻辑放在 Effect 中。另一方面,如果用户转到另一个页面,然后按“后退”按钮回到了这个界面,Effect 会随着组件再次挂载而再次执行。故 它不应该写在 Effect 中,应当把 /api/buy
请求操作移动到购买按钮事件处理程序中。
总结
Effect 构建了与外部系统交互的桥梁,可以实现渲染后,Dom 节点同系统状态同步的目的,但不能滥用 Effect,并且在处理 Effect 时,应当考虑是否需要借助 useEffect
的清理机制,以在页面刷新获组件卸载时清理相应的资源。
React 使用 Effect 同外部系统协作
https://jblogs.tech/2024/03/framworks/front_framworks/react/effect-sync-with-external-sys/