第二章前置知识:2.3 Synchronizing with Effects将你的组件与外部系统同步

91 阅读16分钟

本专栏致力于每周分享一个项目,如果本文对你有帮助的话,欢迎点赞或者关注☘️

React18 源码系列会随着学习 React 源码的实时进度而实时更新:约,两天一小改,五天一大改。

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

What are Effects and how are they different from events?

什么是Effects以及它们与事件有何不同

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

Rendering code 位于组件的顶层,您可以在这里获取 props 和 state,并且 transform 他们,然后返回您想要在屏幕上看到的 JSX。渲染代码必须是纯粹 Pure 的。就像数学公式一样,它应该只计算结果,而不做任何其他事情。

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

1️⃣副作用的定义:在计算机科学中,如果操作、函数表达式除了读取其参数值并将值返回给操作调用者的主要效果之外,还具有任何可观察到的效果,则称其具有副作用。 2️⃣示例副作用包括修改非局部变量静态局部变量通过引用传递的可变参数;引发错误或异常;执行I/O ;或调用其他有副作用的函数。进行非幂等性操作,导致多次操作和单次操作对应用造成的影响不一致。 3️⃣函数式编程旨在最小化或消除副作用。缺乏副作用使得对程序进行形式验证变得更容易。

连接到服务器不是纯粹的计算(它是副作用),因此在渲染期间不会发生。

Effects允许您指定由渲染本身而不是由特定事件引起的副作用。例如在聊天中发送消息是一个事件,因为它是由用户单击特定按钮直接引起的。然而,建立服务器连接是一种Effects,因为无论哪种交互导致组件出现,它都应该发生。效果在屏幕更新后commit阶段结束时运行。

在React中,大写的“Effect”指的是上面 React 特定的定义,即由渲染引起的副作用。为了指代更广泛的编程概念,我们会说“side effect”

You might not need an Effect

您可能不需要副作用

Effects 通常用于“跳出”React 代码并与某些外部系统同步。这包括浏览器 API、第三方小部件、网络等。如果您的Effect仅根据其他状态调整某些状态,则您可能不需要Effect。

How to write an Effect

如何编写Effect

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

声明Effect。

默认情况下,您的 Effect 将在每次 Commit 后运行。

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

import { useEffect } from 'react';

// 组件的顶层调用它并在 Effect 中放入一些代码:
function MyComponent() {
  useEffect(() => {
    // Code here will run after *every* render
  });
  return <div />;
}

该代码不正确的原因是它尝试在渲染期间对 DOM 节点执行某些操作。在 React 中,渲染应该是 JSX 的纯粹计算,并且不应该包含修改 DOM 等副作用。通过将 DOM 更新包装在 Effect 中,您可以让 React 首先更新屏幕。然后你的Effect运行。

import { useState, useRef, useEffect } from 'react';

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

  // 而且,当VideoPlayer第一次被调用时,它的DOM还不存在!还没有 DOM 节点可以调用play()或pause() ,因为在您返回 JSX 之前,React 不知道要创建什么 DOM。
  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"
      />
    </>
  );
}

// 解决方案是用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 />;
}

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

Effect 作为渲染的结果运行,Effect在渲染后运行,Effect中setState设置状态会触发渲染,渲染完成后又会触发Effect执行。Effect 通常应将您的组件与外部系统同步。如果没有外部系统,并且您只想根据其他状态调整某些状态,则可能不需要Effect。

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

指定Effect依赖。

大多数 Effect 应该仅在需要时重新运行,而不是在每次渲染后重新运行。而默认情况下,Effect 却在每次渲染后运行。通常,这不是您想要的:

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

您可以通过指定依赖项数组作为useEffect调用的第二个参数来告诉 React跳过不必要的重新运行 Effect。(可以使用lint来校验写没写正确依赖):

指定[isPlaying]作为依赖数组告诉 React,如果isPlaying与之前渲染期间的相同,它应该跳过重新运行 Effect。

依赖项数组可以包含多个依赖项。仅当您指定的所有依赖项的值与之前渲染期间的值完全相同时,React 才会跳过重新运行 Effect。

React 使用[Object.is](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is)比较来比较依赖项值。Object.is() 静态方法确定两个值是否相同Object.is()不等同于[==](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Equality)运算符,Object.is()不会强制转换任一值。Object.is()也不等同于[===](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Strict_equality)运算符。 Object.is()===之间的唯一区别在于它们对有符号零和NaN值的处理。 ===运算符(和==运算符)将数值-0+0视为相等,但将[NaN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NaN)视为彼此不相等。如果满足以下条件之一,则两个值相同:

  • 两者均[undefined](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/undefined)
  • 都为[null](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/null)
  • 均为true或均为false
  • 两个字符串具有相同的长度、相同的字符且顺序相同
  • 都是同一个对象(意味着两个值都引用内存中的同一个对象)
  • 两个BigInt具有相同的数值
  • 两个Symbol引用相同的symbol值
  • 两者都+0
  • 均为-0
  • 均为[NaN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NaN)
  • 或者两者都非零,不是[NaN](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/NaN) ,并且具有相同的值
 useEffect(() => {
    if (isPlaying) { // It's used here...
      // ...
    } else {
      // ...
    }
  }, [isPlaying]); // ...so it must be declared here!

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

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和set函数

此 Effect同时使用refisPlaying ,但仅将isPlaying声明为依赖项:

这是因为ref对象具有稳定的标识: React 保证您在每次渲染时始终从相同的useRef调用中获得相同的对象。它永远不会改变,因此它本身永远不会导致 Effect 重新运行。

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

useState返回的[set](https://react.dev/reference/react/useState#setstate)函数也具有稳定的标识,因此您经常会看到它们从依赖项中被省略。如果 linter 允许您省略依赖项而不会出现错误,那么这样做是安全的。

仅当 linter 可以“看到”对象稳定时,忽略始终稳定的依赖关系才有效。例如,如果ref是从父组件传递的,则必须在依赖项数组中指定它。但是,这很好,因为您无法知道父组件是否始终传递相同的引用,或者有条件地传递多个引用之一。

如果需要的话添加清理。

某些Effect需要指定如何停止、撤消或清理它们正在执行的操作。例如,“连接”需要“断开”,“订阅”需要“取消订阅”,“获取”需要“取消”或“忽略”。清理函数应该停止或撤消 Effect 正在执行的任何操作。

Effect 内的代码不使用任何 props 或 state,因此您的依赖项数组为[] (空)。这告诉 React 仅在组件“mount”时(即第一次出现在屏幕上)时运行此代码。

想象一下, ChatRoom组件是一个具有许多不同屏幕的大型应用程序的一部分。用户在ChatRoom页面上开始他们的旅程。该组件安装并调用connection.connect() 。然后想象用户导航到另一个屏幕,例如“设置”页面。 ChatRoom组件卸载。最后,用户单击“后退”, ChatRoom再次安装。这将建立第二个连接,但第一个连接永远不会被破坏!当用户在应用程序中导航时,连接会不断堆积。

要解决此问题,请从 Effect 返回一个清理函数:每次在 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>;
}
useEffect(() => {
    const connection = createConnection();
    connection.connect();
    return () => {
      connection.disconnect();
    };
  }, []);

How to handle the Effect firing twice in development?

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

Don’t use refs to prevent Effects from firing

不要使用 refs 来阻止 Effects 触发

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

// 这使得您在开发过程中只能看到"✅ Connecting..."一次,但它并不能修复错误。
// 当用户导航离开时,连接仍然没有关闭,当用户返回时,会创建一个新连接。当用户在应用程序中导航时,连接会不断堆积,就像“修复”之前一样。

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

Controlling non-React widgets

控制非 React 小部件

有时您需要添加不是用 React 编写的 UI 小部件。例如,假设您要向页面添加地图组件。它有一个setZoomLevel()方法,您希望使zoom level与 React 代码中的zoomLevel状态变量保持同步。您的效果将类似于:

// 请注意,在这种情况下不需要清理。因为使用相同的值调用setZoomLevel两次不会执行任何操作
useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

某些 API 可能不允许您连续调用它们两次。例如,内置[<dialog>](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement)元素的[showModal](https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/showModal)方法如果调用两次就会抛出异常。实现清理功能并使其关闭对话框:

Subscribing to events

订阅活动

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

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

Triggering animations

触发动画

如果你的 Effect 动画化了一些东西,清理函数应该将动画重置为初始值.

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // Trigger the animation
  return () => {
    node.style.opacity = 0; // Reset to the initial value
  };
}, []);

如果您使用支持tweening(指的是在两个关键帧之间生成平滑过渡的中间帧的过程)的第三方动画库,则清理函数应将时间轴重置为其初始状态。

Fetching data

获取数据

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

您无法“撤消”已经发生的网络请求,但是您的清理功能应该确保不再相关的提取不会继续影响您的应用程序。如果userId'Alice'更改为'Bob' ,清理操作会确保忽略'Alice'响应,即使它在'Bob'之后到达。

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

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

  startFetching();

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

What are good alternatives to data fetching in Effects

在 Effects 中获取数据有哪些好的替代方案?

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

  • Effects don’t run on the server. 客户端计算机必须下载所有 JavaScript 并呈现您的应用程序,然后才发现它现在需要加载数据。
  • 直接在 Effects 中获取可以轻松创建“网络瀑布”。 您渲染父组件,它获取一些数据,渲染子组件,然后它们开始获取数据。如果网络不是很快,这比并行获取所有数据要慢得多。
  • 直接在 Effects 中获取通常意味着您无需预加载或缓存数据。 例如,如果组件卸载然后再次安装,则必须再次获取数据。

这个缺点列表并不是 React 所特有的。它适用于在任何库挂载时获取数据。与路由一样,数据获取要做好并不简单,因此我们建议采用以下方法:

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

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

Sending analytics

发送分析

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

由于在生产中,不会有重复的访问日志。 从实际角度来看, logVisit不应该在开发中执行多次,因为您不希望来自开发计算机的日志影响生产指标。要调试您发送的分析事件,您可以将应用程序部署到暂存环境(在生产模式下运行)或暂时选择退出严格模式。为了进行更精确的分析,交叉观察器可以帮助跟踪哪些组件位于视口中以及它们保持可见的时间。

Not an Effect: Initializing the application

没有效果:初始化应用程序

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

这保证了此类逻辑仅在浏览器加载页面后运行一次。

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

function App() {
  // ...
}

Not an Effect: Buying a product

不是效果:购买产品

有时,即使您编写了清理函数,也无法阻止运行 Effect 两次而导致用户可见的后果。例如,也许您的 Effect 发送了一个 POST 请求,例如购买产品:

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

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

nt handler: 购买不是渲染引起的;它是由特定的相互作用引起的。它应该仅在用户按下按钮时运行。删除 Effect 并将您的/api/buy请求移至 Buy 按钮事件处理程序中:

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

Putting it all together

把它们放在一起

React 总是在下一个渲染的 Effect 之前清理上一个渲染的 Effect。

换句话说,每个渲染的Effect是相互隔离的。如果您好奇这是如何工作的,您可以阅读有关闭包的内容。

image.png

image.png

Each render has its own Effects

您可以将useEffect视为将一个行为“附加”到渲染输出。

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

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

在初始渲染中,React 运行此 Effect,它连接到'general'聊天室。

使用相同的依赖项重新渲染,JSX 输出是相同的,React 看到渲染输出没有改变,所以它不会更新 DOM。React 将第二个渲染中的['general'] '] 与第一个渲染中的['general']进行比较。因为所有依赖项都是相同的,所以 React会忽略第二次渲染的 Effect。 它永远不会被调用。

使用不同的依赖项重新渲染,这次,该组件返回不同的 JSX,React 更新 DOM。React 将第三个渲染中的['travel']与第二个渲染中的['general']进行比较。一种依赖是不同的: Object.is('travel', 'general')false 。效果无法被跳过。在 React 可以应用第三个渲染的 Effect 之前,它需要清理最后运行的 Effect。 第二个渲染的 Effect 被跳过,因此 React 需要清理第一个渲染的 Effect。如果向上滚动到第一个渲染,您将看到它的清理在使用createConnection('general')创建的连接上调用disconnect() 。这会断开应用程序与'general'聊天室的连接。之后,React 运行第三个渲染的 Effect。它连接到'travel'聊天室。

假设用户导航离开,并且组件卸载。 React 运行最后一个 Effect 的清理函数。最后一个效果来自第三次渲染。第三个渲染的清理破坏了createConnection('travel')连接。因此应用程序会与'travel'房间断开连接。

严格模式打开时,React 在挂载后重新挂载每个组件一次(状态和 DOM 被保留)。这可以帮助您找到需要清理的效果并尽早暴露竞争条件等错误。此外,每当您在开发中保存文件时,React 都会重新remount Effects。这两种行为都仅限于开发。

参考链接

关于作者

作者:Wandra

内容:算法 | 趋势 |源码|Vue | React | CSS | Typescript | Webpack | Vite | GithubAction | GraphQL | Uniqpp。

专栏:欢迎关注呀🌹