与效果同步
某些组件可能需要与外部系统同步。例如,连接服务器、发送分析日志,或在组件显示在屏幕上后控制独立于 React 状态的事物。
一种名为 "效果"(Effects)的东西可以帮助您在渲染后将组件与 React 以外的系统同步。
什么是 "效果",它们与 "事件 "有何不同?
在讨论 Effects 之前,了解 React 组件中的两种逻辑类型非常重要。
- 呈现代码应该是纯函数。
- 事件处理程序是组件中的嵌套函数,它所做的不仅仅是计算。换句话说,事件处理程序包含用户操作产生的副作用。
上述两个逻辑可能还不够。例如,考虑一个聊天室
组件,当它显示在屏幕上时,必须无条件地连接到聊天服务器。与服务器的连接不是一个纯函数(它是一个副作用),因此它不能在呈现过程中发生。
**效果允许由渲染本身而非特定事件引起的副作用。**在聊天中发送信息就是一个事件。这是因为它发生在用户点击特定按钮时。然而,连接到服务器是一个 "效果",因为它应该在组件出现的那一刻发生,而与交互无关。效果会在屏幕更新后和提交结束时运行。这正是将 React 组件与外部系统同步的好时机。
注意 到此为止,"Effect(效果)"是 React 的特定定义,意指由呈现引起的次要效果。在更一般的编程中提及这一概念时,我们将使用 "副作用"。
您可能不需要 "效果"。
**不要随意在组件中添加效果。**请记住,"效果 "主要用于跳出 React 代码并与外部系统同步。我们所说的外部系统是指浏览器 API、第三方部件、网络等。如果您要实现的效果只是基于不同的状态,那么您可能不需要使用效果。
如何编写效果
要编写一个 "效果",请遵循以下三个步骤。
- **声明效果。**默认情况下,每次提交后都会执行 "效果"。
- **指定 "效果 "依赖关系。**大多数 "效果 "只应在需要时再次运行,而不是在每次呈现后运行。
- **根据需要添加清理功能。**有些效果需要指定如何在重新渲染时停止、还原和清理。例如,如果已连接,则应断开连接;如果已订阅,则应取消订阅。
第一步:定义效果
要在组件中定义效果,需要使用 useEffect
挂钩。
在组件的顶层调用该钩子,并在其中输入以下代码
函数 MyComponent() {
useEffect(() => {
// 这里的代码将在每次*渲染后运行
});
返回 <div />;
}
每次组件渲染时,React 都会更新屏幕并执行** useEffect
** 中的
代码。换句话说,useEffect 会
延迟代码的执行,直到渲染反映在屏幕上。
让我们以VideoPlayer
组件为例
function VideoPlayer({ src, isPlaying }) {
// TODO: do something with isPlaying
return <video src={src} />;
}
由于浏览器中的<video>
标记没有isPlaying
这样的属性,因此控制它的唯一方法就是在 DOM 元素上手动启动play()
或pause(
) 方法。在该组件中,我们需要将 isPlaying 属性的值与play()
和pause()
等调用同步,isPlaying 属性会告诉我们视频当前是否正在播放。
您可以通过获取一个 ref 并在呈现时调用它来实现这一点,但这并不是正确的方法。
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
if (isPlaying) {
ref.current.play(); // 在渲染时调用是不允许的。
} else {
ref.current.pause(); // 此外,这会崩溃。
}
} return <video ref={ref} src={src} loop playsInline />;
}
这段代码之所以不正确,是因为它试图在呈现期间对 DOM 节点进行操作。在 React 中,呈现应该是一个纯函数,不应该有任何副作用(如 DOM 操作)。
此外,当VideoPlayer
首次被调用时,DOM 还不存在。没有节点可以执行play()
或pause(
)
。
解决方案是将这些副作用封装在一个useEffect
中,以便将它们与渲染操作分开。
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 更新打包到效果中时,React 会先更新屏幕,然后运行效果。
⚠️注意!
默认情况下,每次呈现后都会运行效果。因此,以下代码将创建一个无限循环
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
当状态发生变化时,组件将重新渲染,并再次执行 Effect。这将导致另一次状态变化,依此类推。
第二步:指定效果依赖关系
默认情况下,每次渲染后都会执行效果。然而,这往往不是你想要的。
- 因为与外部系统的同步并不总是即时的,所以在不需要的时候,你可能希望省略一些行为。例如,您不想在每次按键后都重新连接服务器。
- 您不希望您的组件在每次按键时都触发淡入动画。您希望这些动画只在组件首次出现时运行一次。
useEffect(() => {
if (isPlaying) { // 在这里使用...
// ...
} else {
// ...
}
}, [isPlaying]); // ...所以必须在这里声明!
在依赖关系数组中指定[isPlaying]
会告诉 React,如果上一次渲染时isPlaying
与之前相同,则不应再次运行该效果。
依赖关系数组可以包含多个依赖关系。只有当指定的依赖关系值与上次呈现时的值完全一致时,React 才会跳过重新运行 "效果"。React 会使用Object.is
比较法来比较依赖关系值。
**请记住,您不能选择依赖项。**如果您指定的依赖关系与 React 根据 "效果 "内部代码所期望的不一致,您将会收到一个 lint 错误。如果不想重新运行代码,请修改 Effect 内的代码,使其 "无依赖"。
-
⚠️ 深入探讨 为什么可以从依赖关系数组中省略 refs?
在下面的代码中,我们在 Effect 内部同时使用了
ref
和isPlaying
,但在依赖关系中只指定了isPlaying
。
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);
这是因为ref
具有稳定的身份。React 保证您将始终从同一个useRef
调用中获得相同的对象,因此在依赖关系数组中是否包含ref
并不重要。同样,从useState
返回的set
函数也具有稳定的标识。
第 3 步:根据需要添加清理
假设您正在编写一个ChatRoom
组件,当它出现时需要连接到聊天服务器。 您得到了createConnection()
API,它会返回一个对象,并带有connect()
和disconnect()
方法。如何在用户可见时保持连接?
useEffect(() => {
const connection = createConnection();
connection.connect(); // ✅ 正在连接...
}, []);
Effect 内的代码不使用任何道具或状态,因此它的依赖关系数组([]
)是空的,这意味着 Effect 只会在组件首次出现在屏幕上时运行。
由于该特效只在安装时运行,因此控制台信息"✅ 正在连接... "只会出现一次。将只显示一次。但是,当我们检查控制台时,会发现它被捕获了两次。
试想一下,如果你从带有ChatRoom
组件的页面上移开,然后再回到该页面,你将再次调用connect()
,建立第二个连接,但第一个连接尚未关闭。这些连接将继续堆积。
除非手动测试,否则这样的错误很容易被忽略,因此 React 会在开发模式下初始挂载后重新挂载所有组件一次。
要解决这个问题,可以让您的 Effect 返回一个清理函数
useEffect(() => {
const connection = createConnection();
connection.connect(); // ✅连接...
return () => {
connection.disconnect(); // ❌ 断开连接。
};
}, []);
React 会在每次再次运行效果时调用清理函数,并在最后移除组件时调用该函数。
现在,在开发环境中,你应该会看到以下三条日志。
"✅正在连接......"
"断开连接。"
"✅ 正在连接......"
**在开发模式下,这是正确的行为。**通过重新加载组件,我们可以验证当 React 浏览其他地方并返回到该组件时,代码不会中断。断开和连接正是应该发生的事情--这是 React 在检查你的代码并寻找错误,这是正常的行为,不要试图摆脱它!
在部署环境中,只需打印一次 "✅ Connecting... "即可
。
如何处理 Effect 在开发环境中运行两次的问题
React 会在开发环境中重新加载组件以查找错误,如上例所示。这里的正确问题不是 "如何让 Effect 只运行一次",而是 "如何修复 Effect,使其在重挂载后正常工作"?
一般的答案是实现一个清理函数。清理函数会中止或恢复 Effect 正在进行的工作。基本原则是,用户不应该区分运行一次的 Effect(如在部署中)和重新加载的 Effect。
你将编写的大多数效果都属于下面的一种通用模式。
控制非 React 编写的部件
有时,您需要添加非 React 编写的 UI 部件。例如,让我们添加一个地图组件。 如果您的地图组件有一个setZomLevel()
方法并与zoomLevel
状态同步,那么您的 Effect 可能会如下所示
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
在这种情况下,无需进行清理。在开发模式下,React 会调用两次setZomLevel()
,因为它会调用两次 Effect,但在部署环境中,这不是问题,因为它不会被不必要地重新加载。
某些 API 可能不允许您连续调用两次。例如,<dialog>
的showModal
方法。
如果调用两次,就会抛出异常。在这种情况下,请实现一个清理函数来关闭对话框。
useEffect(() => {
const dialog = dialogRef.current.displayModal()
对话框.showModal();
return () => dialog.close();
}, []);
订阅事件
如果在 Effect 中订阅了某些事件,则需要在清理函数中取消订阅
useEffect(() => {
函数 handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
动画触发器
如果在效果中为某些内容设置动画,则应在清理函数中将动画设置为初始值
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // 触发动画
return () => {
node.style.opacity = 0; // 重置为初始值
};
}, []);
获取数据
如果效果提取了任何数据,清理函数应中止提取或忽略结果。
useEffect(() => {
let ignore = false;
async 函数 startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
忽略 = true;
};
}, [userId]);
虽然我们无法 "取消 "已经发生的网络请求,但我们确实需要确保清理函数不会影响不再相关的获取。
**在开发环境中,你会在网络选项卡上看到两个获取。**使用上述方法,第一个 Effect 将被立即清理,并将忽略
变量的副本设置为true
,因此即使有其他请求,也不会影响状态,这要归功于if
检查。
**在部署环境中,只会有一个请求。**如果在开发环境中第二个请求会对您造成困扰,最好的办法是使用在组件间缓存响应的解决方案。
函数 TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
在 Effect 中调用fetch
是获取数据最常用的方法,尤其是在完全客户端应用程序中。 不过,这种方法非常被动,而且有很大的缺点。
- **Effect 并不在服务器上运行。**它必须先下载所有 JavaScript 并渲染应用程序,然后才能加载数据。这并不高效。
- **直接在 Effect 中导入数据很容易造成 "网络瀑布"。**您渲染父组件,获取一些数据,再次渲染子组件,然后子组件开始获取自己的数据。除非网络速度很快,否则这比并行获取要慢得多。
- **直接在 Effect 内部获取数据意味着没有预加载或缓存。**例如,如果组件被重新加载,数据就必须重新导入。
- **这不是很方便。**需要编写大量的模板代码才能避免出现错误。
获取数据是一项很难做好的工作,因此我们建议采用以下方法
- 如果您使用的是框架,请使用其内置的数据获取机制。
- 否则,请考虑采用 React Query、useSWR 或 React Router 6.4+ 等开源解决方案。
发送分析结果
让我们来看看发送页面访问分析的代码
useEffect(() => {
logVisit(url); // 发送 POST 请求
}, [url]);
在开发环境中,由于logVisit
被调用了两次,您可能很想修改它。不过,我们建议保持原样。与前面的示例一样,用户可以看到运行一次或两次 logVisit*在行为上没有区别。*事实上,在开发环境中,logVisit
不应该做任何事情,因为你不想在开发环境中收集日志,从而影响产品指标。
在部署环境中,不会有重复的访问日志。
如果不是效果:应用程序初始化
某些逻辑需要在应用程序启动时执行一次。这些逻辑可以放在组件之外。
if (typeof window !== 'undefined') { // 检查浏览器是否正在运行。
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
如上图所示,通过在组件外部执行该逻辑,可以保证在页面加载后只执行一次。