React 使用 Effect 同外部系统协作

React 使用 Effect 同外部系统协作

使用 Effect 同步

什么是 Effect

React 组件中常见的两种逻辑类型:

  • 渲染逻辑代码。 位于组件的顶层,在这里接收 props 和 state,并对它们进行转换,最终返回你想在屏幕上看到的 JSX。渲染的代码必须是纯粹的——就像数学公式一样,它只应该“计算”结果,而不做其他任何事情。
  • 事件处理程序。 是嵌套在组件内部的函数,而不仅仅是计算函数。事件处理程序可能会更新输入字段、提交 HTTP POST 请求以购买产品,或者将用户导航到另一个屏幕。事件处理程序包含由特定用户操作(例如按钮点击或键入)引起的“副作用”(它们改变了程序的状态)。

另外一种就是Effect,与前述类型不一样的是,Effect 允许指定由渲染本身,而不是特定事件引起的副作用。在聊天中发送消息是一个“事件”,因为它直接由用户点击特定按钮引起。然而,建立服务器连接是 Effect,因为它应该发生无论哪种交互导致组件出现。Effect 在屏幕更新后的 提交阶段 运行。这是一个很好的时机,可以将 React 组件与某个外部系统(如网络或第三方库)同步。

如何编写 Effect

编写 Effect 需要遵循以下三个规则:

  1. 声明 Effect。默认情况下,Effect 会在每次渲染后都会执行。
  2. 指定 Effect 依赖。大多数 Effect 应该按需执行,而不是在每次渲染后都执行。例如,淡入动画应该只在组件出现时触发。连接和断开服务器的操作只应在组件出现和消失时,或者切换聊天室时执行。
  3. 必要时添加清理(cleanup)函数。有时 Effect 需要指定如何停止、撤销,或者清除它的效果。例如,“连接”操作需要“断连”,“订阅”需要“退订”,“获取”既需要“取消”也需要“忽略”。

声明 Effect

在 React 中引入 useEffect Hook,并在组件顶部调用它,传入在每次渲染时都需要执行的代码:

声明 effect
1
2
3
4
5
6
7
8
import { useEffect } from 'react';

function MyComponent() {
useEffect(() => {
// 每次渲染后都会执行此处的代码
});
return <div />;
}

这样一来,useEffect 会把相应的代码放到每次屏幕更新渲染之后执行。以官方例子为例,考虑一个 <VideoPlayer> React 组件。通过传递布尔类型的 isPlaying prop 以控制是播放还是暂停,VideoPlayer 组件渲染了内置的 <video> 标签,由于浏览器的 <video> 标签没有 isPlaying 属性,而控制它的唯一方式是在 DOM 元素上调用 play()pause() 方法。因此,需要将 isPlaying prop 的值与 play()pause() 等函数的调用进行同步,该属性用于告知当前视频是否应该播放,在这过程中就需要获取 <video> DOM 节点的引用。
如果不使用 Effect,试图在渲染期间通过 ref 调用 play()pause() 是达不到目的的:

直接通过 ref 调用 API进行同步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import { useState, useRef, useEffect } from 'react';

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);

if (isPlaying) {
ref.current.play(); // 渲染期间不能调用 `play()`。
} else {
ref.current.pause(); // 同样,调用 `pause()` 也不行。
}

return <video ref={ref} src={src} loop playsInline />;
}

export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
return (
<>
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? '暂停' : '播放'}
</button>
<VideoPlayer
isPlaying={isPlaying}
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
/>
</>
);
}

解决办法是 使用 useEffect 包裹副作用,把相应逻辑分离到渲染逻辑的计算过程之外

通过 Effect 分离同步逻辑
1
2
3
4
5
6
7
8
9
10
11
12
13
import { useEffect, useRef } from 'react';  

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}

将调用 DOM 方法的操作封装在 Effect 中之后,就可以让 React 先更新屏幕,确定相关 DOM 创建好了以后然后再运行 Effect。这样一来,当 VideoPlayer 组件渲染时(无论是否为首次渲染),React 都会刷新屏幕,确保 <video> 元素已经正确地出现在 DOM 中,然后,运行 Effect,最后,Effect 将根据 isPlaying 的值调用 play()pause()

指定 Effect 依赖

一般来说,Effect 会在 每次 渲染时执行。但更多时候,并不需要每次渲染的时候都执行 Effect

  • 有时这会拖慢运行速度。因为与外部系统的同步操作总是有一定时耗,在非必要时可能希望跳过它。
  • 有时这会导致程序逻辑错误。例如,组件的淡入动画只需要在第一轮渲染出现时播放一次,而不是每次触发新一轮渲染后都播放。

为了达到这个目的,可以将 依赖数组 传入 useEffect 的第二个参数,以告诉 React 跳过不必要地重新运行 Effect。比如:

传入依赖数组,包含 isPlaying
1
2
3
useEffect(() => {
// ...
}, [isPlaying]);

在上述示例中,指定 [isPlaying] 会告诉 React,如果 isPlaying 在上一次渲染时与当前相同,它应该跳过重新运行 Effect。通过这个改变,输入框的输入不会导致 Effect 重新运行,但是按下播放/暂停按钮会重新运行 Effect。
依赖数组可以包含多个依赖项。当指定的所有依赖项在上一次渲染期间的值与当前值完全相同时,React 会跳过重新运行该 Effect。React 使用 Object.is 比较依赖项的值。但也 不能随意选择依赖项

值得注意的是,没有依赖数组作为第二个参数,与依赖数组位空数组 [] 的行为是不一致的:

useEffect 不同依赖数组的差异
1
2
3
4
5
6
7
8
9
10
11
useEffect(() => {
// 这里的代码会在每次渲染后执行
});

useEffect(() => {
// 这里的代码只会在组件挂载后执行
}, []);

useEffect(() => {
//这里的代码只会在每次渲染后,并且 a 或 b 的值与上次渲染不一致时执行
}, [a, b]);

按需添加清理(cleanup)函数

假设想编写一个 ChatRoom 组件,该组件出现时需要连接到聊天服务器。现在已经提供了 createConnection() API,该 API 返回一个包含 connect()disconnection() 方法的对象。这时可以通过如下方式建立链接:

链接聊天室
1
2
3
4
useEffect(() => {  
const connection = createConnection();
connection.connect();
}, []);

Effect 中的代码没有使用任何 props 或 state,此时指定依赖数组为空数组 []。这告诉 React 仅在组件“挂载”时运行此代码,即首次出现在屏幕上这一阶段
但是这样会有个问题:在页面切换后,每次返回聊天室页面,聊天组件都会重新挂载,因此,同一个聊天室将会被重复挂载,中间却从未卸载。为了解决这个问题,可以在 Effect 中返回一个 清理(cleanup) 函数:

从 useEffect 返回清理函数
1
2
3
4
5
6
7
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);

这样,每次重新执行 Effect 之前,React 都会调用清理函数;组件被卸载时,也会调用清理函数。具体例子如下:

聊天室例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useState, useEffect } from 'react';

export function createConnection() {
// 真实的实现会将其连接到服务器,此处代码只是示例
return {
connect() {
console.log('✅ 连接中……');
},
disconnect() {
console.log('❌ 连接断开。');
}
};
}

export default function ChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => connection.disconnect();
}, []);
return <h1>欢迎来到聊天室!</h1>;
}

为了避免一些不必要的Bug,需要注意使用清理函数机制,使得操作成对出现,例如:

  1. 如果 Effect 订阅了某些事件,清理函数应该退订这些事件;
  2. 如果 Effect 对某些内容加入了动画,清理函数应将动画重置;
  3. 如果 Effect 将会获取数据,清理函数应该要么中止该数据获取操作,要么忽略其结果;

初始化应用时不需要使用 Effect 的情形

某些逻辑应该只在应用程序启动时运行一次。比如,验证登陆状态和加载本地程序数据。可以将其放在组件之外:

组件外检查运行一次的状态
1
2
3
4
5
6
7
8
if (typeof window !== 'undefined') { // 检查是否在浏览器中运行
checkAuthToken();
loadDataFromLocalStorage();
}

function App() {
// ……
}

此外,不要在 Effect 中执行购买商品一类的操作,因为,有时即使编写了一个清理函数,也不能避免执行两次 Effect。一方面,开发环境下,Effect 会执行两次,这意味着购买操作执行了两次,但是这并非是预期的结果,所以不应该把这个业务逻辑放在 Effect 中。另一方面,如果用户转到另一个页面,然后按“后退”按钮回到了这个界面,Effect 会随着组件再次挂载而再次执行。故 它不应该写在 Effect 中,应当把 /api/buy 请求操作移动到购买按钮事件处理程序中

总结

Effect 构建了与外部系统交互的桥梁,可以实现渲染后,Dom 节点同系统状态同步的目的,但不能滥用 Effect,并且在处理 Effect 时,应当考虑是否需要借助 useEffect 的清理机制,以在页面刷新获组件卸载时清理相应的资源。

作者

Jeill

发布于

2024-03-24

更新于

2024-04-05

许可协议

评论