官方文件 与效果同步 阅读和整理

🌐

本帖由 DeepL 翻译。如有任何翻译错误,请告知我们!

与效果同步

与效果同步 - React

某些组件可能需要与外部系统同步。例如,连接服务器、发送分析日志,或在组件显示在屏幕上后控制独立于 React 状态的事物。

一种名为 "效果"(Effects)的东西可以帮助您在渲染后将组件与 React 以外的系统同步。

什么是 "效果",它们与 "事件 "有何不同?

在讨论 Effects 之前,了解 React 组件中的两种逻辑类型非常重要。

  • 呈现代码应该是纯函数。
  • 事件处理程序是组件中的嵌套函数,它所做的不仅仅是计算。换句话说,事件处理程序包含用户操作产生的副作用。

上述两个逻辑可能还不够。例如,考虑一个聊天室组件,当它显示在屏幕上时,必须无条件地连接到聊天服务器。与服务器的连接不是一个纯函数(它是一个副作用),因此它不能在呈现过程中发生。

**效果允许由渲染本身而非特定事件引起的副作用。**在聊天中发送信息就是一个事件。这是因为它发生在用户点击特定按钮时。然而,连接到服务器是一个 "效果",因为它应该在组件出现的那一刻发生,而与交互无关。效果会在屏幕更新后和提交结束时运行。这正是将 React 组件与外部系统同步的好时机。

注意 到此为止,"Effect(效果)"是 React 的特定定义,意指由呈现引起的次要效果。在更一般的编程中提及这一概念时,我们将使用 "副作用"。

您可能不需要 "效果"。

**不要随意在组件中添加效果。**请记住,"效果 "主要用于跳出 React 代码并与外部系统同步。我们所说的外部系统是指浏览器 API、第三方部件、网络等。如果您要实现的效果只是基于不同的状态,那么您可能不需要使用效果。

如何编写效果

要编写一个 "效果",请遵循以下三个步骤。

  1. **声明效果。**默认情况下,每次提交后都会执行 "效果"。
  2. **指定 "效果 "依赖关系。**大多数 "效果 "只应在需要时再次运行,而不是在每次呈现后运行。
  3. **根据需要添加清理功能。**有些效果需要指定如何在重新渲染时停止、还原和清理。例如,如果已连接,则应断开连接;如果已订阅,则应取消订阅。

第一步:定义效果

要在组件中定义效果,需要使用 useEffect挂钩。

在组件的顶层调用该钩子,并在其中输入以下代码

函数 MyComponent() {
  useEffect(() => {
    // 这里的代码将在每次*渲染后运行
  });
  返回 <div />;
}

每次组件渲染时,React 都会更新屏幕并执行** useEffect** 中的代码。换句话说,useEffect 会延迟代码的执行,直到渲染反映在屏幕上。

让我们以VideoPlayer组件为例

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

由于浏览器中的<video>标记没有isPlaying这样的属性,因此控制它的唯一方法就是在 DOM 元素上手动启动play()pause() 方法。在该组件中,我们需要将 isPlaying 属性的值与play()pause()等调用同步,isPlaying 属性会告诉我们视频当前是否正在播放。

您可以通过获取一个 ref 并在呈现时调用它来实现这一点,但这并不是正确的方法。

function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  if (isPlaying) {
    ref.current.play(); // 在渲染时调用是不允许的。
  } else {
    ref.current.pause(); // 此外,这会崩溃。
  }

  } return <video ref={ref} src={src} loop playsInline />;
}

Image.png

这段代码之所以不正确,是因为它试图在呈现期间对 DOM 节点进行操作。在 React 中,呈现应该是一个纯函数,不应该有任何副作用(如 DOM 操作)。

此外,当VideoPlayer首次被调用时,DOM 还不存在。没有节点可以执行play()pause( )

解决方案是将这些副作用封装在一个useEffect中,以便将它们与渲染操作分开。

import { useEffect, useRef } from 'react'function VideoPlayer({ src, isPlaying }) {
  const ref = useRef(null);

  useEffect(() => { // + + +
    if (isPlaying) {
      ref.current.play();
    } else {
      ref.current.pause();
    }
  }); // + +

  return <video ref={ref} src={src} loop playsInline />;
}

当您将 DOM 更新打包到效果中时,React 会先更新屏幕,然后运行效果。

⚠️注意!

默认情况下,每次呈现后会运行效果。因此,以下代码将创建一个无限循环

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

当状态发生变化时,组件将重新渲染,并再次执行 Effect。这将导致另一次状态变化,依此类推。

第二步:指定效果依赖关系

默认情况下,每次渲染后都会执行效果。然而,这往往不是你想要的。

  • 因为与外部系统的同步并不总是即时的,所以在不需要的时候,你可能希望省略一些行为。例如,您不想在每次按键后都重新连接服务器。
  • 您不希望您的组件在每次按键时都触发淡入动画。您希望这些动画只在组件首次出现时运行一次。
useEffect(() => {
    if (isPlaying) { // 在这里使用...
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ...所以必须在这里声明!

在依赖关系数组中指定[isPlaying]会告诉 React,如果上一次渲染时isPlaying与之前相同,则不应再次运行该效果。

依赖关系数组可以包含多个依赖关系。只有当指定的依赖关系值与上次呈现时的值完全一致时,React 才会跳过重新运行 "效果"。React 会使用Object.is比较法来比较依赖关系值。

**请记住,您不能选择依赖项。**如果您指定的依赖关系与 React 根据 "效果 "内部代码所期望的不一致,您将会收到一个 lint 错误。如果不想重新运行代码,请修改 Effect 内的代码,使其 "无依赖"。

  • ⚠️ 深入探讨 为什么可以从依赖关系数组中省略 refs?

    在下面的代码中,我们在 Effect 内部同时使用了refisPlaying,但在依赖关系中只指定了isPlaying

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

这是因为ref具有稳定的身份。React 保证您将始终从同一个useRef调用中获得相同的对象,因此在依赖关系数组中是否包含ref并不重要。同样,从useState返回的set函数也具有稳定的标识。

第 3 步:根据需要添加清理

假设您正在编写一个ChatRoom组件,当它出现时需要连接到聊天服务器。 您得到了createConnection()API,它会返回一个对象,并带有connect()disconnect()方法。如何在用户可见时保持连接?

useEffect(() => {
  const connection = createConnection();
  connection.connect(); // ✅ 正在连接...
}, []);

Effect 内的代码不使用任何道具或状态,因此它的依赖关系数组([])是空的,这意味着 Effect 只会在组件首次出现在屏幕上时运行。

由于该特效只在安装时运行,因此控制台信息"✅ 正在连接... "只会出现一次。将只显示一次。但是,当我们检查控制台时,会发现它被捕获了两次。

试想一下,如果你从带有ChatRoom组件的页面上移开,然后再回到该页面,你将再次调用connect(),建立第二个连接,但第一个连接尚未关闭。这些连接将继续堆积。

除非手动测试,否则这样的错误很容易被忽略,因此 React 会在开发模式下初始挂载后重新挂载所有组件一次。

要解决这个问题,可以让您的 Effect 返回一个清理函数

useEffect(() => {
    const connection = createConnection();
    connection.connect(); // ✅连接...
    return () => {
      connection.disconnect(); // ❌ 断开连接。
    };
  }, []);

React 会在每次再次运行效果时调用清理函数,并在最后移除组件时调用该函数。

现在,在开发环境中,你应该会看到以下三条日志。

  1. "✅正在连接......"
  2. "断开连接。"
  3. "✅ 正在连接......"

**在开发模式下,这是正确的行为。**通过重新加载组件,我们可以验证当 React 浏览其他地方并返回到该组件时,代码不会中断。断开和连接正是应该发生的事情--这是 React 在检查你的代码并寻找错误,这是正常的行为,不要试图摆脱它!

在部署环境中,只需打印一次 "✅ Connecting... "即可

如何处理 Effect 在开发环境中运行两次的问题

React 会在开发环境中重新加载组件以查找错误,如上例所示。这里的正确问题不是 "如何让 Effect 只运行一次",而是 "如何修复 Effect,使其在重挂载后正常工作"?

一般的答案是实现一个清理函数。清理函数会中止或恢复 Effect 正在进行的工作。基本原则是,用户不应该区分运行一次的 Effect(如在部署中)和重新加载的 Effect。

你将编写的大多数效果都属于下面的一种通用模式。

控制非 React 编写的部件

有时,您需要添加非 React 编写的 UI 部件。例如,让我们添加一个地图组件。 如果您的地图组件有一个setZomLevel()方法并与zoomLevel状态同步,那么您的 Effect 可能会如下所示

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

在这种情况下,无需进行清理。在开发模式下,React 会调用两次setZomLevel(),因为它会调用两次 Effect,但在部署环境中,这不是问题,因为它不会被不必要地重新加载。

某些 API 可能不允许您连续调用两次。例如,<dialog>showModal方法如果调用两次,就会抛出异常。在这种情况下,请实现一个清理函数来关闭对话框。

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

订阅事件

如果在 Effect 中订阅了某些事件,则需要在清理函数中取消订阅

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

动画触发器

如果在效果中为某些内容设置动画,则应在清理函数中将动画设置为初始值

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // 触发动画
  return () => {
    node.style.opacity = 0; // 重置为初始值
  };
}, []);

获取数据

如果效果提取了任何数据,清理函数应中止提取或忽略结果。

useEffect(() => {
  let ignore = falseasync 函数 startFetching() {
    const json = await fetchTodos(userId);
    if (!ignore) {
      setTodos(json);
    }
  }

  startFetching();

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

虽然我们无法 "取消 "已经发生的网络请求,但我们确实需要确保清理函数不会影响不再相关的获取。

**在开发环境中,你会在网络选项卡上看到两个获取。**使用上述方法,第一个 Effect 将被立即清理,并将忽略变量的副本设置为true,因此即使有其他请求,也不会影响状态,这要归功于if检查。

**在部署环境中,只会有一个请求。**如果在开发环境中第二个请求会对您造成困扰,最好的办法是使用在组件间缓存响应的解决方案。

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

在 Effect 中调用fetch是获取数据最常用的方法,尤其是在完全客户端应用程序中。 不过,这种方法非常被动,而且有很大的缺点。

  • **Effect 并不在服务器上运行。**它必须先下载所有 JavaScript 并渲染应用程序,然后才能加载数据。这并不高效。
  • **直接在 Effect 中导入数据很容易造成 "网络瀑布"。**您渲染父组件,获取一些数据,再次渲染子组件,然后子组件开始获取自己的数据。除非网络速度很快,否则这比并行获取要慢得多。
  • **直接在 Effect 内部获取数据意味着没有预加载或缓存。**例如,如果组件被重新加载,数据就必须重新导入。
  • **这不是很方便。**需要编写大量的模板代码才能避免出现错误。

获取数据是一项很难做好的工作,因此我们建议采用以下方法

  • 如果您使用的是框架,请使用其内置的数据获取机制。
  • 否则,请考虑采用 React Query、useSWR 或 React Router 6.4+ 等开源解决方案。

发送分析结果

让我们来看看发送页面访问分析的代码

useEffect(() => {
  logVisit(url); // 发送 POST 请求
}, [url]);

在开发环境中,由于logVisit被调用了两次,您可能很想修改它。不过,我们建议保持原样。与前面的示例一样,用户可以看到运行一次或两次 logVisit*在行为上没有区别。*事实上,在开发环境中,logVisit不应该做任何事情,因为你不想在开发环境中收集日志,从而影响产品指标。

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

如果不是效果:应用程序初始化

某些逻辑需要在应用程序启动时执行一次。这些逻辑可以放在组件之外。

if (typeof window !== 'undefined') { // 检查浏览器是否正在运行。
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

如上图所示,通过在组件外部执行该逻辑,可以保证在页面加载后只执行一次。