有些组件需要与外部系统同步。例如,你可能希望根据 React 状态来控制一个非 React 组件,建立服务器连接,或者在组件出现在屏幕上时发送分析日志。Effect 允许你在渲染后运行一些代码,以便将你的组件与 React 之外的某些系统同步。
🌐 Some components need to synchronize with external systems. For example, you might want to control a non-React component based on the React state, set up a server connection, or send an analytics log when a component appears on the screen. Effects let you run some code after rendering so that you can synchronize your component with some system outside of React.
你将学习到
- 什么是副作用
- 副作用与事件有何不同
- 如何在组件中声明副作用
- 如何跳过不必要的重新运行副作用
- 为什么副作用在开发中运行两次以及如何修复它们
什么是副作用,它们与事件有何不同?
🌐 What are Effects and how are they different from events?
在开始使用副作用之前,你需要熟悉 React 组件中的两种逻辑:
🌐 Before getting to Effects, you need to be familiar with two types of logic inside React components:
- 渲染代码(在描述 UI中介绍)位于组件的顶层。在这里,你使用 props 和 state,将它们转换,并返回你希望在屏幕上看到的 JSX。渲染代码必须是纯粹的。就像数学公式一样,它应该只_计算_结果,而不做其他任何事情。
- 事件处理程序(在 添加交互 中介绍)是组件内部的嵌套函数,它们是执行操作而不仅仅是计算结果。事件处理程序可能会更新输入字段、提交 HTTP POST 请求以购买产品,或将用户导航到另一个屏幕。事件处理程序包含由特定用户操作(例如按钮点击或输入)引起的[“副作用”](https://en.wikipedia.org/wiki/Side_effect_(computer_science)(它们会改变程序的状态)。
有时这还不够。考虑一个 ChatRoom 组件,它必须在屏幕上可见时连接到聊天服务器。连接到服务器不是纯计算(它是一个副作用),所以不能在渲染时发生。不过,没有单一的特定事件,比如点击,会导致 ChatRoom 被显示。
🌐 Sometimes this isn’t enough. Consider a ChatRoom component that must connect to the chat server whenever it’s visible on the screen. Connecting to a server is not a pure calculation (it’s a side effect) so it can’t happen during rendering. However, there is no single particular event like a click that causes ChatRoom to be displayed.
Effects 允许你指定由渲染本身而不是特定事件引起的副作用。 在聊天中发送消息是一个 事件,因为它是由用户点击特定按钮直接引起的。然而,建立服务器连接是一个 Effect,因为无论哪个交互导致组件出现,它都应该发生。Effects 会在屏幕更新后的 commit 末尾运行。这是将 React 组件与某些外部系统(例如网络或第三方库)同步的好时机。
你可能不需要副作用
🌐 You might not need an Effect
不要急于为你的组件添加 Effects。 请记住,Effects 通常用于“跳出”你的 React 代码并与某些外部系统进行同步。这包括浏览器 API、第三方小部件、网络等等。如果你的 Effect 只是根据其他状态调整某些状态,你可能不需要使用 Effect。
如何编写副作用
🌐 How to write an Effect
要编写副作用,请执行以下三个步骤:
🌐 To write an Effect, follow these three steps:
- 声明一个副作用。 默认情况下,你的副作用会在每次提交之后运行。
- 指定 Effect 的依赖。 大多数 Effect 应该只在必要时重新运行,而不是在每次渲染后都运行。例如,淡入动画只应在组件出现时触发。连接和断开聊天室也只应在组件出现和消失时,或者聊天室发生变化时进行。你将学习如何通过指定依赖来控制这一点。
- 如有必要,添加清理。 有些效果需要指定如何停止、撤销或清理它们正在执行的操作。例如,“connect” 需要 “disconnect”,“subscribe” 需要 “unsubscribe”,而 “fetch” 需要 “cancel” 或 “ignore”。你将通过返回一个清理函数来学习如何做到这一点。
让我们详细看看这些步骤中的每一个。
🌐 Let’s look at each of these steps in detail.
步骤 1:声明一个效果
🌐 Step 1: Declare an Effect
要在你的组件中声明一个 Effect,请从 React 导入 useEffect Hook:
🌐 To declare an Effect in your component, import the useEffect Hook from React:
import { useEffect } from 'react';然后,在组件的顶层调用它,并将一些代码放入副作用中:
🌐 Then, call it at the top level of your component and put some code inside your Effect:
function MyComponent() {
useEffect(() => {
// Code here will run after *every* render
});
return <div />;
}每次你的组件渲染时,React 都会更新屏幕 然后 运行 useEffect 内的代码。换句话说,useEffect 会“延迟”某段代码的执行,直到该渲染反映在屏幕上。
🌐 Every time your component renders, React will update the screen and then run the code inside useEffect. In other words, useEffect “delays” a piece of code from running until that render is reflected on the screen.
让我们看看如何使用 Effect 与外部系统同步。考虑一个 <VideoPlayer> React 组件。通过向它传递一个 isPlaying 属性来控制它是播放还是暂停会很方便:
🌐 Let’s see how you can use an Effect to synchronize with an external system. Consider a <VideoPlayer> React component. It would be nice to control whether it’s playing or paused by passing an isPlaying prop to it:
<VideoPlayer isPlaying={isPlaying} />;你的自定义 VideoPlayer 组件渲染内置浏览器 <video> 标签:
🌐 Your custom VideoPlayer component renders the built-in browser <video> tag:
function VideoPlayer({ src, isPlaying }) {
// TODO: do something with isPlaying
return <video src={src} />;
}然而,浏览器 <video> 标签没有 isPlaying 属性。唯一的控制方法是手动调用 DOM 元素上的 play() 和 pause() 方法。你需要将 isPlaying 属性的值(它表示视频当前是否应该播放)与像 play() 和 pause() 这样的调用进行同步。
🌐 However, the browser <video> tag does not have an isPlaying prop. The only way to control it is to manually call the play() and pause() methods on the DOM element. You need to synchronize the value of isPlaying prop, which tells whether the video should currently be playing, with calls like play() and pause().
我们首先需要 获取一个 ref 到 <video> DOM 节点。
🌐 We’ll need to first get a ref to the <video> DOM node.
你可能会想在渲染期间尝试调用 play() 或 pause(),但那是不正确的:
🌐 You might be tempted to try to call play() or pause() during rendering, but that isn’t correct:
import { useState, useRef, useEffect } from 'react'; function VideoPlayer({ src, isPlaying }) { const ref = useRef(null); if (isPlaying) { ref.current.play(); // Calling these while rendering isn't allowed. } else { ref.current.pause(); // Also, this crashes. } return <video ref={ref} src={src} loop playsInline />; } export default function App() { const [isPlaying, setIsPlaying] = useState(false); return ( <> <button onClick={() => setIsPlaying(!isPlaying)}> {isPlaying ? 'Pause' : 'Play'} </button> <VideoPlayer isPlaying={isPlaying} src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" /> </> ); }
这段代码不正确的原因是它在渲染过程中尝试对 DOM 节点进行操作。在 React 中,渲染应该是 JSX 的纯计算,不应包含诸如修改 DOM 之类的副作用。
🌐 The reason this code isn’t correct is that it tries to do something with the DOM node during rendering. In React, rendering should be a pure calculation of JSX and should not contain side effects like modifying the DOM.
此外,当第一次调用 VideoPlayer 时,其 DOM 还不存在!还没有 DOM 节点可以在其上调用 play() 或 pause(),因为 React 在你返回 JSX 之前不知道要创建什么 DOM。
🌐 Moreover, when VideoPlayer is called for the first time, its DOM does not exist yet! There isn’t a DOM node yet to call play() or pause() on, because React doesn’t know what DOM to create until you return the JSX.
这里的解决方案是用 useEffect 封装副作用,以将其移出渲染计算:
🌐 The solution here is to wrap the side effect with useEffect to move it out of the rendering calculation:
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 执行。
🌐 By wrapping the DOM update in an Effect, you let React update the screen first. Then your Effect runs.
当你的 VideoPlayer 组件渲染时(无论是第一次渲染还是重新渲染),会发生几件事情。首先,React 会更新屏幕,确保 <video> 标签在 DOM 中并具有正确的属性。然后 React 会运行你的 Effect。最后,你的 Effect 会根据 isPlaying 的值调用 play() 或 pause()。
🌐 When your VideoPlayer component renders (either the first time or if it re-renders), a few things will happen. First, React will update the screen, ensuring the <video> tag is in the DOM with the right props. Then React will run your Effect. Finally, your Effect will call play() or pause() depending on the value of isPlaying.
多次按播放/暂停,并观察视频播放器如何保持与 isPlaying 值同步:
🌐 Press Play/Pause multiple times and see how the video player stays synchronized to the isPlaying value:
import { useState, useRef, useEffect } 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 />; } export default function App() { const [isPlaying, setIsPlaying] = useState(false); return ( <> <button onClick={() => setIsPlaying(!isPlaying)}> {isPlaying ? 'Pause' : 'Play'} </button> <VideoPlayer isPlaying={isPlaying} src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" /> </> ); }
在这个例子中,你同步到 React 状态的“外部系统”是浏览器媒体 API。你可以使用类似的方法将旧的非 React 代码(例如 jQuery 插件)封装成声明式的 React 组件。
🌐 In this example, the “external system” you synchronized to React state was the browser media API. You can use a similar approach to wrap legacy non-React code (like jQuery plugins) into declarative React components.
请注意,实际上控制视频播放器要复杂得多。调用 play() 可能会失败,用户可能会使用内置浏览器控件播放或暂停,等等。这个示例非常简化且不完整。
🌐 Note that controlling a video player is much more complex in practice. Calling play() may fail, the user might play or pause using the built-in browser controls, and so on. This example is very simplified and incomplete.
步骤 2:指定效果依赖
🌐 Step 2: Specify the Effect dependencies
默认情况下,效果在每次渲染后运行。通常,这不是你想要的:
🌐 By default, Effects run after every render. Often, this is not what you want:
- 有时候,它会很慢。与外部系统同步并不总是即时的,所以除非必要,你可能想跳过它。例如,你不想在每次按键时都重新连接聊天服务器。
- 有时候,这是错误的。例如,你不希望在每次按键时触发组件的淡入动画。动画应该只在组件第一次出现时播放一次。
为了演示这个问题,这里是之前的例子,包含几个 console.log 调用和一个可以更新父组件状态的文本输入。注意输入时 Effect 会重新运行:
🌐 To demonstrate the issue, here is the previous example with a few console.log calls and a text input that updates the parent component’s state. Notice how typing causes the Effect to re-run:
import { useState, useRef, useEffect } from 'react'; function VideoPlayer({ src, isPlaying }) { const ref = useRef(null); useEffect(() => { if (isPlaying) { console.log('Calling video.play()'); ref.current.play(); } else { console.log('Calling video.pause()'); ref.current.pause(); } }); return <video ref={ref} src={src} loop playsInline />; } export default function App() { const [isPlaying, setIsPlaying] = useState(false); const [text, setText] = useState(''); return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <button onClick={() => setIsPlaying(!isPlaying)}> {isPlaying ? 'Pause' : 'Play'} </button> <VideoPlayer isPlaying={isPlaying} src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" /> </> ); }
你可以通过在 useEffect 调用中将一个 依赖 数组指定为第二个参数,告诉 React 跳过不必要的重新运行 Effect。首先在上面的示例第 14 行添加一个空的 [] 数组:
🌐 You can tell React to skip unnecessarily re-running the Effect by specifying an array of dependencies as the second argument to the useEffect call. Start by adding an empty [] array to the above example on line 14:
useEffect(() => {
// ...
}, []);你应该看到一个显示 React Hook useEffect has a missing dependency: 'isPlaying' 的错误:
🌐 You should see an error saying React Hook useEffect has a missing dependency: 'isPlaying':
import { useState, useRef, useEffect } from 'react'; function VideoPlayer({ src, isPlaying }) { const ref = useRef(null); useEffect(() => { if (isPlaying) { console.log('Calling video.play()'); ref.current.play(); } else { console.log('Calling video.pause()'); ref.current.pause(); } }, []); // This causes an error return <video ref={ref} src={src} loop playsInline />; } export default function App() { const [isPlaying, setIsPlaying] = useState(false); const [text, setText] = useState(''); return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <button onClick={() => setIsPlaying(!isPlaying)}> {isPlaying ? 'Pause' : 'Play'} </button> <VideoPlayer isPlaying={isPlaying} src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" /> </> ); }
问题是你 Effect 中的代码依赖于isPlaying 属性来决定该做什么,但这个依赖没有被明确声明。要解决此问题,请将 isPlaying 添加到依赖数组中:
🌐 The problem is that the code inside of your Effect depends on the isPlaying prop to decide what to do, but this dependency was not explicitly declared. To fix this issue, add isPlaying to the dependency array:
useEffect(() => {
if (isPlaying) { // It's used here...
// ...
} else {
// ...
}
}, [isPlaying]); // ...so it must be declared here!现在所有的依赖都已声明,因此没有错误。将 [isPlaying] 指定为依赖数组告诉 React,如果 isPlaying 与上一次渲染时相同,它应该跳过重新运行你的 Effect。通过这个更改,在输入内容时不会导致 Effect 重新运行,但按下播放/暂停按钮会:
🌐 Now all dependencies are declared, so there is no error. Specifying [isPlaying] as the dependency array tells React that it should skip re-running your Effect if isPlaying is the same as it was during the previous render. With this change, typing into the input doesn’t cause the Effect to re-run, but pressing Play/Pause does:
import { useState, useRef, useEffect } from 'react'; function VideoPlayer({ src, isPlaying }) { const ref = useRef(null); useEffect(() => { if (isPlaying) { console.log('Calling video.play()'); ref.current.play(); } else { console.log('Calling video.pause()'); ref.current.pause(); } }, [isPlaying]); return <video ref={ref} src={src} loop playsInline />; } export default function App() { const [isPlaying, setIsPlaying] = useState(false); const [text, setText] = useState(''); return ( <> <input value={text} onChange={e => setText(e.target.value)} /> <button onClick={() => setIsPlaying(!isPlaying)}> {isPlaying ? 'Pause' : 'Play'} </button> <VideoPlayer isPlaying={isPlaying} src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4" /> </> ); }
依赖数组可以包含多个依赖。只有当你指定的所有依赖的值与上一次渲染时完全相同时,React 才会跳过重新运行 Effect。React 使用 Object.is 比较来比较依赖值。详情请参见 useEffect 参考。
🌐 The dependency array can contain multiple dependencies. React will only skip re-running the Effect if all of the dependencies you specify have exactly the same values as they had during the previous render. React compares the dependency values using the Object.is comparison. See the useEffect reference for details.
请注意,你无法“选择”你的依赖。 如果你指定的依赖与 React 根据 Effect 内的代码所期望的不匹配,你将会收到 lint 错误。这有助于捕捉代码中的许多错误。如果你不希望某些代码重新运行,编辑 Effect 代码本身以不再“需要”该依赖。
深入研究
🌐 Why was the ref omitted from the dependency array?
此效果使用了 both ref 和 isPlaying,但只有 isPlaying 被声明为依赖:
🌐 This Effect uses both ref and isPlaying, but only isPlaying is declared as a dependency:
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);这是因为 ref 对象具有稳定的标识:React 保证每次渲染从同一个 useRef 调用中总是获得相同的对象。它永远不会改变,因此它本身不会导致 Effect 重新运行。因此,无论是否包含它都无关紧要。包含它也是可以的:
🌐 This is because the ref object has a stable identity: React guarantees you’ll always get the same object from the same useRef call on every render. It never changes, so it will never by itself cause the Effect to re-run. Therefore, it does not matter whether you include it or not. Including it is fine too:
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying, ref]);useState 返回的 set 函数 也具有稳定的身份,所以你经常会看到它们在依赖中被省略。如果 linter 允许你在不报错的情况下省略某个依赖,那么这样做是安全的。
🌐 The set functions returned by useState also have stable identity, so you will often see them omitted from the dependencies too. If the linter lets you omit a dependency without errors, it is safe to do.
仅省略始终稳定的依赖只有在 linter 能“看到”该对象是稳定的情况下才有效。例如,如果 ref 是从父组件传入的,你必须在依赖数组中指定它。不过,这也是好的,因为你无法确定父组件是否始终传入相同的 ref,或者有条件地传入多个 ref 中的一个。因此,你的 Effect 确实会依赖于传入了哪个 ref。
🌐 Omitting always-stable dependencies only works when the linter can “see” that the object is stable. For example, if ref was passed from a parent component, you would have to specify it in the dependency array. However, this is good because you can’t know whether the parent component always passes the same ref, or passes one of several refs conditionally. So your Effect would depend on which ref is passed.
步骤 3:如有需要,进行清理
🌐 Step 3: Add cleanup if needed
考虑一个不同的例子。你正在编写一个 ChatRoom 组件,当它出现时需要连接到聊天服务器。你得到一个 createConnection() API,它返回一个包含 connect() 和 disconnect() 方法的对象。如何在组件显示给用户时保持连接?
🌐 Consider a different example. You’re writing a ChatRoom component that needs to connect to the chat server when it appears. You are given a createConnection() API that returns an object with connect() and disconnect() methods. How do you keep the component connected while it is displayed to the user?
从编写副作用逻辑开始:
🌐 Start by writing the Effect logic:
useEffect(() => {
const connection = createConnection();
connection.connect();
});每次重新渲染后连接到聊天会很慢,所以你添加依赖数组:
🌐 It would be slow to connect to the chat after every re-render, so you add the dependency array:
useEffect(() => {
const connection = createConnection();
connection.connect();
}, []);Effect 内的代码没有使用任何 props 或 state,所以你的依赖数组是 [](空的)。这告诉 React 仅在组件“挂载”时运行这段代码,也就是组件首次出现在屏幕上时。
让我们尝试运行这段代码:
🌐 Let’s try running this code:
import { useEffect } from 'react'; import { createConnection } from './chat.js'; export default function ChatRoom() { useEffect(() => { const connection = createConnection(); connection.connect(); }, []); return <h1>Welcome to the chat!</h1>; }
这个 Effect 只会在挂载时运行,所以你可能会预期 "✅ Connecting..." 只在控制台打印一次。然而,如果你查看控制台,"✅ Connecting..." 被打印了两次。这是为什么?
🌐 This Effect only runs on mount, so you might expect "✅ Connecting..." to be printed once in the console. However, if you check the console, "✅ Connecting..." gets printed twice. Why does it happen?
想象一下,ChatRoom 组件是一个拥有许多不同界面的更大应用的一部分。用户从 ChatRoom 页面开始他们的使用旅程。组件挂载并调用 connection.connect()。然后想象用户导航到另一个界面——例如,进入设置页面。ChatRoom 组件卸载。最后,用户点击返回,ChatRoom 再次挂载。这会建立第二个连接——但第一个连接从未被销毁!当用户在应用中导航时,连接会不断累积。
🌐 Imagine the ChatRoom component is a part of a larger app with many different screens. The user starts their journey on the ChatRoom page. The component mounts and calls connection.connect(). Then imagine the user navigates to another screen—for example, to the Settings page. The ChatRoom component unmounts. Finally, the user clicks Back and ChatRoom mounts again. This would set up a second connection—but the first connection was never destroyed! As the user navigates across the app, the connections would keep piling up.
像这样的漏洞如果没有大量的人工测试很容易被忽视。为了帮助你快速发现它们,在开发过程中,React 会在组件初次挂载后立即重新挂载每个组件一次。
🌐 Bugs like this are easy to miss without extensive manual testing. To help you spot them quickly, in development React remounts every component once immediately after its initial mount.
看到 "✅ Connecting..." 日志两次可以帮助你注意到真正的问题:你的代码在组件卸载时没有关闭连接。
🌐 Seeing the "✅ Connecting..." log twice helps you notice the real issue: your code doesn’t close the connection when the component unmounts.
要解决此问题,请从你的 Effect 返回一个清理函数:
🌐 To fix the issue, return a cleanup function from your Effect:
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);React 会在每次 Effect 再次运行之前调用你的清理函数,并且在组件卸载(被移除)时最后调用一次。让我们看看当实现清理函数时会发生什么:
🌐 React will call your cleanup function each time before the Effect runs again, and one final time when the component unmounts (gets removed). Let’s see what happens when the cleanup function is implemented:
import { useState, useEffect } from 'react'; import { createConnection } from './chat.js'; export default function ChatRoom() { useEffect(() => { const connection = createConnection(); connection.connect(); return () => connection.disconnect(); }, []); return <h1>Welcome to the chat!</h1>; }
现在你在开发中得到了三个控制台日志:
🌐 Now you get three console logs in development:
"✅ Connecting...""❌ Disconnected.""✅ Connecting..."
这是在开发中的正确行为。 通过重新挂载你的组件,React 会验证离开再返回是否会破坏你的代码。断开连接然后再次连接正是应该发生的事情!当你正确实现清理时,运行一次 Effect 与运行它、清理它然后再次运行它,在用户看来应该没有区别。有额外的连接/断开调用对是因为 React 在开发中正在检查你的代码是否存在错误。这是正常的——不要试图让它消失!
在生产环境中,你只会看到 "✅ Connecting..." 被打印一次。 组件的重新挂载只会在开发环境中发生,以帮助你找到需要清理的副作用。你可以关闭 严格模式 来选择退出开发行为,但我们建议保持开启。这可以让你发现上面提到的许多错误。
如何在开发中处理副作用触发两次?
🌐 How to handle the Effect firing twice in development?
React 在开发过程中会故意重新挂载你的组件,以发现像上一个例子中的错误。正确的问题不是“如何只运行一次 Effect”,而是“如何修复我的 Effect,使其在重新挂载后仍然正常工作”。
🌐 React intentionally remounts your components in development to find bugs like in the last example. The right question isn’t “how to run an Effect once”, but “how to fix my Effect so that it works after remounting”.
通常,答案是实现清理函数。清理函数应该停止或撤销 Effect 所做的任何操作。经验法则是,用户不应该能够区分 Effect 只运行一次(如在生产环境中)与一个 setup → cleanup → setup 的序列(如你在开发环境中看到的)。
🌐 Usually, the answer is to implement the cleanup function. The cleanup function should stop or undo whatever the Effect was doing. The rule of thumb is that the user shouldn’t be able to distinguish between the Effect running once (as in production) and a setup → cleanup → setup sequence (as you’d see in development).
你将编写的大多数副作用器都适合下面的一种常见模式。
🌐 Most of the Effects you’ll write will fit into one of the common patterns below.
控制非 React 小部件
🌐 Controlling non-React widgets
有时你需要添加不是用 React 编写的 UI 小部件。例如,假设你正在向页面添加一个地图组件。它有一个 setZoomLevel() 方法,并且你希望将缩放级别与 React 代码中的 zoomLevel 状态变量保持同步。你的 Effect 可能看起来像这样:
🌐 Sometimes you need to add UI widgets that aren’t written in React. For example, let’s say you’re adding a map component to your page. It has a setZoomLevel() method, and you’d like to keep the zoom level in sync with a zoomLevel state variable in your React code. Your Effect would look similar to this:
useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);请注意,在这种情况下不需要进行清理。在开发中,React 会调用 Effect 两次,但这没问题,因为用相同的值调用 setZoomLevel 两次不会有任何影响。这可能会稍微慢一些,但这无关紧要,因为在生产环境中它不会不必要地重新挂载。
🌐 Note that there is no cleanup needed in this case. In development, React will call the Effect twice, but this is not a problem because calling setZoomLevel twice with the same value does not do anything. It may be slightly slower, but this doesn’t matter because it won’t remount needlessly in production.
有些 API 可能不允许你连续调用它们两次。例如,内置 <dialog> 元素的 showModal 方法如果你调用两次会抛出异常。实现清理函数,并让它关闭对话框:
🌐 Some APIs may not allow you to call them twice in a row. For example, the showModal method of the built-in <dialog> element throws if you call it twice. Implement the cleanup function and make it close the dialog:
useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);在开发中,你的 Effect 将会先调用 showModal(),然后立即调用 close(),接着再次调用 showModal()。这与在生产环境中调用一次 showModal() 的用户可见行为相同。
🌐 In development, your Effect will call showModal(), then immediately close(), and then showModal() again. This has the same user-visible behavior as calling showModal() once, as you would see in production.
订阅事件
🌐 Subscribing to events
如果你的副作用订阅了某些东西,清理函数应该取消订阅:
🌐 If your Effect subscribes to something, the cleanup function should unsubscribe:
useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);在开发中,你的 Effect 将会先调用 addEventListener(),然后立即调用 removeEventListener(),然后再次使用相同的处理程序调用 addEventListener()。因此,一个时间点上只会有一个活跃的订阅。这与在生产环境中调用一次 addEventListener() 所呈现的用户可见行为相同。
🌐 In development, your Effect will call addEventListener(), then immediately removeEventListener(), and then addEventListener() again with the same handler. So there would be only one active subscription at a time. This has the same user-visible behavior as calling addEventListener() once, as in production.
触发动画
🌐 Triggering animations
如果你的副作用对某些内容进行动画处理,清理函数应将动画重置为初始值:
🌐 If your Effect animates something in, the cleanup function should reset the animation to the initial values:
useEffect(() => {
const node = ref.current;
node.style.opacity = 1; // Trigger the animation
return () => {
node.style.opacity = 0; // Reset to the initial value
};
}, []);在开发中,不透明度将先设置为 1,然后设置为 0,再设置回 1。这应当与直接将其设置为 1 所呈现的用户可见行为相同,这也是在生产环境中会发生的情况。如果你使用支持补间动画的第三方动画库,你的清理函数应当将时间轴重置到初始状态。
🌐 In development, opacity will be set to 1, then to 0, and then to 1 again. This should have the same user-visible behavior as setting it to 1 directly, which is what would happen in production. If you use a third-party animation library with support for tweening, your cleanup function should reset the timeline to its initial state.
请求数据
🌐 Fetching data
如果你的 Effect 获取某些东西,清理函数应该要么中止获取,要么忽略其结果:
🌐 If your Effect fetches something, the cleanup function should either abort the fetch or ignore its result:
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);你无法“撤销”已经发生的网络请求,但你的清理函数应确保那些_不再相关的_请求不会继续影响你的应用。如果 userId 从 'Alice' 变为 'Bob',清理函数会确保即使 'Alice' 的响应在 'Bob' 之后到达,也会被忽略。
🌐 You can’t “undo” a network request that already happened, but your cleanup function should ensure that the fetch that’s not relevant anymore does not keep affecting your application. If the userId changes from 'Alice' to 'Bob', cleanup ensures that the 'Alice' response is ignored even if it arrives after 'Bob'.
在开发过程中,你会在网络(Network)标签中看到两次抓取(Fetch)。 这没有任何问题。使用上述方法,第一个 Effect 会立即被清理,因此它持有的 ignore 变量副本会被设置为 true。所以即使有额外的请求,也不会影响状态,这得益于 if (!ignore) 的检查。
在生产环境中,只会有一个请求。 如果开发环境中的第二个请求让你困扰,最好的方法是使用一种可以去重请求并在组件之间缓存其响应的解决方案:
function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...这不仅会改善开发体验,还会让你的应用感觉更快。例如,用户按下返回按钮时,不必等待某些数据重新加载,因为它会被缓存。你可以自己构建这样的缓存,也可以使用许多替代手动在 Effects 中获取数据的方法之一。
🌐 This will not only improve the development experience, but also make your application feel faster. For example, the user pressing the Back button won’t have to wait for some data to load again because it will be cached. You can either build such a cache yourself or use one of the many alternatives to manual fetching in Effects.
深入研究
🌐 What are good alternatives to data fetching in Effects?
在 Effects 中编写 fetch 调用是一种获取数据的流行方式,尤其是在完全基于客户端的应用中。然而,这是一种非常手动的方法,并且有显著的缺点:
🌐 Writing fetch calls inside Effects is a popular way to fetch data, especially in fully client-side apps. This is, however, a very manual approach and it has significant downsides:
- Effect 不会在服务器上运行。 这意味着初始的服务器渲染 HTML 将只包含一个没有数据的加载状态。客户端计算机将不得不下载所有 JavaScript 并渲染你的应用,然后才会发现现在它需要加载数据。这效率不高。
- 在 Effects 中直接获取数据使创建“网络瀑布”变得容易。 你渲染父组件,它获取一些数据,渲染子组件,然后它们开始获取各自的数据。如果网络速度不快,这比并行获取所有数据要慢得多。
- 在 Effects 中直接获取通常意味着你不会预加载或缓存数据。 例如,如果组件卸载然后再次挂载,它将不得不再次获取数据。
- 这不是很符合人机工程学。 在编写
fetch调用时,需要编写相当多的样板代码,以避免像 竞态条件 这样的错误。
这个缺点列表并不特定于 React。它适用于使用任何库在组件挂载时获取数据。就像路由一样,数据获取做得好并不简单,因此我们推荐以下方法:
🌐 This list of downsides is not specific to React. It applies to fetching data on mount with any library. Like with routing, data fetching is not trivial to do well, so we recommend the following approaches:
- 如果你使用框架,请使用其内置的数据获取机制。 现代 React 框架已经集成了高效的数据获取机制,并且不会遇到上述问题。
- 否则,考虑使用或构建客户端缓存。 常见的开源解决方案包括 TanStack Query、useSWR 和 React Router 6.4+。你也可以自己构建解决方案,在这种情况下,你会在底层使用 Effects,但需要添加去重请求、缓存响应以及避免网络瀑布(通过预加载数据或将数据需求提升到路由)的逻辑。
如果这些方法都不适合你,你可以继续直接在副作用中获取数据。
🌐 You can continue fetching data directly in Effects if neither of these approaches suit you.
发送分析
🌐 Sending analytics
考虑在页面访问时发送分析事件的代码:
🌐 Consider this code that sends an analytics event on the page visit:
useEffect(() => {
logVisit(url); // Sends a POST request
}, [url]);在开发过程中,每个 URL 会被调用两次 logVisit,所以你可能会想尝试修复它。我们建议保持这段代码不变。 就像之前的示例一样,运行一次和运行两次在用户可见的行为上没有区别。从实际角度来看,logVisit 在开发过程中不应该执行任何操作,因为你不希望开发机器的日志影响生产指标。每次保存组件文件时组件都会重新挂载,所以在开发环境中它无论如何都会记录额外的访问次数。
🌐 In development, logVisit will be called twice for every URL, so you might be tempted to try to fix that. We recommend keeping this code as is. Like with earlier examples, there is no user-visible behavior difference between running it once and running it twice. From a practical point of view, logVisit should not do anything in development because you don’t want the logs from the development machines to skew the production metrics. Your component remounts every time you save its file, so it logs extra visits in development anyway.
在生产环境中,不会有重复的访问日志。
要调试你发送的分析事件,你可以将应用部署到一个预发布环境(以生产模式运行),或者暂时退出 严格模式 及其仅在开发中进行的重新挂载检查。你也可以从路由变化事件处理器发送分析,而不是从 Effect 中发送。为了更精确的分析,交叉监视器 可以帮助追踪哪些组件在视口中以及它们可见的持续时间。
🌐 To debug the analytics events you’re sending, you can deploy your app to a staging environment (which runs in production mode) or temporarily opt out of Strict Mode and its development-only remounting checks. You may also send analytics from the route change event handlers instead of Effects. For more precise analytics, intersection observers can help track which components are in the viewport and how long they remain visible.
不是效果:正在初始化应用
🌐 Not an Effect: Initializing the application
有些逻辑只应在应用启动时运行一次。你可以将它放在组件外部:
🌐 Some logic should only run once when the application starts. You can put it outside your components:
if (typeof window !== 'undefined') { // Check if we're running in the browser.
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}这保证了此类逻辑仅在浏览器加载页面后运行一次。
🌐 This guarantees that such logic only runs once after the browser loads the page.
不是效果:购买产品
🌐 Not an Effect: Buying a product
有时候,即使你编写了清理函数,也无法防止运行 Effect 两次所带来的用户可见的后果。例如,可能你的 Effect 会发送一个 POST 请求,比如购买产品:
🌐 Sometimes, even if you write a cleanup function, there’s no way to prevent user-visible consequences of running the Effect twice. For example, maybe your Effect sends a POST request like buying a product:
useEffect(() => {
// 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code.
fetch('/api/buy', { method: 'POST' });
}, []);你不会想要重复购买这个产品。然而,这也是为什么你不应该把这个逻辑放在 Effect 中的原因。如果用户去了其他页面然后再按返回呢?你的 Effect 会再次运行。你不想在用户访问页面时购买产品;你想在用户点击购买按钮时购买它。
🌐 You wouldn’t want to buy the product twice. However, this is also why you shouldn’t put this logic in an Effect. What if the user goes to another page and then presses Back? Your Effect would run again. You don’t want to buy the product when the user visits a page; you want to buy it when the user clicks the Buy button.
购买不是由渲染引起的;它是由特定的交互引起的。它应该只在用户按下按钮时运行。删除该效果,并将你的 /api/buy 请求移到购买按钮的事件处理器中:
🌐 Buying is not caused by rendering; it’s caused by a specific interaction. It should run only when the user presses the button. Delete the Effect and move your /api/buy request into the Buy button event handler:
function handleClick() {
// ✅ Buying is an event because it is caused by a particular interaction.
fetch('/api/buy', { method: 'POST' });
}这说明,如果重新挂载破坏了应用的逻辑,这通常会揭示已有的错误。 从用户的角度来看,访问一个页面不应该与先访问它,点击一个链接,然后按后退再查看该页面有所不同。React 通过在开发中将组件重新挂载一次,来验证你的组件是否遵守这一原则。
把它们放在一起
🌐 Putting it all together
这个游乐场可以帮助你“感受”效果在实际中的工作方式。
🌐 This playground can help you “get a feel” for how Effects work in practice.
此示例使用 setTimeout 来安排一个控制台日志,其中包含输入文本,计划在 Effect 运行后三秒出现。清理函数会取消未完成的超时。首先按“挂载组件”开始:
🌐 This example uses setTimeout to schedule a console log with the input text to appear three seconds after the Effect runs. The cleanup function cancels the pending timeout. Start by pressing “Mount the component”:
import { useState, useEffect } from 'react'; function Playground() { const [text, setText] = useState('a'); useEffect(() => { function onTimeout() { console.log('⏰ ' + text); } console.log('🔵 Schedule "' + text + '" log'); const timeoutId = setTimeout(onTimeout, 3000); return () => { console.log('🟡 Cancel "' + text + '" log'); clearTimeout(timeoutId); }; }, [text]); return ( <> <label> What to log:{' '} <input value={text} onChange={e => setText(e.target.value)} /> </label> <h1>{text}</h1> </> ); } export default function App() { const [show, setShow] = useState(false); return ( <> <button onClick={() => setShow(!show)}> {show ? 'Unmount' : 'Mount'} the component </button> {show && <hr />} {show && <Playground />} </> ); }
你最初会看到三个日志:Schedule "a" log、Cancel "a" log 和 Schedule "a" log。三秒钟后还会有一个日志显示 a。正如你之前学到的,额外的 schedule/cancel 成对出现,是因为 React 在开发环境下会重新挂载组件一次,以验证你是否正确实现了清理工作。
🌐 You will see three logs at first: Schedule "a" log, Cancel "a" log, and Schedule "a" log again. Three second later there will also be a log saying a. As you learned earlier, the extra schedule/cancel pair is because React remounts the component once in development to verify that you’ve implemented cleanup well.
现在将输入内容编辑为 abc。如果你足够快地编辑,你会看到 Schedule "ab" log 紧接着 Cancel "ab" log 和 Schedule "abc" log。React 总是在下一次渲染的 Effect 执行前清理上一次渲染的 Effect。 这就是为什么即使你快速输入,也最多只有一个定时器被安排。将输入编辑几次,并观察控制台,以了解 Effect 是如何被清理的。
🌐 Now edit the input to say abc. If you do it fast enough, you’ll see Schedule "ab" log immediately followed by Cancel "ab" log and Schedule "abc" log. React always cleans up the previous render’s Effect before the next render’s Effect. This is why even if you type into the input fast, there is at most one timeout scheduled at a time. Edit the input a few times and watch the console to get a feel for how Effects get cleaned up.
在输入框中输入一些内容,然后立即点击“卸载组件”。注意卸载如何清理上一次渲染的 Effect。在这里,它会在最后的超时触发之前清除它。
🌐 Type something into the input and then immediately press “Unmount the component”. Notice how unmounting cleans up the last render’s Effect. Here, it clears the last timeout before it has a chance to fire.
最后,编辑上面的组件,并将清理函数注释掉,这样超时就不会被取消。试着快速输入 abcde。你预计三秒钟后会发生什么?超时中的 console.log(text) 会打印最新的 text 并产生五个 abcde 日志吗?试试看以验证你的直觉!
🌐 Finally, edit the component above and comment out the cleanup function so that the timeouts don’t get cancelled. Try typing abcde fast. What do you expect to happen in three seconds? Will console.log(text) inside the timeout print the latest text and produce five abcde logs? Give it a try to check your intuition!
三秒钟后,你应该会看到一系列日志(a、ab、abc、abcd 和 abcde),而不是五个 abcde 日志。每个 Effect 都会“捕获”其对应渲染的 text 值。 text 状态的变化并不重要:来自包含 text = 'ab' 的渲染的 Effect 总是会看到 'ab'。换句话说,每次渲染的 Effect 都相互独立。如果你想了解其工作原理,你可以阅读关于 闭包 的资料。
🌐 Three seconds later, you should see a sequence of logs (a, ab, abc, abcd, and abcde) rather than five abcde logs. Each Effect “captures” the text value from its corresponding render. It doesn’t matter that the text state changed: an Effect from the render with text = 'ab' will always see 'ab'. In other words, Effects from each render are isolated from each other. If you’re curious how this works, you can read about closures.
深入研究
🌐 Each render has its own Effects
你可以将 useEffect 理解为将一段行为“附加”到渲染输出上。考虑以下这个 Effect:
🌐 You can think of useEffect as “attaching” a piece of behavior to the render output. Consider this Effect:
export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <h1>Welcome to {roomId}!</h1>;
}让我们看看当用户在应用中导航时到底发生了什么。
🌐 Let’s see what exactly happens as the user navigates around the app.
初始渲染
🌐 Initial render
用户访问 <ChatRoom roomId="general" />。让我们在脑海中替换 roomId 为 'general':
🌐 The user visits <ChatRoom roomId="general" />. Let’s mentally substitute roomId with 'general':
// JSX for the first render (roomId = "general")
return <h1>Welcome to general!</h1>;**效果也是渲染输出的一部分。**第一次渲染的效果变为:
// Effect for the first render (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the first render (roomId = "general")
['general']React 运行此 Effect,它连接到 'general' 聊天室。
🌐 React runs this Effect, which connects to the 'general' chat room.
使用相同的依赖重新渲染
🌐 Re-render with same dependencies
假设 <ChatRoom roomId="general" /> 重新渲染。JSX 输出是相同的:
🌐 Let’s say <ChatRoom roomId="general" /> re-renders. The JSX output is the same:
// JSX for the second render (roomId = "general")
return <h1>Welcome to general!</h1>;React 看到渲染输出没有改变,所以它不会更新 DOM。
🌐 React sees that the rendering output has not changed, so it doesn’t update the DOM.
第二次渲染的副作用如下所示:
🌐 The Effect from the second render looks like this:
// Effect for the second render (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the second render (roomId = "general")
['general']React 将第二次渲染的 ['general'] 与第一次渲染的 ['general'] 进行比较。因为所有依赖都相同,React 会忽略第二次渲染的 Effect。 它永远不会被调用。
🌐 React compares ['general'] from the second render with ['general'] from the first render. Because all dependencies are the same, React ignores the Effect from the second render. It never gets called.
使用不同的依赖重新渲染
🌐 Re-render with different dependencies
然后,用户访问 <ChatRoom roomId="travel" />。这次,该组件返回不同的 JSX:
🌐 Then, the user visits <ChatRoom roomId="travel" />. This time, the component returns different JSX:
// JSX for the third render (roomId = "travel")
return <h1>Welcome to travel!</h1>;React 更新 DOM,将 "Welcome to general" 改为 "Welcome to travel"。
🌐 React updates the DOM to change "Welcome to general" into "Welcome to travel".
第三次渲染的副作用如下所示:
🌐 The Effect from the third render looks like this:
// Effect for the third render (roomId = "travel")
() => {
const connection = createConnection('travel');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the third render (roomId = "travel")
['travel']React 会将第三次渲染的 ['travel'] 与第二次渲染的 ['general'] 进行比较。一个依赖不同:Object.is('travel', 'general') 是 false。该 Effect 无法被跳过。
🌐 React compares ['travel'] from the third render with ['general'] from the second render. One dependency is different: Object.is('travel', 'general') is false. The Effect can’t be skipped.
在 React 可以应用第三次渲染的 Effect 之前,它需要先清理上次运行的 Effect。 第二次渲染的 Effect 被跳过,因此 React 需要清理第一次渲染的 Effect。如果你向上滚动到第一次渲染,你会看到它的清理调用了 disconnect() 来处理用 createConnection('general') 创建的连接。这会将应用从 'general' 聊天室中断开。
之后,React 运行第三次渲染的 Effect。它连接到 'travel' 聊天室。
🌐 After that, React runs the third render’s Effect. It connects to the 'travel' chat room.
卸载
🌐 Unmount
最后,假设用户导航离开,并且 ChatRoom 组件卸载。React 会运行最后一个 Effect 的清理函数。最后一个 Effect 来自第三次渲染。第三次渲染的清理会销毁 createConnection('travel') 连接。因此应用会断开与 'travel' 房间的连接。
🌐 Finally, let’s say the user navigates away, and the ChatRoom component unmounts. React runs the last Effect’s cleanup function. The last Effect was from the third render. The third render’s cleanup destroys the createConnection('travel') connection. So the app disconnects from the 'travel' room.
仅开发的行为
🌐 Development-only behaviors
当 严格模式 开启时,React 会在组件挂载后重新挂载每个组件一次(状态和 DOM 会被保留)。这 有助于你找到需要清理的副作用 并且能及早暴露像竞态条件这样的错误。此外,每当你在开发中保存文件时,React 会重新挂载副作用。这两种行为都是仅在开发环境下的。
🌐 When Strict Mode is on, React remounts every component once after mount (state and DOM are preserved). This helps you find Effects that need cleanup and exposes bugs like race conditions early. Additionally, React will remount the Effects whenever you save a file in development. Both of these behaviors are development-only.
回顾
- 与事件不同,副作用是由渲染本身而不是特定交互引起的。
- 副作用让你可以将组件与某些外部系统(第三方 API、网络等)同步。
- 默认情况下,副作用在每次渲染后运行(包括初始渲染)。
- 如果 React 的所有依赖都具有与上次渲染期间相同的值,则 React 将跳过副作用。
- 你无法“选择”你的依赖。它们是由 Effect 内部的代码决定的。
- 空依赖数组(
[])对应组件的“挂载”,即被添加到屏幕上。 - 在严格模式下,React 会两次挂载组件(仅在开发中!)以对你的副作用进行压力测试。
- 如果你的副作用因为重新挂载而中断,你需要实现一个清理函数。
- React 将在副作用下次运行之前以及卸载期间调用你的清理函数。
挑战 1 of 4: 挂载时聚焦于一个字段
🌐 Focus a field on mount
在这个例子中,表单渲染了一个 <MyInput /> 组件。
🌐 In this example, the form renders a <MyInput /> component.
使用输入的 focus() 方法使 MyInput 在出现在屏幕上时自动获得焦点。已经有一个被注释掉的实现,但它并不能正常工作。找出它为什么不起作用,并修复它。(如果你熟悉 autoFocus 属性,请假装它不存在:我们正在从零重新实现相同的功能。)
🌐 Use the input’s focus() method to make MyInput automatically focus when it appears on the screen. There is already a commented out implementation, but it doesn’t quite work. Figure out why it doesn’t work, and fix it. (If you’re familiar with the autoFocus attribute, pretend that it does not exist: we are reimplementing the same functionality from scratch.)
import { useEffect, useRef } from 'react'; export default function MyInput({ value, onChange }) { const ref = useRef(null); // TODO: This doesn't quite work. Fix it. // ref.current.focus() return ( <input ref={ref} value={value} onChange={onChange} /> ); }
要验证你的解决方案是否有效,请按“显示表单”,并确认输入框获得焦点(变为高亮并将光标置于其中)。然后按“隐藏表单”,再按“显示表单”。确认输入框再次被高亮。
🌐 To verify that your solution works, press “Show form” and verify that the input receives focus (becomes highlighted and the cursor is placed inside). Press “Hide form” and “Show form” again. Verify the input is highlighted again.
MyInput 应该只在挂载时聚焦,而不是在每次渲染后。要验证行为是否正确,请按“显示表单”,然后反复点击“变为大写”复选框。点击复选框不应聚焦其上方的输入框。