使用 Effect 进行同步
某些组件可能需要与外部系统同步。例如,在组件出现在屏幕上后控制与 React 状态无关的服务器连接或发送分析日志。
Effects 可以在渲染后将组件与 React 外部的系统同步。
什么是 Effects?它们与事件有何不同?
在了解 Effects 之前,你需要了解 React 组件中的两种逻辑类型。
- 渲染代码必须是纯函数。
- 事件处理程序是组件中的嵌套函数,它们执行操作而不仅仅是计算。它们可以更新字段、发送 HTTP 请求或导航到不同的屏幕。换句话说,事件处理程序包含由用户操作引起的副作用。
上述两种逻辑可能不足够。例如,考虑一个在屏幕上出现时必须连接到聊天服务器的 ChatRoom
组件。与服务器的连接不是纯函数(它是一个副作用),所以它不能在渲染期间发生。
Effects 允许你通过渲染本身而不是特定事件来引起副作用。 在聊天中发送消息是一个事件,因为它发生在用户点击特定按钮时。然而,服务器连接是一个 Effect,因为它必须在组件出现的那一刻发生,无论交互如何。Effects 在屏幕更新后、提交结束时执行。这是将 React 组件与外部系统同步的好时机。
Note
到目前为止的"Effect"代表 React 中的特殊定义,很快意味着由渲染引起的副作用。在提到更一般编程中的概念时,我将使用术语"副作用"。
你可能不需要 Effect
不要盲目地向组件添加 Effects。 记住,Effects 主要用于与 React 代码之外的外部系统同步。这里的外部系统指的是浏览器 API、第三方小部件、网络等。如果你想做的 Effect 仅仅基于另一个状态,你可能不需要 Effect。
如何编写 Effect
按照以下 3 个步骤编写 Effect。
- 声明 Effect。 默认情况下,Effects 在每次提交后运行。
- 指定 Effect 依赖项。 大多数 Effects 应该只在需要时再次运行,而不是在每次渲染后。
- 必要时添加清理函数。 某些 Effects 需要指定在重新渲染时如何停止、撤销和清理。例如,如果你连接,你需要断开连接;如果你订阅,你需要取消订阅。
步骤 1:定义 Effect
要在组件中定义 Effect,请使用 useEffect
Hook。
在组件的顶层调用它并在其中放入代码。
function MyComponent() {
useEffect(() => {
// 这里的代码将在*每次*渲染后运行
});
return <div />;
}
每次组件渲染时,React 都会更新屏幕,然后执行 useEffect
内的代码。换句话说,useEffect
延迟了代码的执行,直到渲染反映在屏幕上。
让我们以 VideoPlayer
组件为例。
function VideoPlayer({ src, isPlaying }) {
// TODO: 对 isPlaying 做些什么
return <video src={src} />;
}
由于浏览器的 <video>
标签没有像 isPlaying
这样的属性,控制它的方法是在 DOM 元素上手动执行 play()
或 pause()
方法。在这个组件中,你需要将 isPlaying prop 的值(表示视频当前是否正在播放)与 play()
和 pause()
的调用同步。
你可能会尝试获取一个 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 更新包装在 Effect 中,你允许 React 先更新屏幕,然后 Effect 再运行。
Warning
默认情况下,Effects 在每次渲染后运行。这就是为什么以下代码会创建无限循环。
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
当状态改变时,组件重新渲染,Effect 再次运行,结果状态再次改变,依此类推。
步骤 2:指定 Effect 依赖项
默认情况下,Effects 在每次渲染后运行。但这通常不是你想要的。
- 与外部系统的同步并不总是立即的,所以当不必要时你可能想跳过操作。例如,你不会想在每次按键时都重新连接到服务器。
- 你不会想让组件在每次按键时都触发淡入动画。你希望这样的动画只在组件首次出现时运行一次。
useEffect(() => {
if (isPlaying) { // 它在这里使用...
// ...
} else {
// ...
}
}, [isPlaying]); // ...所以必须在这里声明!
将 [isPlaying]
指定为依赖数组告诉 React,如果 isPlaying
与上一次渲染期间的值相同,则应该跳过重新运行 Effect。
依赖数组可以包含多个依赖项。只有当你指定的所有依赖项值与上一次渲染期间的值完全相同时,React 才会跳过重新运行 Effect。React 使用 Object.is
比较来比较依赖项值。
记住,你不能选择依赖项。 如果你指定的依赖项与 React 基于 Effect 内部代码所期望的不匹配,你会得到一个 lint 错误。如果你不想重新运行代码,请修改 Effect 内部,使其"不需要依赖项"。
-
⚠️ 深入探讨 为什么可以从依赖数组中省略 ref?
在下面的代码中,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
组件,它需要在出现时连接到聊天服务器。你得到了一个 API createConnection()
,它返回一个具有 connect()
和 disconnect()
方法的对象。你将如何在组件显示给用户时保持连接活动?
useEffect(() => {
const connection = createConnection();
connection.connect(); // ✅ 连接中...
}, []);
Effect 内部的代码不使用任何 props 或 state,所以它有一个空的依赖数组([]
)。这意味着 Effect 只会在组件首次出现在屏幕上时运行。
由于这个 Effect 只在挂载时运行,你可能期望"✅ 连接中..."只被记录一次。但如果你检查控制台,你会看到它被记录了两次。
想象一个页面上的 ChatRoom
组件,你导航到另一个页面,然后返回。connect()
会再次被调用,设置第二个连接,但第一个连接从未关闭。
如果没有手动测试,这样的错误很容易被忽略,这就是为什么 React 在开发模式下会在初始挂载后重新挂载每个组件一次。
要修复这个问题,从 Effect 返回一个清理函数。
useEffect(() => {
const connection = createConnection();
connection.connect(); // ✅ 连接中...
return () => {
connection.disconnect(); // ❌ 已断开连接。
};
}, []);
React 在 Effect 再次运行之前调用清理函数,以及在组件被移除时最后一次调用。
在开发环境中,你会看到三个日志:
"✅ 连接中..."
"❌ 已断开连接。"
"✅ 连接中..."
这在开发模式下是正确的行为。 通过重新挂载组件,React 验证代码在导航离开并返回时不会崩溃。断开连接和连接正是应该发生的事情!当 React 在开发中重新挂载你的组件时,它是在检查你的代码中的错误,这是正常行为,所以不要试图消除它。
在生产环境中,只会输出一次 "✅ 连接中..."
。
处理开发中 Effect 运行两次的情况
如上例所示,React 在开发中重新挂载组件以查找错误。正确的问题不是"如何运行 Effect 一次",而是"如何修复 Effect 以在重新挂载后正确工作。"
一般答案是实现清理函数。清理函数应该停止或撤销 Effect 正在做的任何事情。基本原则是用户应该无法区分运行一次的效果(如在生产中)和运行、清理然后再次运行的效果(如在开发中)。
你将编写的大多数 Effects 将属于以下常见模式之一:
控制非 React 小部件
有时你需要添加不是用 React 编写的 UI 小部件。例如,假设你正在添加一个地图组件。它有一个 setZoomLevel()
方法,你想将其与 zoomLevel
状态同步。你的 Effect 看起来会像这样:
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);
在这种情况下,不需要清理。在开发模式下,React 会调用 Effect 两次,但这不是问题,因为用相同的值调用 setZomLevel()
两次不会做任何事情。有些 API 可能不是幂等的,意味着用相同的值调用它们两次可能会产生与调用一次不同的结果。例如,<dialog>
元素的 showModal
方法在被调用两次时会抛出异常。在这种情况下,实现一个清理函数来关闭对话框。
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);
订阅事件
如果你的 Effect 订阅了某些东西,清理函数应该取消订阅。
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
触发动画
如果你的 Effect 为某些东西设置动画,清理函数应该将动画重置为初始值。
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // 触发动画
return () => {
node.style.opacity = 0; // 重置为初始值
};
}, []);
获取数据
如果你的 Effect 获取数据,清理函数应该要么中止获取,要么忽略其结果。
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
你不能"取消"已经发生的网络请求,但你的清理函数应该确保不再相关的获取不会影响状态。
在开发中,你会在网络选项卡中看到两个获取。 使用上述方法,第一个 Effect 立即被清理,所以 ignore
变量的副本被设置为 true
。因此,即使有额外的请求,由于 if
检查,它也不会影响状态。
在生产中,只会有一个请求。 如果开发中的第二个请求困扰你,最好的方法是使用一个在组件之间去重请求并缓存响应的解决方案:
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...
在 Effects 中获取数据的好替代方案是什么?
在 Effects 中直接调用 fetch
是获取数据的流行方式,尤其是在完全客户端的应用中。然而,这是一种非常手动的方法,有显著的缺点:
- Effects 不在服务器上运行。 这意味着初始的服务器渲染的 HTML 将只包含一个没有数据的加载状态。客户端计算机将不得不下载所有 JavaScript 并渲染你的应用,只是为了发现它现在需要加载数据。
- 直接在 Effects 中获取容易创建"网络瀑布"。 你渲染父组件,它获取一些数据,渲染子组件,然后它们开始获取它们的数据。如果网络不是很快,这比并行获取所有数据要慢得多。
- 直接在 Effects 中获取意味着你不预加载或缓存数据。 例如,如果组件卸载然后再次挂载,它将不得不再次获取数据。
- 这不是很符合人体工程学。 要编写一个不受错误影响的数据获取实现,需要相当多的样板代码。
这些缺点不是特定于 React 的。它适用于使用任何库在挂载时获取数据。像路由一样,数据获取做得好并不简单,所以我们推荐这些方法:
- 如果你使用框架,使用其内置的数据获取机制。 现代 React 框架有集成的数据获取机制,既高效又不受上述缺陷影响。
- 否则,考虑使用或构建客户端缓存。 流行的开源解决方案包括 React Query、useSWR 和 React Router 6.4+。
发送分析
考虑在页面被访问时发送页面访问分析事件的代码。
useEffect(() => {
logVisit(url); // 发送 POST 请求
}, [url]);
在开发中,logVisit
将为每个 URL 调用两次,所以你可能想修复这个问题。然而,我们建议保持原样。与前面的例子一样,在运行一次或两次之间没有用户可见的差异。从实际角度来看,logVisit
在开发中不应该做任何事情,因为你不希望开发数据扭曲生产指标。
在生产环境中,不会有重复的访问日志。
不是 Effect:应用程序初始化
某些逻辑应该只在应用程序启动时运行一次。你可以将这样的逻辑放在组件外部。
if (typeof window !== 'undefined') { // 检查是否在浏览器中运行。
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
这确保逻辑只在页面加载后运行一次。