与 Effect 同步

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

你将学习

  • 什么是 Effect
  • Effect 与事件有何不同
  • 如何在组件中声明 Effect
  • 如何跳过不必要地重新运行 Effect
  • 为什么 Effect 在开发过程中会运行两次以及如何修复它们

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

在学习 Effect 之前,你需要熟悉 React 组件内部的两种逻辑类型

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

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

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

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

注意

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

你可能不需要 Effect

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

如何编写 Effect

要编写 Effect,请遵循以下三个步骤

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

让我们详细了解一下这些步骤。

步骤1:声明一个Effect

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

import { useEffect } from 'react';

然后,在组件的顶层调用它,并将一些代码放在你的Effect内部。

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

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

让我们看看如何使用Effect与外部系统同步。考虑一个<VideoPlayer> React组件。通过向它传递一个isPlaying prop来控制它是否正在播放或暂停会很好。

<VideoPlayer isPlaying={isPlaying} />;

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

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

但是,浏览器<video>标签没有isPlaying prop。唯一可以控制它的方法是手动调用DOM元素上的play()pause()方法。你需要将isPlaying prop的值(指示视频应该当前播放)与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还不存在!还没有可以调用play()pause()的DOM节点,因为在返回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中并具有正确的props。然后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()可能会失败,用户可能会使用内置的浏览器控件进行播放或暂停,等等。这个例子非常简化且不完整。

陷阱

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

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

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

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

步骤2:指定Effect依赖项

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

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

为了演示这个问题,这里有一个之前的示例,其中添加了一些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 has a missing dependency: 'isPlaying'

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

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

  useEffect(() => {
    if (isPlaying) {
      console.log('Calling video.play()');
      ref.current.play();
    } else {
      console.log('Calling video.pause()');
      ref.current.pause();
    }
  }, []); // This causes an error

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

export default function App() {
  const [isPlaying, setIsPlaying] = useState(false);
  const [text, setText] = useState('');
  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={() => setIsPlaying(!isPlaying)}>
        {isPlaying ? 'Pause' : 'Play'}
      </button>
      <VideoPlayer
        isPlaying={isPlaying}
        src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
      />
    </>
  );
}

问题在于,Effect 内部的代码依赖于isPlaying 属性来决定做什么,但此依赖项未明确声明。要解决此问题,请将isPlaying添加到依赖项数组中。

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

请注意,您不能“选择”您的依赖项。如果根据 Effect 内部的代码,您指定的依赖项与 React 预期不符,则会收到 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 使用两者ref和isPlaying,但只有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,它返回一个具有connect()disconnect()方法的对象。您如何在其显示给用户时保持组件连接?

首先编写 Effect 逻辑。

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

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

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

Effect 内部的代码不使用任何 props 或状态,因此您的依赖项数组为[](空)。这告诉 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 仅在挂载时运行,因此您可能期望"✅ Connecting..."在控制台中打印一次。但是,如果您检查控制台,"✅ Connecting..."会打印两次。为什么会这样?

假设`ChatRoom`组件是包含许多不同屏幕的较大应用程序的一部分。用户从`ChatRoom`页面开始他们的旅程。组件挂载并调用`connection.connect()。然后想象一下用户导航到另一个屏幕——例如,设置页面。`ChatRoom`组件卸载。最后,用户点击“返回”,`ChatRoom`组件再次挂载。这将建立第二个连接——但第一个连接从未被销毁!当用户在应用程序中导航时,连接会不断累积。

如果没有大量的手动测试,很容易错过这样的错误。为了帮助您快速发现它们,在开发过程中,React 会在其初始挂载后立即重新挂载每个组件。

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

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

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

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

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. "✅ Connecting..."
  2. "❌ Disconnected."
  3. "✅ Connecting..."

这是开发环境中的正确行为。通过重新挂载您的组件,React 验证了导航离开并返回不会破坏您的代码。断开连接然后重新连接正是应该发生的事情!当您正确实现清理函数时,Effect 运行一次与运行它、清理它然后再次运行它之间应该没有任何用户可见的差异。存在额外的连接/断开连接调用对是因为 React 正在探测您的代码在开发环境中的错误。这是正常的——不要试图让它消失!

在生产环境中,您只会看到一次`"✅ Connecting..."`输出。重新挂载组件只发生在开发环境中,以帮助您找到需要清理的 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();
}
}, []);

这使得您在开发中只会看到一次`"✅ Connecting..."`,但这并没有修复错误。

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

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

请参阅下面的示例,了解如何处理常见模式。

控制非 React 组件

有时您需要添加未在 React 中编写的 UI 组件。例如,假设您正在向页面添加地图组件。它有一个`setZoomLevel()`方法,并且您希望将缩放级别与 React 代码中的`zoomLevel`状态变量保持同步。您的 Effect 将类似于此。

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

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

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

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

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

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

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

非 Effect:初始化应用程序

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

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

function App() {
// ...
}

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

非 Effect:购买产品

有时,即使您编写了清理函数,也无法阻止两次运行 Effect 造成对用户可见的影响。例如,您的 Effect 可能会发送 POST 请求,例如购买产品。

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

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

购买并非由渲染引起;而是由特定交互引起的。它应该只在用户按下按钮时运行。删除 Effect 并将您的 /api/buy 请求移到“购买”按钮的事件处理程序中:

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

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

综合起来

此游乐场可以帮助您“了解”Effect 在实践中的工作方式。

此示例使用 setTimeout 安排一个控制台日志,其中包含输入文本,该文本将在 Effect 运行三秒钟后显示。清理函数会取消挂起的超时。首先按下“挂载组件”。

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 的日志。正如您之前了解到的,额外的 schedule/cancel 对是因为 React 在开发中重新挂载组件一次,以验证您是否已很好地实现了清理。

现在将输入编辑为 abc。如果您操作足够快,您将看到 Schedule "ab" log 立即之后是 Cancel "ab" logSchedule "abc" logReact 始终会在下一次渲染的 Effect 之前清理上一次渲染的 Effect。这就是为什么即使您快速输入到输入框中,一次最多也只有一个超时被安排。多次编辑输入并观察控制台,以了解 Effect 如何被清理。

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

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

三秒钟后,您应该会看到一系列日志(aababcabcdabcde),而不是五个 abcde 日志。每个 Effect 都“捕获”其相应渲染中的 text 值。text 状态发生变化并不重要:具有 text = 'ab' 的渲染的 Effect 将始终看到 'ab'。换句话说,每次渲染的 Effect 彼此隔离。如果您好奇这是如何工作的,您可以阅读有关 闭包 的内容。

深入探讨

每次渲染都有其自己的 Effect

您可以将 useEffect 视为将一段行为“附加”到渲染输出。考虑一下这个 Effect。

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 也是渲染输出的一部分。第一次渲染的 Effect 变成:

// Effect for the first render (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the first render (roomId = "general")
['general']

React 运行这个 Effect,它连接到'general'聊天室。

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

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

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

React 发现渲染输出没有变化,因此它不会更新 DOM。

第二次渲染的 Effect 看起来像这样:

// Effect for the second render (roomId = "general")
() => {
const connection = createConnection('general');
connection.connect();
return () => connection.disconnect();
},
// Dependencies for the second render (roomId = "general")
['general']

React 将第二次渲染的['general']与第一次渲染的['general']进行比较。因为所有依赖项都相同,所以 React 忽略了第二次渲染的 Effect。它从未被调用。

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

然后,用户访问了<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 看起来像这样:

// 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。如果你向上滚动到第一次渲染,你会看到它的清理调用了disconnect(),用于通过createConnection('general')创建的连接。这会断开应用程序与'general'聊天室的连接。

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

卸载

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

仅限开发环境的行为

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

总结

  • 与事件不同,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 应该只在_挂载_时获得焦点,而不是每次渲染后都获得焦点。要验证行为是否正确,请点击“显示表单”,然后重复点击“转换为大写”复选框。点击复选框_不应_使上面的输入框获得焦点。