使用 Effects 进行同步

一些组件需要与外部系统同步。例如,你可能希望根据 React 状态控制一个非 React 组件,建立服务器连接,或者在组件出现在屏幕上时发送分析日志。**_Effects_ 允许你在渲染后运行一些代码,以便你可以将组件与 React 之外的某些系统同步。**

你将学到

  • 什么是 Effects
  • Effects 与事件的不同之处
  • 如何在组件中声明 Effect
  • 如何在不必要的情况下跳过重新运行 Effect
  • 为什么 Effects 在开发中会运行两次,以及如何修复它们

什么是 Effects,它们与事件有什么不同?

在了解 Effects 之前,你需要熟悉 React 组件内部的两种逻辑类型

  • **渲染代码**(在 描述 UI 中介绍)位于组件的顶层。在这里,你获取 props 和状态,对它们进行转换,并返回你想在屏幕上看到的 JSX。 渲染代码必须是纯的。 就像数学公式一样,它应该只**_计算_**结果,而不做任何其他事情。

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

有时这还不够。考虑一个 ChatRoom 组件,它必须在每次可见时连接到聊天服务器。连接到服务器不是纯粹的计算(它是一个副作用),因此不能在渲染期间发生。但是,没有像点击这样导致 ChatRoom 显示的特定事件。

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

注意

在本文本中以及之后的文本中,大写的 “Effect” 是指上面特定于 React 的定义,即由渲染引起的副作用。为了指代更广泛的编程概念,我们将使用 “副作用”。

你可能不需要 Effect

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

如何编写 Effect

要编写 Effect,请按照以下三个步骤操作

  1. **声明 Effect。** 默认情况下,你的 Effect 将在每次 提交 后运行。
  2. 指定 Effect 的依赖项。大多数 Effect 应该只在*需要时*才重新运行,而不是在每次渲染后都运行。例如,淡入动画应该只在组件出现时触发。连接和断开聊天室应该只在组件出现和消失时,或者聊天室发生变化时发生。您将学习如何通过指定*依赖项*来控制这一点。
  3. 如果需要,添加清理函数。某些 Effect 需要指定如何停止、撤销或清理它们正在做的事情。例如,“连接”需要“断开”,“订阅”需要“取消订阅”,而“获取”需要“取消”或“忽略”。您将学习如何通过返回一个*清理函数*来做到这一点。

让我们详细了解一下每一个步骤。

步骤 1:声明一个 Effect

要在组件中声明一个 Effect,请从 React 中导入 useEffect 钩子

import { useEffect } from 'react';

然后,在组件的顶层调用它,并在 Effect 中放入一些代码

function MyComponent() {
useEffect(() => {
// Code here will run after *every* render
});
return <div />;
}

每次组件渲染时,React 都会更新屏幕,*然后*运行 useEffect 中的代码。换句话说,useEffect 会“延迟”一段代码的运行,直到渲染结果反映在屏幕上。

让我们看看如何使用 Effect 与外部系统同步。考虑一个 <VideoPlayer> React 组件。通过向其传递一个 isPlaying 属性来控制它是播放还是暂停,这将是一件好事

<VideoPlayer isPlaying={isPlaying} />;

您的自定义 VideoPlayer 组件渲染内置的浏览器 <video> 标签

function VideoPlayer({ src, isPlaying }) {
// TODO: do something with isPlaying
return <video src={src} />;
}

然而,浏览器 <video> 标签没有 isPlaying 属性。控制它的唯一方法是在 DOM 元素上手动调用 play()pause() 方法。您需要将 isPlaying 属性的值(指示视频*是否*应该正在播放)与 play()pause() 等调用同步。

我们需要首先 获取对 <video> DOM 节点的引用。

您可能会尝试在渲染过程中调用 play()pause(),但这并不正确

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 等副作用。

此外,当 VideoPlayer 首次被调用时,它的 DOM 还不存在!还没有 DOM 节点可以调用 play()pause(),因为在您返回 JSX 之前,React 并不知道要创建什么 DOM。

这里的解决方案是 使用 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 才会运行。

当您的 VideoPlayer 组件渲染时(无论是第一次还是重新渲染),会发生以下几件事。首先,React 会更新屏幕,确保 <video> 标签在 DOM 中,并具有正确的属性。然后 React 会运行您的 Effect。最后,您的 Effect 会根据 isPlaying 的值调用 play()pause()

多次按下播放/暂停,看看视频播放器如何与 isPlaying 值保持同步

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 组件中。

请注意,在实践中控制视频播放器要复杂得多。调用 play() 可能会失败,用户可能会使用内置的浏览器控件进行播放或暂停,等等。本例非常简化,并不完整。

陷阱

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

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

Effects 作为渲染的*结果*运行。设置状态会*触发*渲染。在 Effect 中立即设置状态就像把电源插座插到自己身上一样。Effect 运行,它设置状态,这会导致重新渲染,这会导致 Effect 再次运行,它再次设置状态,这会导致另一次重新渲染,等等。

Effects 通常应该将您的组件与*外部*系统同步。如果没有外部系统,并且您只想根据其他状态调整某些状态,您可能不需要 Effect。

步骤 2:指定 Effect 依赖项

默认情况下,Effects 在*每次*渲染后运行。通常,这不是您想要的:

  • 有时,它很慢。与外部系统同步并不总是即时的,因此您可能希望除非必要,否则跳过此操作。例如,您不希望在每次按键时都重新连接到聊天服务器。
  • 有时,这是错误的。例如,您不希望在每次按键时都触发组件淡入动画。动画应该只在组件第一次出现时播放一次。

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

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 行的示例中添加一个空的 [] 数组。

useEffect(() => {
// ...
}, []);

您应该会看到一个错误提示:React Hook useEffect 缺少一个依赖项:'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 添加到依赖项数组中。

useEffect(() => {
if (isPlaying) { // It's used here...
// ...
} else {
// ...
}
}, [isPlaying]); // ...so it must be declared here!

现在所有依赖项都已声明,因此不会出现错误。将 [isPlaying] 指定为依赖项数组,告诉 React 如果 isPlaying 与上一次渲染时相同,则应跳过重新运行 Effect。通过此更改,在输入框中键入内容不会导致 Effect 重新运行,但按下播放/暂停按钮会。

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 参考

请注意,您不能“选择”您的依赖项。 如果您指定的依赖项与 React 根据 Effect 代码的预期不匹配,您将收到 lint 错误。这有助于捕获代码中的许多错误。如果您不希望某些代码重新运行,请 *编辑 Effect 代码本身* 以使其“不需要”该依赖项。

陷阱

没有依赖项数组和具有 *空* 的 [] 依赖项数组的行为是不同的。

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

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

深入探讨

为什么 ref 被省略在依赖项数组之外?

这个 Effect *同时* 使用了 refisPlaying,但只有 isPlaying 被声明为依赖项。

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying]);

这是因为 ref 对象具有 *稳定的标识:* React 保证 您在每次渲染时都将从同一个 useRef 调用中获得相同的对象。它永远不会改变,因此它本身永远不会导致 Effect 重新运行。因此,您是否包含它并不重要。包含它也是可以的。

function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
}, [isPlaying, ref]);

useState 返回的 set 函数 也具有稳定的标识,因此您也经常会看到它们被省略在依赖项之外。如果 linter 允许您省略一个依赖项而没有错误,那么这样做是安全的。

省略始终稳定的依赖项仅在 linter 可以“看到”对象是稳定的情况下才有效。例如,如果 ref 是从父组件传递的,则必须在依赖项数组中指定它。但是,这很好,因为您不知道父组件是始终传递相同的 ref,还是有条件地传递多个 ref 之一。因此,您的 Effect *将* 取决于传递的是哪个 ref。

步骤 3:如果需要,添加清理函数

考虑一个不同的例子。您正在编写一个 ChatRoom 组件,该组件需要在出现时连接到聊天服务器。您将获得一个 createConnection() API,该 API 返回一个具有 connect()disconnect() 方法的对象。如何在组件显示给用户时保持连接?

首先编写 Effect 逻辑。

useEffect(() => {
const connection = createConnection();
connection.connect();
});

在每次重新渲染后连接到聊天都很慢,因此您添加了依赖项数组。

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

Effect 中的代码没有使用任何属性或状态,因此您的依赖项数组是 [](空)。这告诉 React 仅在组件“挂载”时运行此代码,即第一次出现在屏幕上时。

让我们尝试运行此代码。

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 仅在挂载时运行,因此您可能希望在控制台中仅打印一次 "✅ 连接中..."但是,如果您查看控制台,"✅ 连接中..." 会被打印两次。为什么会发生这种情况?

假设 ChatRoom 组件是一个包含许多不同屏幕的大型应用程序的一部分。用户从 ChatRoom 页面开始他们的旅程。组件挂载并调用 connection.connect()。然后想象用户导航到另一个屏幕,例如“设置”页面。 ChatRoom 组件卸载。最后,用户单击“后退”按钮,ChatRoom 再次挂载。这将建立第二个连接,但第一个连接从未断开!随着用户在应用程序中导航,连接将不断累积。

如果没有广泛的手动测试,此类错误很容易被遗漏。为了帮助您快速发现它们,在开发过程中,React 会在组件初始挂载后立即重新挂载每个组件一次。

看到两次 "✅ 连接中..." 日志可以帮助您注意到真正的问题:您的代码在组件卸载时没有关闭连接。

要解决此问题,请从您的 Effect 中返回一个 *清理函数* 。

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

React 每次在 Effect 再次运行之前都会调用您的清理函数,并在组件卸载(被移除)时最后调用一次。让我们看看实现清理函数后会发生什么。

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

现在,您将在开发过程中获得三个控制台日志。

  1. "✅ 连接中..."
  2. "❌ 已断开连接。"
  3. "✅ 连接中..."

这是开发环境中的正确行为。 通过重新挂载组件,React 会验证离开并返回当前页面是否会破坏你的代码。断开连接然后再重新连接正是应该发生的事情!当你正确实现了清理逻辑后,运行一次 Effect 和运行、清理、再运行之间应该没有用户可见的区别。 之所以会多出一对连接/断开调用,是因为 React 正在探测你的代码中是否存在错误。这是正常现象,不要试图消除它!

在生产环境中,你只会看到 "✅ 连接中…" 打印一次。 重新挂载组件只会在开发环境中发生,以帮助你找到需要清理的 Effect。你可以关闭 严格模式 来选择退出此开发行为,但我们建议你保持开启状态。这可以让你找到许多类似上面示例中的错误。

如何处理 Effect 在开发环境中触发两次?

React 会在开发环境中有意重新挂载你的组件,以便发现上一个示例中的错误。 正确的问题不是“如何只运行一次 Effect”,而是“如何修复我的 Effect,使其在重新挂载后也能正常工作”。

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

你将编写的大多数 Effect 都符合以下常见模式之一。

陷阱

不要使用 refs 来阻止 Effects 触发

为了防止 Effects 在开发环境中触发两次,一个常见的错误做法是使用 ref 来防止 Effect 运行多次。 例如,你可以使用 useRef 来“修复”上述错误

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

这会使你在开发环境中只看到一次 "✅ 连接中…",但它并没有真正修复错误。

当用户离开页面时,连接仍然没有关闭,当他们返回时,会创建一个新的连接。 当用户在应用程序中导航时,连接会不断累积,就像“修复”之前一样。

要修复此错误,仅仅使 Effect 运行一次是不够的。 Effect 需要在重新挂载后也能正常工作,这意味着需要像上面的解决方案一样清理连接。

有关如何处理常见模式的示例,请参见下文。

控制非 React 小部件

有时,你需要添加不是用 React 编写的 UI 小部件。 例如,假设你要向页面添加一个地图组件。 它有一个 setZoomLevel() 方法,并且你想保持缩放级别与 React 代码中的 zoomLevel 状态变量同步。 你的 Effect 看起来类似于这样

useEffect(() => {
const map = mapRef.current;
map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

请注意,在这种情况下不需要清理。 在开发环境中,React 会调用两次 Effect,但这并不是问题,因为使用相同的值调用两次 setZoomLevel 不会产生任何影响。 它可能会稍微慢一些,但这并不重要,因为它在生产环境中不会不必要地重新挂载。

某些 API 可能不允许你连续调用两次。 例如,内置 <dialog> 元素的 showModal 方法会在你调用两次时抛出异常。 实现清理函数并使其关闭对话框

useEffect(() => {
const dialog = dialogRef.current;
dialog.showModal();
return () => dialog.close();
}, []);

在开发环境中,你的 Effect 会调用 showModal(),然后立即调用 close(),然后再调用 showModal()。 这与调用一次 showModal() 具有相同的用户可见行为,就像你在生产环境中看到的那样。

订阅事件

如果你的 Effect 订阅了某些内容,则清理函数应该取消订阅

useEffect(() => {
function handleScroll(e) {
console.log(window.scrollX, window.scrollY);
}
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);

在开发环境中,你的 Effect 会调用 addEventListener(),然后立即调用 removeEventListener(),然后再使用相同的处理程序调用 addEventListener()。 因此,一次只有一个活动的订阅。 这与调用一次 addEventListener() 具有相同的用户可见行为,就像在生产环境中一样。

触发动画

如果你的 Effect 动画化了某些内容,则清理函数应该将动画重置为初始值

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 具有相同的用户可见行为,就像在生产环境中发生的那样。 如果你使用支持补间的第三方动画库,则清理函数应将时间轴重置为其初始状态。

获取数据

如果您的 Effect 获取了某些内容,则清理函数应该 中止获取 或忽略其结果。

useEffect(() => {
let ignore = false;

async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}

startFetching();

return () => {
ignore = true;
};
}, [userId]);

您不能“撤消”已经发生的网络请求,但您的清理函数应确保不再相关的获取不会继续影响您的应用程序。如果 userId'Alice' 更改为 'Bob',则清理可确保即使在 'Bob' 之后到达,也会忽略 'Alice' 的响应。

在开发过程中,您会在“网络”选项卡中看到两次获取。这没有错。使用上述方法,第一个 Effect 会立即被清理,因此它的 ignore 变量副本将被设置为 true。因此,即使存在额外的请求,由于 if (!ignore) 检查,它也不会影响状态。

在生产环境中,只会发出一个请求。 如果开发过程中的第二个请求让您感到困扰,最好的方法是使用一种解决方案来消除重复请求并在组件之间缓存它们的响应。

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

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

深入探讨

Effects 中数据获取的良好替代方案是什么?

在 Effects 内部编写 fetch 调用是 获取数据 的一种流行方式,尤其是在完全客户端的应用程序中。然而,这是一种非常手动的方法,并且存在明显的缺点。

  • Effects 不会在服务器上运行。这意味着最初服务器渲染的 HTML 将仅包含加载状态,而没有数据。客户端计算机必须下载所有 JavaScript 并渲染您的应用程序,然后才会发现现在需要加载数据。这不是很有效率。
  • 直接在 Effects 中获取数据很容易造成“网络瀑布”。您渲染父组件,它获取一些数据,渲染子组件,然后子组件开始获取它们的数据。如果网络速度不是很快,这将比并行获取所有数据慢得多。
  • 直接在 Effects 中获取数据通常意味着您不会预加载或缓存数据。例如,如果组件卸载然后再次挂载,它将不得不再次获取数据。
  • 它不是很符合人体工程学。在编写 fetch 调用时,需要编写大量的样板代码,才能避免出现 竞争条件 等错误。

这些缺点并非 React 特有的。它适用于使用任何库在挂载时获取数据。与路由一样,数据获取并非易事,因此我们建议采用以下方法。

  • 如果您使用的是框架,请使用其内置的数据获取机制。 现代 React 框架具有集成的数据获取机制,这些机制效率很高,并且不会遇到上述问题。
  • 否则,请考虑使用或构建客户端缓存。 流行的开源解决方案包括 React QueryuseSWRReact Router 6.4+。您也可以构建自己的解决方案,在这种情况下,您将在幕后使用 Effects,但要添加用于消除重复请求、缓存响应和避免网络瀑布(通过预加载数据或将数据需求提升到路由)的逻辑。

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

发送分析数据

考虑以下在页面访问时发送分析事件的代码。

useEffect(() => {
logVisit(url); // Sends a POST request
}, [url]);

在开发过程中,logVisit 将为每个 URL 调用两次,因此您可能会尝试修复此问题。我们建议保留此代码原样。与前面的示例一样,运行一次和运行两次之间没有用户可见的行为差异。从实际的角度来看,logVisit 在开发过程中不应该做任何事情,因为您不希望来自开发机器的日志扭曲生产指标。每次保存组件文件时,它都会重新挂载,因此无论如何它都会在开发过程中记录额外的访问次数。

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

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

非 Effect:初始化应用程序

某些逻辑应该只在应用程序启动时运行一次。您可以将其放在组件之外。

if (typeof window !== 'undefined') { // Check if we're running in the browser.
checkAuthToken();
loadDataFromLocalStorage();
}

function App() {
// ...
}

这可以确保此类逻辑在浏览器加载页面后只运行一次。

非副作用:购买产品

有时,即使编写了清理函数,也无法阻止副作用函数运行两次而产生的用户可见后果。例如,假设你的副作用函数发送了一个 POST 请求,比如购买产品。

useEffect(() => {
// 🔴 Wrong: This Effect fires twice in development, exposing a problem in the code.
fetch('/api/buy', { method: 'POST' });
}, []);

你不会想购买两次产品。然而,这也是你不应该将此逻辑放在副作用函数中的原因。如果用户转到另一个页面,然后按下“后退”按钮会发生什么?你的副作用函数会再次运行。你不希望在用户*访问*页面时购买产品;你希望在用户*点击*“购买”按钮时购买产品。

购买不是由渲染引起的;它是由特定的交互引起的。它应该只在用户按下按钮时运行。 删除副作用函数,并将你的 /api/buy 请求移至“购买”按钮的事件处理程序中:

function handleClick() {
// ✅ Buying is an event because it is caused by a particular interaction.
fetch('/api/buy', { method: 'POST' });
}

这说明如果重新挂载破坏了应用程序的逻辑,则通常会暴露出现有的错误。 从用户的角度来看,访问一个页面应该与访问该页面、点击一个链接,然后按下“后退”按钮再次查看该页面没有什么不同。React 通过在开发过程中重新挂载组件来验证你的组件是否遵守此原则。

汇总

这个练习场可以帮助你“感受”副作用函数在实践中的工作方式。

此示例使用 setTimeout 来安排在副作用函数运行三秒后显示带有输入文本的控制台日志。清理函数会取消挂起的超时。首先按下“挂载组件”。

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 />}
    </>
  );
}

你一开始会看到三个日志:安排 "a" 日志取消 "a" 日志,以及再次出现的 安排 "a" 日志。三秒后,还会出现一条日志,显示 a。正如你之前所了解的,额外的安排/取消对是因为 React 在开发过程中重新挂载了一次组件,以验证你是否很好地实现了清理。

现在编辑输入以显示 abc。如果你操作得足够快,你会看到 安排 "ab" 日志,紧接着是 取消 "ab" 日志安排 "abc" 日志React 始终会在下一个渲染的副作用函数之前清理上一个渲染的副作用函数。 这就是为什么即使你快速输入到输入框中,一次最多也只安排一个超时。多次编辑输入并观察控制台,以了解副作用函数是如何被清理的。

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

最后,编辑上面的组件并注释掉清理函数,以便超时不会被取消。尝试快速输入 abcde。你预计三秒钟后会发生什么?在超时打印*最新*的 text 并生成五个 abcde 日志时,console.log(text) 会在超时中打印出来吗?试一试,检验一下你的直觉!

三秒钟后,你应该会看到一系列日志(aababcabcdabcde),而不是五个 abcde 日志。 每个副作用函数都会从其对应的渲染中“捕获” text 值。 text 状态是否更改无关紧要:渲染中带有 text = 'ab' 的副作用函数将始终看到 'ab'。换句话说,每次渲染的副作用函数彼此隔离。如果你好奇这是如何工作的,可以阅读有关 闭包 的内容。

深入探讨

每次渲染都有自己的副作用函数

你可以将 useEffect 视为将一段行为“附加”到渲染输出。考虑一下这个副作用函数。

export default function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);

return <h1>Welcome to {roomId}!</h1>;
}

让我们看看当用户在应用程序中导航时究竟会发生什么。

初始渲染

用户访问 <ChatRoom roomId="general" />。让我们在 精神上替换 roomId'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 运行此副作用函数,该函数连接到 'general' 聊天室。

使用相同的依赖项重新渲染

假设 <ChatRoom roomId="general" /> 重新渲染。JSX 输出相同。

// JSX for the second render (roomId = "general")
return <h1>Welcome to general!</h1>;

React 发现渲染输出没有改变,所以它不会更新 DOM。

第二次渲染的副作用函数如下所示。

// 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*忽略*第二次渲染的副作用函数。 它永远不会被调用。

使用不同的依赖项重新渲染

然后,用户访问 <ChatRoom roomId="travel" />。这次,组件返回不同的 JSX

// JSX for the third render (roomId = "travel")
return <h1>Welcome to travel!</h1>;

React 更新 DOM,将 "Welcome to general" 更改为 "Welcome to travel"

第三次渲染的效果如下所示

// 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 应用第三次渲染的 Effect 之前,它需要清理上一个*确实*运行的 Effect。 第二次渲染的 Effect 被跳过了,因此 React 需要清理第一次渲染的 Effect。如果您向上滚动到第一次渲染,您将看到它的清理调用了使用 createConnection('general') 创建的连接上的 disconnect()。这将断开应用程序与 'general' 聊天室的连接。

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

卸载

最后,假设用户导航离开,并且 ChatRoom 组件卸载。React 运行最后一个 Effect 的清理函数。最后一个 Effect 来自第三次渲染。第三次渲染的清理销毁了 createConnection('travel') 连接。因此,应用程序与 'travel' 聊天室断开连接。

仅限开发环境的行为

严格模式 打开时,React 会在挂载后重新挂载每个组件一次(保留状态和 DOM)。这 有助于您找到需要清理的 Effect,并及早暴露诸如竞争条件之类的错误。此外,每当您在开发过程中保存文件时,React 都会重新挂载 Effect。这两种行为都仅在开发环境中出现。

回顾

  • 与事件不同,Effect 是由渲染本身而不是特定交互引起的。
  • Effect 允许您将组件与某些外部系统(第三方 API、网络等)同步。
  • 默认情况下,Effect 在每次渲染后运行(包括初始渲染)。
  • 如果 Effect 的所有依赖项都与上次渲染时的值相同,React 将跳过该 Effect。
  • 您不能“选择”您的依赖项。它们由 Effect 内部的代码决定。
  • 空依赖数组 ([]) 对应于组件“挂载”,即被添加到屏幕上。
  • 在严格模式下,React 会挂载组件两次(仅限开发环境!),以对您的 Effect 进行压力测试。
  • 如果您的 Effect 因为重新挂载而中断,则需要实现清理函数。
  • React 将在下一次 Effect 运行之前以及卸载期间调用您的清理函数。

挑战 1 4:
挂载时聚焦字段

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

使用输入的 focus() 方法,使 MyInput 在屏幕上显示时自动获得焦点。已经有一个注释掉的实现,但它不能正常工作。找出它不能工作的原因,并进行修复。(如果您熟悉 autoFocus 属性,请假装它不存在:我们正在从头开始重新实现相同的功能。)

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}
    />
  );
}

要验证您的解决方案是否有效,请按“显示表单”,并验证输入是否获得焦点(变为高亮显示,并且光标位于其中)。按“隐藏表单”和“显示表单”。验证输入是否再次高亮显示。

MyInput 应该只在*挂载时*获得焦点,而不是每次渲染后都获得焦点。要验证行为是否正确,请按“显示表单”,然后重复按“转换为大写”复选框。单击复选框*不应*使其上方的输入获得焦点。