使用副作用进行同步

一些组件需要与外部系统同步。例如,你可能希望根据 React 状态控制非 React 组件、设置服务器连接或在组件出现在屏幕上时发送分析日志。副作用让你在渲染后运行一些代码,这样你就可以将你的组件与 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 Effects are

  • 副作用与事件有何不同

    ¥How Effects are different from events

  • 如何在组件中声明副作用

    ¥How to declare an Effect in your component

  • 如何跳过不必要的重新运行副作用

    ¥How to skip re-running an Effect unnecessarily

  • 为什么副作用在开发中运行两次以及如何修复它们

    ¥Why Effects run twice in development and how to fix them

什么是副作用,它们与事件有何不同?

¥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:

  • 渲染代码(在 描述用户界面 中引入)位于组件的顶层。这是你获取属性和状态的地方,转换它们,并返回你想在屏幕上看到的 JSX。渲染代码必须是纯粹的。 就像数学公式一样,它应该只计算结果,而不做任何其他事情。

    ¥Rendering code (introduced in Describing the UI) lives at the top level of your component. This is where you take the props and state, transform them, and return the JSX you want to see on the screen. Rendering code must be pure. Like a math formula, it should only calculate the result, but not do anything else.

  • 事件处理程序(在 添加交互性 中引入)是组件内的嵌套函数,它们执行操作而不仅仅是计算它们。事件处理程序可能会更新输入字段、提交 HTTP POST 请求以购买产品或将用户导航到另一个屏幕。事件处理程序包含由特定用户操作(例如,单击按钮或键入)引起的 “副作用”(它们更改程序的状态)。

    ¥Event handlers (introduced in Adding Interactivity) are nested functions inside your components that do things rather than just calculate them. An event handler might update an input field, submit an HTTP POST request to buy a product, or navigate the user to another screen. Event handlers contain “side effects” (they change the program’s state) caused by a specific user action (for example, a button click or typing).

有时这还不够。考虑一个 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.

副作用允许你指定由渲染本身而不是特定事件引起的副作用。在聊天中发送消息是一个事件,因为它是由用户单击特定按钮直接引起的。但是,设置服务器连接是一个副作用,因为无论哪个交互导致组件出现,它都应该发生。屏幕更新后,副作用在 提交 结束时运行。这是将 React 组件与某些外部系统(如网络或第三方库)同步的好时机。

¥Effects let you specify side effects that are caused by rendering itself, rather than by a particular event. Sending a message in the chat is an event because it is directly caused by the user clicking a specific button. However, setting up a server connection is an Effect because it should happen no matter which interaction caused the component to appear. Effects run at the end of a commit after the screen updates. This is a good time to synchronize the React components with some external system (like network or a third-party library).

注意

在本文的此处和后面,大写的 “副作用” 指的是上面 React 特定的定义,即由渲染引起的副作用。为了引用更广泛的编程概念,我们会说 “副作用”。

¥Here and later in this text, capitalized “Effect” refers to the React-specific definition above, i.e. a side effect caused by rendering. To refer to the broader programming concept, we’ll say “side effect”.

你可能不需要副作用

¥You might not need an Effect

不要急于为你的组件添加副作用。请记住,副作用通常用于你的 React 代码的 “走出” 并与某些外部系统同步。这包括浏览器 API、第三方小部件、网络等。如果你的副作用仅根据其他状态调整某些状态,你可能不需要副作用

¥Don’t rush to add Effects to your components. Keep in mind that Effects are typically used to “step out” of your React code and synchronize with some external system. This includes browser APIs, third-party widgets, network, and so on. If your Effect only adjusts some state based on other state, you might not need an Effect.

如何编写副作用

¥How to write an Effect

要编写副作用,请执行以下三个步骤:

¥To write an Effect, follow these three steps:

  1. 声明副作用。默认情况下,你的效果将在每个 提交 之后运行。

    ¥Declare an Effect. By default, your Effect will run after every commit.

  2. 指定副作用依赖。大多数副作用只应在需要时重新运行,而不是在每次渲染后重新运行。例如,淡入动画应该只在组件出现时触发。与聊天室的连接和断开连接应该只在组件出现和消失时发生,或者在聊天室发生变化时发生。你将学习如何通过指定依赖来控制它。

    ¥Specify the Effect dependencies. Most Effects should only re-run when needed rather than after every render. For example, a fade-in animation should only trigger when a component appears. Connecting and disconnecting to a chat room should only happen when the component appears and disappears, or when the chat room changes. You will learn how to control this by specifying dependencies.

  3. 如果需要,添加清理。某些副作用需要指定如何停止、撤消或清理它们正在做的任何事情。例如,“连接” 需要 “断开连接”,“订阅” 需要 “取消订阅”,“请求” 需要 “取消” 或 “忽略”。你将学习如何通过返回清理函数来执行此操作。

    ¥Add cleanup if needed. Some Effects need to specify how to stop, undo, or clean up whatever they were doing. For example, “connect” needs “disconnect”, “subscribe” needs “unsubscribe”, and “fetch” needs either “cancel” or “ignore”. You will learn how to do this by returning a cleanup function.

让我们详细看看这些步骤中的每一个。

¥Let’s look at each of these steps in detail.

步骤 1:声明副作用

¥Step 1: Declare an Effect

要在组件中声明副作用,请从 React 导入 useEffect 钩子

¥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.

让我们看看如何使用副作用与外部系统同步。考虑一个 <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() 方法。你需要使用 play()pause() 等调用来同步 isPlaying 属性的值,该属性指示当前是否应该播放视频。

¥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().

我们需要先 获取一个引用 指向 <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 还不存在!还没有要调用 play()pause() 的 DOM 节点,因为在你返回 JSX 之前,React 不知道要创建什么 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 更新封装在副作用中,你可以让 React 首先更新屏幕。然后你的副作用运行。

¥By wrapping the DOM update in an Effect, you let React update the screen first. Then your Effect runs.

当你的 VideoPlayer 组件渲染时(无论是第一次还是重新渲染),会发生一些事情。首先,React 将更新屏幕,确保 <video> 标签位于具有正确属性的 DOM 中。然后 React 将运行你的副作用。最后,你的副作用将根据 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.

易犯错误

默认情况下,副作用在每次渲染后运行。这就是为什么这样的代码会产生无限循环:

¥By default, Effects run after every render. This is why code like this will produce an infinite loop:

const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});

副作用作为渲染的结果运行。设置状态触发渲染。在副作用中立即设置状态就像将电源插座插入自身。副作用运行,它设置状态,这导致重新渲染,这导致副作用运行,它再次设置状态,这导致另一个重新渲染,以此类推。

¥Effects run as a result of rendering. Setting state triggers rendering. Setting state immediately in an Effect is like plugging a power outlet into itself. The Effect runs, it sets the state, which causes a re-render, which causes the Effect to run, it sets the state again, this causes another re-render, and so on.

副作用通常应该使你的组件与外部系统同步。如果没有外部系统,你只想根据其他状态调整一些状态,你可能不需要副作用

¥Effects should usually synchronize your components with an external system. If there’s no external system and you only want to adjust some state based on other state, you might not need an Effect.

步骤 2:指定副作用依赖

¥Step 2: Specify the Effect dependencies

默认情况下,副作用在每次渲染后运行。通常,这不是你想要的:

¥By default, Effects run after every render. Often, this is not what you want:

  • 有时,它很慢。与外部系统同步并不总是即时的,因此除非必要,否则你可能希望跳过它。例如,你不想在每次击键时都重新连接到聊天服务器。

    ¥Sometimes, it’s slow. Synchronizing with an external system is not always instant, so you might want to skip doing it unless it’s necessary. For example, you don’t want to reconnect to the chat server on every keystroke.

  • 有时候,这是错误的。例如,你不想在每次击键时触发组件淡入动画。动画应该只在组件第一次出现时播放一次。

    ¥Sometimes, it’s wrong. For example, you don’t want to trigger a component fade-in animation on every keystroke. The animation should only play once when the component appears for the first time.

为了演示这个问题,这里是前面的示例,其中包含几个 console.log 调用和更新父组件状态的文本输入。请注意键入如何导致副作用重新运行:

¥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 跳过不必要的重新运行副作用。首先在第 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"
      />
    </>
  );
}

问题是你的副作用中的代码依赖于 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 与之前渲染期间的相同,它应该跳过重新运行副作用。通过此更改,键入输入不会导致副作用重新运行,但按播放/暂停会:

¥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 只会跳过重新运行副作用。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 根据副作用中的代码所期望的不匹配,你将收到 lint 错误。这有助于捕获代码中的许多错误。如果你不想重新运行某些代码,将副作用代码本身编辑为不 “需要” 该依赖。

¥Notice that you can’t “choose” your dependencies. You will get a lint error if the dependencies you specified don’t match what React expects based on the code inside your Effect. This helps catch many bugs in your code. If you don’t want some code to re-run, edit the Effect code itself to not “need” that dependency.

易犯错误

没有依赖数组和空 [] 依赖数组的行为是不同的:

¥The behaviors without the dependency array and with an empty [] dependency array are different:

useEffect(() => {
// This runs after every render
});

useEffect(() => {
// This runs only on mount (when the component appears)
}, []);

useEffect(() => {
// This runs on mount *and also* if either a or b have changed since the last render
}, [a, b]);

我们将在下一步中仔细研究 “挂载” 的含义。

¥We’ll take a close look at what “mount” means in the next step.

深入研究

为什么从依赖数组中省略了引用?

¥Why was the ref omitted from the dependency array?

此副作用同时使用 refisPlaying,但仅将 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 调用 你总是会得到相同的对象。它永远不会改变,所以它自己永远不会导致副作用重新运行。因此,是否包含它并不重要。包括它也很好:

¥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 是从父组件传递的,则必须在依赖数组中指定它。但是,这很好,因为你无法知道父组件是否始终传递相同的引用,或者有条件地传递几个引用之一。因此,你的副作用将取决于传递的是哪个引用。

¥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 内的代码不使用任何属性或 state,因此你的依赖数组为 [](空)。这告诉 React 仅在组件 “挂载”(即第一次出现在屏幕上)时运行此代码。

¥The code inside the Effect does not use any props or state, so your dependency array is [] (empty). This tells React to only run this code when the component “mounts”, i.e. appears on the screen for the first time.

让我们尝试运行这段代码:

¥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>;
}

此副作用仅在挂载时运行,因此你可能希望 "✅ 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.

要解决此问题,请从你的副作用中返回一个清理函数:

¥To fix the issue, return a cleanup function from your Effect:

useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);

每次副作用再次运行之前,React 都会调用你的清理函数,最后一次组件卸载(被删除)时。让我们看看执行清理函数时会发生什么:

¥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:

  1. "✅ Connecting..."
  2. "❌ Disconnected."
  3. "✅ Connecting..."

这是开发中的正确行为。通过重新挂载你的组件,React 验证离开和返回导航不会破坏你的代码。断开连接然后再次连接正是应该发生的事情!当你很好地实现清理时,运行副作用一次与运行副作用、清理并再次运行副作用之间应该没有用户可见的区别。有一个额外的连接/断开调用对,因为 React 正在探测你的代码以查找开发中的错误。这很正常 - 不要试图让它消失!

¥This is the correct behavior in development. By remounting your component, React verifies that navigating away and back would not break your code. Disconnecting and then connecting again is exactly what should happen! When you implement the cleanup well, there should be no user-visible difference between running the Effect once vs running it, cleaning it up, and running it again. There’s an extra connect/disconnect call pair because React is probing your code for bugs in development. This is normal—don’t try to make it go away!

在生产中,你只会看到 "✅ Connecting..." 打印一次。重新挂载组件仅在开发过程中发生,以帮助你找到需要清理的副作用。你可以关闭 严格模式 以选择退出开发行为,但我们建议将其保持打开状态。这可以让你发现许多像上面的错误。

¥In production, you would only see "✅ Connecting..." printed once. Remounting components only happens in development to help you find Effects that need cleanup. You can turn off Strict Mode to opt out of the development behavior, but we recommend keeping it on. This lets you find many bugs like the one above.

如何在开发中处理副作用触发两次?

¥How to handle the Effect firing twice in development?

React 有意在开发中重新挂载你的组件以查找上一个示例中的错误。正确的问题不是 “如何运行一次副作用”,而是 “如何修复我的副作用,使其在重新挂载后正常工作”。

¥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”.

通常,答案是实现清理函数。清除函数应该停止或撤消副作用正在做的任何事情。经验法则是,用户不应该能够区分副作用运行一次(如在生产中)和设置 → 清理 → 设置序列(如你在开发中看到的)。

¥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.

易犯错误

不要使用引用来阻止副作用触发

¥Don’t use refs to prevent Effects from firing

在开发过程中防止效果触发两次的一个常见陷阱是使用 ref 来防止效果运行多次。例如,你可以使用 useRef “fix” 上述错误:

¥A common pitfall for preventing Effects firing twice in development is to use a ref to prevent the Effect from running more than once. For example, you could “fix” the above bug with a useRef:

const connectionRef = useRef(null);
useEffect(() => {
// 🚩 This wont fix the bug!!!
if (!connectionRef.current) {
connectionRef.current = createConnection();
connectionRef.current.connect();
}
}, []);

这使得你在开发过程中只能看到 "✅ Connecting..." 一次,但它并不能修复错误。

¥This makes it so you only see "✅ Connecting..." once in development, but it doesn’t fix the bug.

当用户导航离开时,连接仍然没有关闭,当用户返回时,会创建一个新连接。当用户在应用中导航时,连接会不断堆积,就像 “fix” 之前一样。

¥When the user navigates away, the connection still isn’t closed and when they navigate back, a new connection is created. As the user navigates across the app, the connections would keep piling up, the same as it would before the “fix”.

要修复该错误,仅使副作用运行一次是不够的。效果需要重新安装后才能生效,这意味着需要像上面的解决方案一样清理连接。

¥To fix the bug, it is not enough to just make the Effect run once. The effect needs to work after re-mounting, which means the connection needs to be cleaned up like in the solution above.

有关如何处理常见模式的信息,请参阅下面的示例。

¥See the examples below for how to handle common patterns.

控制非 React 小部件

¥Controlling non-React widgets

有时你需要添加未写入 React 的 UI 小部件。例如,假设你要向页面添加地图组件。它有一个 setZoomLevel() 方法,你希望使缩放级别与 React 代码中的 zoomLevel 状态变量保持同步。你的副作用看起来类似于:

¥Sometimes you need to add UI widgets that aren’t written to 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 会调用副作用两次,但这不是问题,因为用相同的值调用 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();
}, []);

在开发中,你的副作用将调用 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);
}, []);

在开发中,你的副作用将调用 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

如果你的副作用获取了一些东西,清理函数应该 中止请求 或忽略它的结果:

¥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'.

在开发中,你将在“网络”选项卡中看到两个请求。没有什么不妥。使用上述方法,第一个副作用将立即被清理,因此它的 ignore 变量副本将被设置为 true。因此,即使有额外的请求,也不会影响状态,这要归功于 if (!ignore) 检查。

¥In development, you will see two fetches in the Network tab. There is nothing wrong with that. With the approach above, the first Effect will immediately get cleaned up so its copy of the ignore variable will be set to true. So even though there is an extra request, it won’t affect the state thanks to the if (!ignore) check.

在生产中,只会有一个请求。如果开发中的第二个请求困扰你,最好的方法是使用一种解决方案,该解决方案可以对请求进行数据去重并在组件之间缓存它们的响应:

¥In production, there will only be one request. If the second request in development is bothering you, the best approach is to use a solution that deduplicates requests and caches their responses between components:

function TodoList() {
const todos = useSomeDataLibrary(`/api/user/${userId}/todos`);
// ...

这不仅会改善开发体验,还会让你的应用感觉更快。例如,按下后退按钮的用户不必等待某些数据再次加载,因为它会被缓存。你可以自己构建这样的缓存,也可以使用众多替代方法之一来在副作用中手动获取。

¥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?

在副作用中编写 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:

  • 副作用不在服务器上运行。这意味着初始服务器渲染的 HTML 将仅包含没有数据的加载状态。客户端计算机必须下载所有 JavaScript 并渲染你的应用,然后才发现它现在需要加载数据。这不是很有效。

    ¥Effects don’t run on the server. This means that the initial server-rendered HTML will only include a loading state with no data. The client computer will have to download all JavaScript and render your app only to discover that now it needs to load the data. This is not very efficient.

  • 直接在副作用中请求可以轻松创建 “网络瀑布”。你渲染父组件,它获取一些数据,渲染子组件,然后它们开始获取数据。如果网络不是很快,这比并行获取所有数据要慢得多。

    ¥Fetching directly in Effects makes it easy to create “network waterfalls”. You render the parent component, it fetches some data, renders the child components, and then they start fetching their data. If the network is not very fast, this is significantly slower than fetching all data in parallel.

  • 直接在副作用中请求通常意味着你没有预加载或缓存数据。例如,如果组件卸载然后再次挂载,则它必须再次获取数据。

    ¥Fetching directly in Effects usually means you don’t preload or cache data. For example, if the component unmounts and then mounts again, it would have to fetch the data again.

  • 这不是很符合人机工程学。在以一种不会出现像 竞态条件 这样的错误的方式编写 fetch 调用时,涉及到相当多的样板代码。

    ¥It’s not very ergonomic. There’s quite a bit of boilerplate code involved when writing fetch calls in a way that doesn’t suffer from bugs like race conditions.

这个缺点列表并不是 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 框架集成了高效的数据请求机制,不会出现上述问题。

    ¥If you use a framework, use its built-in data fetching mechanism. Modern React frameworks have integrated data fetching mechanisms that are efficient and don’t suffer from the above pitfalls.

  • 否则,请考虑使用或构建客户端缓存。流行的开源解决方案包括 React 查询使用驻波比React 路由 6.4+。 你也可以构建自己的解决方案,在这种情况下,你可以在后台使用副作用,但添加用于删除重复请求、缓存响应和避免网络瀑布的逻辑(通过预加载数据或提升 路由的数据要求)。

    ¥Otherwise, consider using or building a client-side cache. Popular open source solutions include React Query, useSWR, and React Router 6.4+. You can build your own solution too, in which case you would use Effects under the hood, but add logic for deduplicating requests, caching responses, and avoiding network waterfalls (by preloading data or hoisting data requirements to routes).

如果这些方法都不适合你,你可以继续直接在副作用中获取数据。

¥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]);

在开发中,logVisit 将针对每个 URL 调用两次,因此你可能想尝试修复它。我们建议保持此代码不变。与前面的示例一样,运行一次和运行两次之间没有用户可见的行为差异。从实际的角度来看,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.

在生产中,不会有重复的访问日志。

¥In production, there will be no duplicate visit logs.

要调试你发送的分析事件,你可以将你的应用部署到暂存环境(在生产模式下运行)或暂时选择退出 严格模式 及其仅用于开发的重新挂载检查。你还可以从路由更改事件处理程序而不是副作用发送分析。为了进行更精确的分析,交集观察者 可以帮助跟踪哪些组件在视口中以及它们保持可见的时间。

¥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

有时,即使你编写了清理函数,也无法避免运行副作用两次导致用户可见的后果。例如,你的副作用可能会发送一个 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' });
}, []);

你不会想购买该产品两次。然而,这也是为什么你不应该将此逻辑放在副作用中的原因。如果用户转到另一个页面然后按返回怎么办?你的副作用会再次运行。你不想在用户访问页面时购买产品;当用户单击“购买”按钮时,你想购买它。

¥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 通过在开发中重新挂载它们来验证你的组件是否遵守此原则。

¥This illustrates that if remounting breaks the logic of your application, this usually uncovers existing bugs. From a user’s perspective, visiting a page shouldn’t be different from visiting it, clicking a link, then pressing Back to view the page again. React verifies that your components abide by this principle by remounting them once in development.

把它们放在一起

¥Putting it all together

这个演练场可以帮助你 “感受一下” 了解副作用在实践中是如何工作的。

¥This playground can help you “get a feel” for how Effects work in practice.

此示例使用 setTimeout 安排控制台日志,其中输入文本在副作用运行后三秒出现。清除函数取消挂起的超时。按 “挂载组件” 开始:

¥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" logCancel "a" logSchedule "a" log。三秒钟后,还会有一条日志显示 a。正如你之前了解到的,额外的调度/取消对是因为 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" logSchedule "abc" log。React 总是在下一个渲染的副作用之前清理上一个渲染的副作用。这就是为什么即使你快速输入,一次也最多安排一个超时。编辑输入几次并观察控制台以了解副作用是如何被清理的。

¥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.

在输入中输入内容,然后立即按 “卸载组件”。请注意卸载如何清理最后一个渲染的副作用。在这里,它在有机会触发之前清除了最后一次超时。

¥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,产生 5 条 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!

三秒后,你应该会看到一系列日志(aababcabcdabcde),而不是五个 abcde 日志。每个副作用 “捕获” 来自其相应渲染的 text 值。text 状态改变并不重要:使用 text = 'ab' 渲染的副作用将始终看到 'ab'。换句话说,来自每个渲染器的副作用是相互隔离的。如果你好奇这是如何工作的,你可以阅读 闭包

¥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 视为 “附属” 渲染输出的一种行为。考虑这个副作用:

¥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" />。让我们使用 'general' 在心里替代 roomId

¥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>;

副作用也是渲染输出的一部分。第一个渲染器的副作用变为:

¥The Effect is also a part of the rendering output. The first render’s Effect becomes:

// 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 运行此副作用,它连接到 '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 忽略了第二次渲染的副作用。它永远不会被调用。

¥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。副作用不能跳过。

¥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 可以应用来自第三次渲染的副作用之前,它需要清理最后运行的副作用。第二个渲染的副作用被跳过了,所以 React 需要清理第一个渲染的副作用。如果你向上滚动到第一个渲染,你会看到它的清理在使用 createConnection('general') 创建的连接上调用 disconnect()。这会断开应用与 'general' 聊天室的连接。

¥Before React can apply the Effect from the third render, it needs to clean up the last Effect that did run. The second render’s Effect was skipped, so React needs to clean up the first render’s Effect. If you scroll up to the first render, you’ll see that its cleanup calls disconnect() on the connection that was created with createConnection('general'). This disconnects the app from the 'general' chat room.

之后,React 运行第三次渲染的副作用。它连接到 'travel' 聊天室。

¥After that, React runs the third render’s Effect. It connects to the 'travel' chat room.

卸载

¥Unmount

最后,假设用户导航离开,ChatRoom 组件卸载。React 运行最后一个副作用的清理函数。最后一个副作用来自第三个渲染。第三次渲染的清理破坏了 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.

回顾

  • 与事件不同,副作用是由渲染本身而不是特定交互引起的。

    ¥Unlike events, Effects are caused by rendering itself rather than a particular interaction.

  • 副作用让你可以将组件与某些外部系统(第三方 API、网络等)同步。

    ¥Effects let you synchronize a component with some external system (third-party API, network, etc).

  • 默认情况下,副作用在每次渲染后运行(包括初始渲染)。

    ¥By default, Effects run after every render (including the initial one).

  • 如果 React 的所有依赖都具有与上次渲染期间相同的值,则 React 将跳过副作用。

    ¥React will skip the Effect if all of its dependencies have the same values as during the last render.

  • 你不能 “挑选” 你的依赖。它们由副作用中的代码决定。

    ¥You can’t “choose” your dependencies. They are determined by the code inside the Effect.

  • 空依赖数组 ([]) 对应于组件 “挂载”,即被添加到屏幕。

    ¥Empty dependency array ([]) corresponds to the component “mounting”, i.e. being added to the screen.

  • 在严格模式下,React 会两次挂载组件(仅在开发中!)以对你的副作用进行压力测试。

    ¥In Strict Mode, React mounts components twice (in development only!) to stress-test your Effects.

  • 如果你的副作用因为重新挂载而中断,你需要实现一个清理函数。

    ¥If your Effect breaks because of remounting, you need to implement a cleanup function.

  • React 将在副作用下次运行之前以及卸载期间调用你的清理函数。

    ¥React will call your cleanup function before the Effect runs next time, and during the unmount.

挑战 1 / 4:
挂载时聚焦于一个字段

¥Focus a field on mount

在此示例中,表单渲染 <MyInput /> 组件。

¥In this example, the form renders a <MyInput /> component.

使用 input 的 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 应该只关注挂载而不是在每次渲染之后。要验证行为是否正确,请按 “显示表单”,然后重复按 “使其变成大写” 复选框。单击该复选框不应聚焦其上方的输入。

¥MyInput should only focus on mount rather than after every render. To verify that the behavior is right, press “Show form” and then repeatedly press the “Make it uppercase” checkbox. Clicking the checkbox should not focus the input above it.


React 中文网 - 粤ICP备13048890号