你可能不需要 Effect

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

你将学习

  • 为什么以及如何从组件中移除不必要的 Effects
  • 如何在没有 Effects 的情况下缓存昂贵的计算
  • 如何在没有 Effects 的情况下重置和调整组件状态
  • 如何在事件处理程序之间共享逻辑
  • 哪些逻辑应该移动到事件处理程序
  • 如何通知父组件更改

如何移除不必要的 Effects

在两种常见情况下,你不需要 Effects

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

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

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

基于 props 或状态更新状态

假设你有一个组件,它有两个状态变量:firstNamelastName。你想通过连接它们来计算一个fullName。此外,你希望fullNamefirstNamelastName更改时更新。你的第一直觉可能是添加一个fullName状态变量,并在 Effect 中更新它。

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 更改时重置所有状态

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 将两个具有不同 userIdProfile 组件视为两个不同的组件,它们不应该共享任何状态。 每当 key(你已将其设置为 userId)发生变化时,React 都会重新创建 DOM 并 重置 Profile 组件及其所有子组件的状态。现在,在不同用户资料之间导航时,comment 字段会自动清除。

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

调整 prop 更改时的某些状态

有时,你可能想要在 prop 更改时重置或调整状态的一部分,但不是全部。

这个 List 组件接收一个 items 列表作为 prop,并在 selection 状态变量中维护选定的项。你希望在 items prop 接收不同的数组时将 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 更高效,但大多数组件也不需要它。 无论你如何操作,基于 prop 或其他状态调整状态都会使你的数据流更难理解和调试。始终检查你是否可以 使用 key 重置所有状态在渲染过程中计算所有内容。例如,与其存储(和重置)选定的,不如存储选定的项 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');
}
// ...
}

这个 Effect 是不必要的。它也很可能导致错误。例如,假设你的应用程序在页面重新加载之间“记住”购物车。如果你添加一个产品到购物车一次并刷新页面,通知将再次出现。每次刷新该产品的页面时,它都会继续出现。这是因为在页面加载时 product.isInCart 已经为 true,因此上面的 Effect 将调用 showNotification()

如果你不确定某些代码应该放在 Effect 中还是事件处理程序中,问问自己为什么需要运行这段代码。仅对应该运行的代码使用 Effects,因为组件已显示给用户。在此示例中,通知应该出现是因为用户按下了按钮,而不是因为页面已显示!删除 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。

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` 组件,该状态可以是 `true` 或 `false`。有几种不同的方法可以切换它(通过点击或拖动)。您希望在 `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 或拥有错误的状态。当子组件在 Effects 中更新其父组件的状态时,数据流将变得非常难以追踪。由于子组件和父组件都需要相同的数据,因此让父组件获取数据,并将其向下传递给子组件。

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。每当浏览器中该数据存储的值发生变化时,组件都会更新其状态。

尽管通常使用 Effects 来实现此目的,但 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 状态更不容易出错。通常,你会编写一个自定义 Hook,例如上面的 useOnlineStatus(),这样你就不需要在各个组件中重复这段代码了。 阅读更多关于从 React 组件订阅外部存储的内容。

获取数据

许多应用程序使用 Effects 来启动数据获取。编写这样的数据获取 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);
}
// ...
}

不需要将此 fetch 移动到事件处理程序中。

这似乎与之前的示例相矛盾,在之前的示例中,你需要将逻辑放入事件处理程序!但是,请考虑一下,这并不是键入事件是 fetch 的主要原因。搜索输入通常会从 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。解决这些问题并非易事,这就是为什么现代框架 提供比在 Effects 中获取数据更有效的内置数据获取机制。

如果你不使用框架(并且不想构建自己的框架),但希望使 Effects 中的数据获取更符合人体工程学,请考虑将你的获取逻辑提取到自定义 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 中将使以后更容易采用高效的数据获取策略。

一般来说,无论何时你不得不求助于编写 Effects,都要注意何时可以将一部分功能提取到一个自定义 Hook 中,该 Hook 具有更声明式和更专用 API,例如上面 useData。你的组件中 `useEffect` 调用越少,维护应用程序就越容易。

回顾

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

挑战 1 4:
无需 Effects 转换数据

下面的 TodoList 显示待办事项列表。“仅显示活动待办事项”复选框被选中时,列表中不显示已完成的待办事项。无论哪些待办事项可见,页脚都显示尚未完成的待办事项数量。

通过删除所有不必要的状态和 Effects 来简化此组件。

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