您可能不需要 Effect

Effect 是 React 范式中的一个应急出口。它们允许您“跳出”React 并将您的组件与某些外部系统同步,例如非 React 小部件、网络或浏览器 DOM。如果不涉及外部系统(例如,如果要在某些 props 或状态更改时更新组件的状态),则不需要 Effect。删除不必要的 Effect 将使您的代码更容易理解、运行速度更快且更不容易出错。

您将学习

  • 为什么要以及如何从组件中移除不必要的 Effect
  • 如何在不使用 Effect 的情况下缓存开销计算
  • 如何在不使用 Effect 的情况下重置和调整组件状态
  • 如何在事件处理程序之间共享逻辑
  • 哪些逻辑应该移至事件处理程序
  • 如何将更改通知父组件

如何移除不必要的 Effect

在以下两种常见情况下,您不需要 Effect

  • 您不需要使用 Effect 来转换数据以进行渲染。 例如,假设您想在显示列表之前对其进行过滤。您可能会尝试编写一个 Effect,在列表更改时更新状态变量。然而,这是低效的。当您更新状态时,React 将首先调用您的组件函数来计算屏幕上应该显示的内容。然后 React 会将这些更改“提交”到 DOM,从而更新屏幕。然后 React 会运行您的 Effect。如果您的 Effect *也* 立即更新状态,这将从头开始重新启动整个过程!为了避免不必要的渲染过程,请在组件的顶层转换所有数据。每当您的 props 或状态更改时,该代码都会自动重新运行。
  • 您不需要使用 Effect 来处理用户事件。 例如,假设您想在用户购买产品时发送 /api/buy POST 请求并显示通知。在“购买”按钮的点击事件处理程序中,您确切地知道发生了什么。等到 Effect 运行时,您就不知道用户做了*什么*(例如,点击了哪个按钮)。这就是为什么您通常会在相应的事件处理程序中处理用户事件。

您*确实*需要使用 Effect 来与外部系统同步。例如,您可以编写一个 Effect,使 jQuery 小部件与 React 状态保持同步。您还可以使用 Effect 获取数据:例如,您可以将搜索结果与当前搜索查询同步。请记住,现代框架提供了比直接在组件中编写 Effect 更有效的数据获取机制。

为了帮助您获得正确的直觉,让我们看一些常见的具体例子!

根据 props 或状态更新状态

假设您有一个组件,它有两个状态变量:firstNamelastName。您想通过连接它们来计算一个 fullName。此外,您希望每当 firstNamelastName 发生变化时,fullName 都会更新。您的第一反应可能是在 Effect 中添加一个 fullName 状态变量并更新它

function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');

// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}

这比必要的更复杂。它也是低效的:它使用 fullName 的过时值进行完整的渲染过程,然后立即使用更新后的值重新渲染。移除状态变量和 Effect

function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ...
}

如果某些内容可以从现有的 props 或 state 计算得出,请勿将其放入 state。 而是在渲染期间计算它。这会使您的代码更快(避免额外的“级联”更新)、更简单(删除一些代码)并且更容易维护(避免由不同的状态变量彼此不同步引起的错误)。 如果您不熟悉这种方法,React 思维模式 解释了应该放入 state 的内容。

缓存开销大的计算

此组件通过获取通过 props 接收的 todos 并根据 filter prop 对其进行过滤来计算 visibleTodos。您可能会想将结果存储在 state 中并从 Effect 更新它

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');

// 🔴 Avoid: redundant state and unnecessary Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);

// ...
}

与前面的示例一样,这既不必要也不高效。 首先,删除 state 和 Effect

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ This is fine if getFilteredTodos() is not slow.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}

通常,此代码很好! 但是也许 getFilteredTodos() 很慢,或者您有很多 todos。 在这种情况下,如果一些不相关的状态变量(如 newTodo)发生了变化,您不希望重新计算 getFilteredTodos()

您可以通过将开销大的计算包装在 useMemo Hook 中来缓存(或 “记忆化”)它

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// ✅ Does not re-run unless todos or filter change
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}

或者,写成一行

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ Does not re-run getFilteredTodos() unless todos or filter change
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}

这告诉 React,除非 todosfilter 发生了变化,否则您不希望重新运行内部函数。React 会在初始渲染期间记住 getFilteredTodos() 的返回值。 在接下来的渲染过程中,它会检查 todosfilter 是否不同。 如果它们与上次相同,则 useMemo 将返回它存储的最后一个结果。 但是,如果它们不同,React 将再次调用内部函数(并存储其结果)。

您包装在 useMemo 中的函数会在渲染期间运行,因此这仅适用于 纯计算。

深入探讨

如何判断计算是否开销大?

一般来说,除非您要创建或循环数千个对象,否则开销可能并不大。 如果您想获得更多信心,可以添加一个控制台日志来测量一段代码中花费的时间

console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');

执行您正在测量的交互(例如,在输入中键入)。 然后,您将在控制台中看到类似于 filter array: 0.15ms 的日志。 如果记录的总时间加起来很大(例如,1ms 或更多),则记忆该计算可能是有意义的。 作为实验,您可以将计算包装在 useMemo 中,以验证该交互的总记录时间是否减少了

console.time('filter array');
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter); // Skipped if todos and filter haven't changed
}, [todos, filter]);
console.timeEnd('filter array');

useMemo 不会使*第一次*渲染更快。 它只帮助您跳过更新时不必要的工作。

请记住,您的机器可能比您的用户更快,因此最好通过人为降低速度来测试性能。 例如,Chrome 提供了 CPU 节流 选项来实现此目的。

另请注意,在开发中测量性能不会给您最准确的结果。 (例如,当 严格模式 打开时,您将看到每个组件渲染两次而不是一次。)要获得最准确的时间,请为生产构建您的应用程序并在类似于您用户的设备上进行测试。

prop 更改时重置所有 state

ProfilePage 组件接收一个 userId prop。 该页面包含一个评论输入,您可以使用 comment 状态变量来保存其值。 有一天,您注意到一个问题:当您从一个配置文件导航到另一个配置文件时,comment 状态不会重置。 因此,很容易不小心在错误用户的个人资料上发表评论。 要解决此问题,您希望在 userId 更改时清除 comment 状态变量

export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');

// 🔴 Avoid: Resetting state on prop change in an Effect
useEffect(() => {
setComment('');
}, [userId]);
// ...
}

这效率很低,因为 ProfilePage 及其子组件将首先使用过时的值进行渲染,然后再次渲染。 这也很复杂,因为您需要在 ProfilePage 内具有某些状态的*每个*组件中执行此操作。 例如,如果评论 UI 是嵌套的,您也需要清除嵌套评论状态。

相反,您可以通过为每个用户的配置文件提供一个显式的键来告诉 React 每个用户的配置文件在概念上是*不同的*配置文件。 将您的组件分成两部分,并将 key 属性从外部组件传递到内部组件

export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}

function Profile({ userId }) {
// ✅ This and any other state below will reset on key change automatically
const [comment, setComment] = useState('');
// ...
}

通常,当同一个组件渲染在同一个位置时,React 会保留其状态。通过将 userId 作为 key 传递给 Profile 组件,您是在要求 React 将具有不同 userId 的两个 Profile 组件视为两个不应共享任何状态的不同组件。每当键(您已将其设置为 userId)发生变化时,React 都会重新创建 DOM 并重置状态 Profile 组件及其所有子组件。现在,在不同配置文件之间导航时,comment 字段将自动清除。

请注意,在此示例中,只有外部 ProfilePage 组件被导出并对项目中的其他文件可见。渲染 ProfilePage 的组件不需要将键传递给它:它们将 userId 作为常规属性传递。 ProfilePage 将其作为 key 传递给内部 Profile 组件是一个实现细节。

当属性更改时调整某些状态

有时,您可能希望在属性更改时重置或调整状态的一部分,而不是全部。

List 组件接收一个 items 列表作为属性,并在 selection 状态变量中维护所选项目。每当 items 属性接收到不同的数组时,您都希望将 selection 重置为 null

function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);

// 🔴 Avoid: Adjusting state on prop change in an Effect
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}

这也不理想。每次 items 更改时,List 及其子组件将首先使用过时的 selection 值进行渲染。然后 React 将更新 DOM 并运行 Effects。最后,setSelection(null) 调用将导致 List 及其子组件再次重新渲染,重新开始整个过程。

首先删除 Effect。相反,在渲染过程中直接调整状态

function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);

// Better: Adjust the state while rendering
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}

像这样存储来自先前渲染的信息可能难以理解,但它比在 Effect 中更新相同的状态要好。在上面的例子中,setSelection 是在渲染过程中直接调用的。React 将在以 return 语句退出后立即重新渲染 List。React 还没有渲染 List 子级或更新 DOM,因此这可以让 List 子级跳过渲染过时的 selection 值。

当您在渲染期间更新组件时,React 会丢弃返回的 JSX 并立即重试渲染。为了避免非常缓慢的级联重试,React 只允许您在渲染期间更新*相同*组件的状态。如果您在渲染期间更新另一个组件的状态,您将看到一个错误。像 items !== prevItems 这样的条件对于避免循环是必要的。您可以像这样调整状态,但任何其他副作用(如更改 DOM 或设置超时)应保留在事件处理程序或 Effects 中,以保持组件的纯净性。

尽管此模式比 Effect 更有效,但大多数组件也不应该需要它。无论您如何操作,根据属性或其他状态调整状态都会使您的数据流更难理解和调试。始终检查您是否可以使用键重置所有状态在渲染期间计算所有内容。例如,您可以存储所选*项目 ID*,而不是存储(和重置)所选*项目*:

function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ Best: Calculate everything during rendering
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}

现在根本不需要“调整”状态。如果具有所选 ID 的项目在列表中,则它将保持选中状态。如果不存在,则在渲染期间计算的 selection 将为 null,因为找不到匹配的项目。此行为不同,但可以说更好,因为对 items 的大多数更改都会保留选择。

在事件处理程序之间共享逻辑

假设您有一个产品页面,其中有两个按钮(购买和结帐),都允许您购买该产品。每当用户将产品放入购物车时,您都希望显示通知。在两个按钮的点击处理程序中调用 showNotification() 会感觉重复,因此您可能会尝试将此逻辑放在 Effect 中

function ProductPage({ product, addToCart }) {
// 🔴 Avoid: Event-specific logic inside an Effect
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);

function handleBuyClick() {
addToCart(product);
}

function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}

这种效果是不必要的,而且很可能会导致错误。例如,假设你的应用程序在页面重新加载时“记住”了购物车。如果你向购物车中添加了一件商品,然后刷新页面,通知会再次出现。每次你刷新该商品的页面时,它都会不断出现。这是因为在页面加载时,product.isInCart 已经是 true,所以上面的 Effect 会调用 showNotification()

当你不能确定某些代码应该放在 Effect 中还是事件处理程序中时,问问自己这段代码为什么需要运行。只对因为组件被显示给用户而应该运行的代码使用 Effect。在这个例子中,通知应该出现,因为用户点击了按钮,而不是因为页面被显示!删除 Effect,并将共享逻辑放入一个从两个事件处理程序调用的函数中。

function ProductPage({ product, addToCart }) {
// ✅ Good: Event-specific logic is called from event handlers
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}

function handleBuyClick() {
buyProduct();
}

function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}

这既删除了不必要的 Effect,又修复了错误。

发送 POST 请求

Form 组件发送两种 POST 请求。它在挂载时发送一个分析事件。当你填写表单并单击提交按钮时,它会向 /api/register 端点发送一个 POST 请求。

function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

// ✅ Good: This logic should run because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);

// 🔴 Avoid: Event-specific logic inside an Effect
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);

function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}

让我们应用与前一个示例相同的标准。

分析 POST 请求应该保留在 Effect 中。这是因为发送分析事件的原因是表单被显示了。(在开发中它会触发两次,但请参阅此处了解如何处理这种情况。)

然而,/api/register POST 请求不是由表单被显示引起的。你只想在一个特定的时刻发送请求:当用户按下按钮时。它应该只在那个特定的交互时发生。删除第二个 Effect,并将该 POST 请求移动到事件处理程序中。

function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');

// ✅ Good: This logic runs because the component was displayed
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);

function handleSubmit(e) {
e.preventDefault();
// ✅ Good: Event-specific logic is in the event handler
post('/api/register', { firstName, lastName });
}
// ...
}

当你选择是将一些逻辑放入事件处理程序还是 Effect 中时,你需要回答的主要问题是,从用户的角度来看,它是什么类型的逻辑。如果此逻辑是由特定交互引起的,请将其保留在事件处理程序中。如果它是由用户看到屏幕上的组件引起的,请将其保留在 Effect 中。

计算链

有时,你可能会想链接 Effect,每个 Effect 都根据其他状态调整一部分状态。

function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);

// 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);

useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);

useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);

useEffect(() => {
alert('Good game!');
}, [isGameOver]);

function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}

// ...

这段代码有两个问题。

一个问题是它效率非常低:组件(及其子组件)必须在链中的每个 set 调用之间重新渲染。在上面的例子中,在最坏的情况下(setCard → 渲染 → setGoldCardCount → 渲染 → setRound → 渲染 → setIsGameOver → 渲染),下面树的重新渲染有三次是不必要的。

即使它不慢,随着代码的演变,你也会遇到你编写的“链”不符合新需求的情况。想象一下,你正在添加一种方法来逐步查看游戏移动的历史记录。你可以通过将每个状态变量更新为过去的值来实现。然而,将 card 状态设置为过去的值会再次触发 Effect 链,并更改你正在显示的数据。这样的代码通常是僵化和脆弱的。

在这种情况下,最好在渲染过程中计算你能计算的内容,并在事件处理程序中调整状态。

function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);

// ✅ Calculate what you can during rendering
const isGameOver = round > 5;

function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}

// ✅ Calculate all the next state in the event handler
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}

// ...

这效率要高得多。此外,如果你实现了一种查看游戏历史记录的方法,现在你将能够将每个状态变量设置为过去的移动,而不会触发调整其他所有值的 Effect 链。如果你需要在多个事件处理程序之间重用逻辑,你可以提取一个函数,并从这些处理程序中调用它。

请记住,在事件处理程序内部,状态的行为就像一个快照。例如,即使在你调用了 setRound(round + 1) 之后,round 变量也会反映用户点击按钮时的值。如果你需要使用下一个值进行计算,请像 const nextRound = round + 1 这样手动定义它。

在某些情况下,你无法直接在事件处理程序中计算下一个状态。例如,想象一个有多个下拉菜单的表单,其中下一个下拉菜单的选项取决于前一个下拉菜单的选定值。然后,Effect 链是合适的,因为你正在与网络同步。

初始化应用程序

有些逻辑只应在应用程序加载时运行一次。

你可能会想把它放在顶层组件的 Effect 中。

function App() {
// 🔴 Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}

然而,你很快就会发现它在开发中运行了两次。这可能会导致问题——例如,它可能会使身份验证令牌失效,因为该函数的设计初衷并不是被调用两次。一般来说,你的组件应该能够承受重新挂载。这包括你的顶层 App 组件。

虽然它在生产环境中实际上可能永远不会被重新挂载,但在所有组件中遵循相同的约束,可以更容易地移动和重用代码。如果某些逻辑必须在每次应用程序加载时运行一次,而不是在每次组件挂载时运行一次,请添加一个顶层变量来跟踪它是否已经执行。

let didInit = false;

function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Only runs once per app load
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}

你也可以在模块初始化期间和应用程序渲染之前运行它。

if (typeof window !== 'undefined') { // Check if we're running in the browser.
// ✅ Only runs once per app load
checkAuthToken();
loadDataFromLocalStorage();
}

function App() {
// ...
}

顶层的代码在你的组件被导入时运行一次——即使它最终没有被渲染。为了避免在导入任意组件时速度变慢或出现意外行为,不要过度使用这种模式。将应用程序范围内的初始化逻辑保留在根组件模块(如 App.js)中,或保留在应用程序的入口点。

通知父组件状态变化

假设您正在编写一个内部带有 isOn 状态的 Toggle 组件,该状态可以是 truefalse。 有几种不同的方法可以切换它(通过单击或拖动)。 您希望在 Toggle 内部状态发生变化时通知父组件,因此您公开了一个 onChange 事件并在 Effect 中调用它

function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);

// 🔴 Avoid: The onChange handler runs too late
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])

function handleClick() {
setIsOn(!isOn);
}

function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}

// ...
}

与之前一样,这不是理想的。 Toggle 首先更新其状态,然后 React 更新屏幕。 然后 React 运行 Effect,它调用从父组件传递的 onChange 函数。 现在父组件将更新自己的状态,开始另一个渲染过程。 最好在一次渲染过程中完成所有操作。

删除 Effect,而是更新两个组件在同一个事件处理程序中的状态

function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);

function updateToggle(nextIsOn) {
// ✅ Good: Perform all updates during the event that caused them
setIsOn(nextIsOn);
onChange(nextIsOn);
}

function handleClick() {
updateToggle(!isOn);
}

function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}

// ...
}

使用这种方法,Toggle 组件及其父组件都会在事件期间更新其状态。 React 批量更新 来自不同组件的更新,因此只有一次渲染过程。

您可能还可以完全删除状态,而是从父组件接收 isOn

// ✅ Also good: the component is fully controlled by its parent
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}

function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}

// ...
}

“提升状态” 让父组件通过切换自身的状态来完全控制 Toggle。 这意味着父组件将包含更多逻辑,但总体而言需要担心的状态更少。 每当您尝试同步两个不同的状态变量时,请尝试提升状态!

将数据传递给父级

Child 组件获取一些数据,然后在 Effect 中将其传递给 Parent 组件

function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 Avoid: Passing data to the parent in an Effect
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}

在 React 中,数据从父组件流向其子组件。 当您在屏幕上看到错误内容时,可以通过沿着组件链向上追溯信息来源,直到找到传递了错误 prop 或具有错误状态的组件。 当子组件在其父组件的 Effect 中更新状态时,数据流将变得非常难以追溯。 由于子组件和父组件都需要相同的数据,因此让父组件获取该数据,并将其向下传递给子组件

function Parent() {
const data = useSomeAPI();
// ...
// ✅ Good: Passing data down to the child
return <Child data={data} />;
}

function Child({ data }) {
// ...
}

这更简单,并保持数据流可预测:数据从父组件向下流动到子组件。

订阅外部存储

有时,您的组件可能需要订阅 React 状态之外的某些数据。 这些数据可能来自第三方库或内置浏览器 API。 由于这些数据可能会在 React 不知情的情况下发生变化,因此您需要手动订阅您的组件。 这通常是通过 Effect 完成的,例如

function useOnlineStatus() {
// Not ideal: Manual store subscription in an Effect
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}

updateState();

window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

在这里,组件订阅了一个外部数据存储(在本例中为浏览器 navigator.onLine API)。 由于此 API 在服务器上不存在(因此不能用于初始 HTML),因此最初状态设置为 true。 每当浏览器中该数据存储的值发生变化时,组件都会更新其状态。

虽然为此使用 Effect 很常见,但 React 有一个专门用于订阅外部存储的 Hook,建议使用。 删除 Effect 并将其替换为对 useSyncExternalStore 的调用

function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}

function useOnlineStatus() {
// ✅ Good: Subscribing to an external store with a built-in Hook
return useSyncExternalStore(
subscribe, // React won't resubscribe for as long as you pass the same function
() => navigator.onLine, // How to get the value on the client
() => true // How to get the value on the server
);
}

function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

这种方法比使用 Effect 手动将可变数据同步到 React 状态更不容易出错。 通常,您会编写一个像上面 useOnlineStatus() 这样的自定义 Hook,这样您就不需要在各个组件中重复此代码。 详细了解从 React 组件订阅外部存储。

获取数据

许多应用程序使用 Effect 来启动数据获取。 编写像这样的数据获取 Effect 很常见

function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);

useEffect(() => {
// 🔴 Avoid: Fetching without cleanup logic
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);

function handleNextPageClick() {
setPage(page + 1);
}
// ...
}

不需要将此获取移动到事件处理程序。

这似乎与之前的示例相矛盾,在之前的示例中,您需要将逻辑放入事件处理程序中! 但是,请考虑键入事件不是获取的主要原因。 搜索输入通常从 URL 预先填充,用户可能会在不触碰输入的情况下导航后退和前进。

pagequery 来自哪里并不重要。 当此组件可见时,您希望将 results 与当前 pagequery 的网络数据保持 同步。 这就是它是 Effect 的原因。

但是,上面的代码有一个错误。 想象一下,您快速输入 "hello"。 然后 query 将从 "h" 变为 "he""hel""hell""hello"。 这将启动单独的获取,但无法保证响应的到达顺序。 例如,"hell" 响应可能在 "hello" 响应之后到达。 因为它将最后调用 setResults(),因此您将显示错误的搜索结果。 这称为 “竞争条件”:两个不同的请求相互“竞争”并以与您预期不同的顺序进入。

要修复竞争条件,您需要 添加一个清理函数 来忽略过时的响应

function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);

function handleNextPageClick() {
setPage(page + 1);
}
// ...
}

这确保了当您的 Effect 获取数据时,除了最后请求的响应之外的所有响应都将被忽略。

处理竞争条件并不是实现数据获取的唯一难点。您可能还想考虑缓存响应(以便用户可以单击“后退”并立即看到上一个屏幕)、如何在服务器上获取数据(以便初始服务器渲染的 HTML 包含获取的内容,而不是加载指示器),以及如何避免网络瀑布(以便子组件可以在不等待每个父组件的情况下获取数据)。

这些问题适用于任何 UI 库,而不仅仅是 React。解决它们并非易事,这就是为什么现代框架提供了比在 Effect 中获取数据更高效的内置数据获取机制。

如果您不使用框架(并且不想构建自己的框架),但希望使从 Effect 获取数据更加符合人体工程学,请考虑将您的获取逻辑提取到自定义 Hook 中,如本例所示

function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);

function handleNextPageClick() {
setPage(page + 1);
}
// ...
}

function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}

您可能还想添加一些逻辑来处理错误并跟踪内容是否正在加载。您可以自己构建这样的 Hook,也可以使用 React 生态系统中已提供的众多解决方案之一。 尽管这本身并不像使用框架的内置数据获取机制那样高效,但将数据获取逻辑移到自定义 Hook 中将使以后更容易采用高效的数据获取策略。

一般来说,每当您不得不求助于编写 Effect 时,请注意何时可以将一部分功能提取到自定义 Hook 中,该 Hook 具有更具声明性和专用性的 API,例如上面的 useData。组件中的原始 useEffect 调用越少,您就越容易维护应用程序。

回顾

  • 如果可以在渲染期间计算某些内容,则不需要 Effect。
  • 要缓存昂贵的计算,请添加 useMemo 而不是 useEffect
  • 要重置整个组件树的状态,请向其传递不同的 key
  • 要根据 prop 更改重置特定状态位,请在渲染期间进行设置。
  • 由于组件已_显示_而运行的代码应在 Effect 中,其余代码应在事件中。
  • 如果需要更新多个组件的状态,最好在单个事件中完成。
  • 每当您尝试同步不同组件中的状态变量时,请考虑将状态提升。
  • 您可以使用 Effect 获取数据,但需要实现清理以避免竞争条件。

挑战 1 4:
无需 Effect 即可转换数据

TodoList 下方显示了一个待办事项列表。勾选“仅显示活动待办事项”复选框后,列表中不会显示已完成的待办事项。无论哪些待办事项可见,页脚都会显示尚未完成的待办事项的数量。

通过删除所有不必要的 state 和 Effect 来简化此组件。

import { useState, useEffect } from 'react';
import { initialTodos, createTodo } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [activeTodos, setActiveTodos] = useState([]);
  const [visibleTodos, setVisibleTodos] = useState([]);
  const [footer, setFooter] = useState(null);

  useEffect(() => {
    setActiveTodos(todos.filter(todo => !todo.completed));
  }, [todos]);

  useEffect(() => {
    setVisibleTodos(showActive ? activeTodos : todos);
  }, [showActive, todos, activeTodos]);

  useEffect(() => {
    setFooter(
      <footer>
        {activeTodos.length} todos left
      </footer>
    );
  }, [activeTodos]);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
      {footer}
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}