你可能不需要副作用

副作用是 React 范式的应急方案。它们让你体验 React 并将你的组件与一些外部系统同步,比如非 React 小部件、网络或浏览器 DOM。如果不涉及外部系统(例如,如果你想在某些属性或状态更改时更新组件的状态),则不需要副作用。删除不必要的副作用将使你的代码更易于理解、运行速度更快并且更不容易出错。

¥Effects are an escape hatch from the React paradigm. They let you “step outside” of React and synchronize your components with some external system like a non-React widget, network, or the browser DOM. If there is no external system involved (for example, if you want to update a component’s state when some props or state change), you shouldn’t need an Effect. Removing unnecessary Effects will make your code easier to follow, faster to run, and less error-prone.

你将学习到

  • 为什么以及如何从你的组件中删除不必要的副作用

    ¥Why and how to remove unnecessary Effects from your components

  • 如何在没有副作用的情况下缓存昂贵的计算

    ¥How to cache expensive computations without Effects

  • 如何在没有副作用的情况下重置和调整组件状态

    ¥How to reset and adjust component state without Effects

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

    ¥How to share logic between event handlers

  • 哪些逻辑应该移至事件处理程序

    ¥Which logic should be moved to event handlers

  • 如何将更改通知父组件

    ¥How to notify parent components about changes

如何删除不必要的副作用

¥How to remove unnecessary Effects

有两种常见情况不需要副作用:

¥There are two common cases in which you don’t need Effects:

  • 你不需要副作用来转换数据以进行渲染。例如,假设你想在显示列表之前对其进行过滤。你可能很想编写一个副作用在列表更改时更新状态变量。然而,这是低效的。当你更新状态时,React 将首先调用你的组件函数来计算屏幕上应该显示什么。然后 React 会将这些更改 “提交” 到 DOM,更新屏幕。然后 React 将运行你的副作用。如果你的副作用也立即更新状态,则整个过程将从头开始!为避免不必要的渲染过程,请转换组件顶层的所有数据。只要你的属性或状态发生变化,该代码就会自动重新运行。

    ¥You don’t need Effects to transform data for rendering. For example, let’s say you want to filter a list before displaying it. You might feel tempted to write an Effect that updates a state variable when the list changes. However, this is inefficient. When you update the state, React will first call your component functions to calculate what should be on the screen. Then React will “commit” these changes to the DOM, updating the screen. Then React will run your Effects. If your Effect also immediately updates the state, this restarts the whole process from scratch! To avoid the unnecessary render passes, transform all the data at the top level of your components. That code will automatically re-run whenever your props or state change.

  • 你不需要副作用来处理用户事件。例如,假设你要发送 /api/buy POST 请求并在用户购买产品时显示通知。在购买按钮单击事件处理程序中,你确切地知道发生了什么。在副作用运行时,你不知道用户做了什么(例如,单击了哪个按钮)。这就是为什么你通常会在相应的事件处理程序中处理用户事件。

    ¥You don’t need Effects to handle user events. For example, let’s say you want to send an /api/buy POST request and show a notification when the user buys a product. In the Buy button click event handler, you know exactly what happened. By the time an Effect runs, you don’t know what the user did (for example, which button was clicked). This is why you’ll usually handle user events in the corresponding event handlers.

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

¥You do need Effects to synchronize with external systems. For example, you can write an Effect that keeps a jQuery widget synchronized with the React state. You can also fetch data with Effects: for example, you can synchronize the search results with the current search query. Keep in mind that modern frameworks provide more efficient built-in data fetching mechanisms than writing Effects directly in your components.

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

¥To help you gain the right intuition, let’s look at some common concrete examples!

根据属性或状态更新状态

¥Updating state based on props or state

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

¥Suppose you have a component with two state variables: firstName and lastName. You want to calculate a fullName from them by concatenating them. Moreover, you’d like fullName to update whenever firstName or lastName change. Your first instinct might be to add a fullName state variable and update it in an 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 的旧值执行整个渲染过程,然后立即使用更新后的值重新渲染。删除状态变量和副作用:

¥This is more complicated than necessary. It is inefficient too: it does an entire render pass with a stale value for fullName, then immediately re-renders with the updated value. Remove the state variable and the Effect:

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

当某些东西可以从现有的属性或状态中计算出来时, 不要把它放在状态。 而是,在渲染过程中计算它。这使你的代码更快(你避免了额外的 “层叠” 更新)、更简单(你删除了一些代码)和更不容易出错(你避免了由于不同状态变量彼此不同步而导致的错误)。如果你觉得这种方法很新,在 React 中思考 解释了应该进入状态的内容。

¥When something can be calculated from the existing props or state, don’t put it in state. Instead, calculate it during rendering. This makes your code faster (you avoid the extra “cascading” updates), simpler (you remove some code), and less error-prone (you avoid bugs caused by different state variables getting out of sync with each other). If this approach feels new to you, Thinking in React explains what should go into state.

缓存昂贵的计算

¥Caching expensive calculations

该组件通过获取它通过属性接收的 todos 并根据 filter 属性过滤它们来计算 visibleTodos。你可能想将结果存储在状态中并从副作用更新它:

¥This component computes visibleTodos by taking the todos it receives by props and filtering them according to the filter prop. You might feel tempted to store the result in state and update it from an 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]);

// ...
}

与前面的示例一样,这既不必要又低效。首先,删除状态和副作用:

¥Like in the earlier example, this is both unnecessary and inefficient. First, remove the state and the 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()

¥Usually, this code is fine! But maybe getFilteredTodos() is slow or you have a lot of todos. In that case you don’t want to recalculate getFilteredTodos() if some unrelated state variable like newTodo has changed.

你可以通过将昂贵的计算封装在 useMemo 钩子中来缓存(或 “记忆化”)昂贵的计算:

¥You can cache (or “memoize”) an expensive calculation by wrapping it in a 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]);
// ...
}

或者,写成一行:

¥Or, written as a single line:

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 将再次调用内部函数(并存储其结果)。

¥This tells React that you don’t want the inner function to re-run unless either todos or filter have changed. React will remember the return value of getFilteredTodos() during the initial render. During the next renders, it will check if todos or filter are different. If they’re the same as last time, useMemo will return the last result it has stored. But if they are different, React will call the inner function again (and store its result).

你封装在 useMemo 中的函数在渲染期间运行,所以这只适用于 纯计算。

¥The function you wrap in useMemo runs during rendering, so this only works for pure calculations.

深入研究

如何判断计算是否昂贵?

¥How to tell if a calculation is expensive?

一般而言,除非你要创建或遍历数千个对象,否则它可能并不昂贵。如果你想获得更多的信心,你可以添加一个控制台日志来衡量一段代码所花费的时间:

¥In general, unless you’re creating or looping over thousands of objects, it’s probably not expensive. If you want to get more confidence, you can add a console log to measure the time spent in a piece of code:

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

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

¥Perform the interaction you’re measuring (for example, typing into the input). You will then see logs like filter array: 0.15ms in your console. If the overall logged time adds up to a significant amount (say, 1ms or more), it might make sense to memoize that calculation. As an experiment, you can then wrap the calculation in useMemo to verify whether the total logged time has decreased for that interaction or not:

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 不会使第一次渲染更快。它只会帮助你跳过不必要的更新工作。

¥useMemo won’t make the first render faster. It only helps you skip unnecessary work on updates.

请记住,你的机器可能比用户的机器快,因此最好通过人为减速来测试性能。例如,Chrome 为此提供了一个 CPU 节流 选项。

¥Keep in mind that your machine is probably faster than your users’ so it’s a good idea to test the performance with an artificial slowdown. For example, Chrome offers a CPU Throttling option for this.

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

¥Also note that measuring performance in development will not give you the most accurate results. (For example, when Strict Mode is on, you will see each component render twice rather than once.) To get the most accurate timings, build your app for production and test it on a device like your users have.

当属性改变时重置所有状态

¥Resetting all state when a prop changes

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

¥This ProfilePage component receives a userId prop. The page contains a comment input, and you use a comment state variable to hold its value. One day, you notice a problem: when you navigate from one profile to another, the comment state does not get reset. As a result, it’s easy to accidentally post a comment on a wrong user’s profile. To fix the issue, you want to clear out the comment state variable whenever the userId changes:

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

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

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

¥This is inefficient because ProfilePage and its children will first render with the stale value, and then render again. It is also complicated because you’d need to do this in every component that has some state inside ProfilePage. For example, if the comment UI is nested, you’d want to clear out nested comment state too.

而是,你可以通过给 React 一个明确的键来告诉 React 每个用户的个人资料在概念上是不同的个人资料。将你的组件一分为二,并将 key 属性从外部组件传递到内部组件:

¥Instead, you can tell React that each user’s profile is conceptually a different profile by giving it an explicit key. Split your component in two and pass a key attribute from the outer component to the inner one:

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 将重新创建 Profile 组件及其所有子级的 DOM 和 重置状态。现在,在配置文件之间导航时,comment 字段将自动清除。

¥Normally, React preserves the state when the same component is rendered in the same spot. By passing userId as a key to the Profile component, you’re asking React to treat two Profile components with different userId as two different components that should not share any state. Whenever the key (which you’ve set to userId) changes, React will recreate the DOM and reset the state of the Profile component and all of its children. Now the comment field will clear out automatically when navigating between profiles.

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

¥Note that in this example, only the outer ProfilePage component is exported and visible to other files in the project. Components rendering ProfilePage don’t need to pass the key to it: they pass userId as a regular prop. The fact ProfilePage passes it as a key to the inner Profile component is an implementation detail.

当属性改变时调整一些状态

¥Adjusting some state when a prop changes

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

¥Sometimes, you might want to reset or adjust a part of the state on a prop change, but not all of it.

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

¥This List component receives a list of items as a prop, and maintains the selected item in the selection state variable. You want to reset the selection to null whenever the items prop receives a different array:

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 并运行副作用。最后,setSelection(null) 调用将导致 List 及其子组件的另一次重新渲染,再次重新启动整个过程。

¥This, too, is not ideal. Every time the items change, the List and its child components will render with a stale selection value at first. Then React will update the DOM and run the Effects. Finally, the setSelection(null) call will cause another re-render of the List and its child components, restarting this whole process again.

从删除副作用开始。而是,在渲染期间直接调整状态:

¥Start by deleting the Effect. Instead, adjust the state directly during rendering:

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

像这样的 存储以前渲染的信息 可能很难理解,但总比在副作用中更新相同的状态要好。在上面的示例中,setSelection 在渲染过程中被直接调用。React 将在使用 return 语句退出后立即重新渲染 List。React 尚未渲染 List 子级或更新 DOM,因此这让 List 子级跳过渲染旧的 selection 值。

¥Storing information from previous renders like this can be hard to understand, but it’s better than updating the same state in an Effect. In the above example, setSelection is called directly during a render. React will re-render the List immediately after it exits with a return statement. React has not rendered the List children or updated the DOM yet, so this lets the List children skip rendering the stale selection value.

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

¥When you update a component during rendering, React throws away the returned JSX and immediately retries rendering. To avoid very slow cascading retries, React only lets you update the same component’s state during a render. If you update another component’s state during a render, you’ll see an error. A condition like items !== prevItems is necessary to avoid loops. You may adjust state like this, but any other side effects (like changing the DOM or setting timeouts) should stay in event handlers or Effects to keep components pure.

尽管此模式比副作用更有效,但大多数组件也不需要它。不管你怎么做,基于属性或其他状态调整状态都会使你的数据流更难理解和调试。始终检查你是否可以改为 使用键重置所有状态在渲染过程中计算一切。例如,你可以存储所选条目 ID,而不是存储(和重置)所选条目:

¥Although this pattern is more efficient than an Effect, most components shouldn’t need it either. No matter how you do it, adjusting state based on props or other state makes your data flow more difficult to understand and debug. Always check whether you can reset all state with a key or calculate everything during rendering instead. For example, instead of storing (and resetting) the selected item, you can store the selected item 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 的大多数更改都保留了选择。

¥Now there is no need to “adjust” the state at all. If the item with the selected ID is in the list, it remains selected. If it’s not, the selection calculated during rendering will be null because no matching item was found. This behavior is different, but arguably better because most changes to items preserve the selection.

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

¥Sharing logic between event handlers

假设你有一个带有两个按钮(购买和结帐)的产品页面,这两个按钮都可以让你购买该产品。你希望在用户将产品放入购物车时显示通知。在两个按钮的点击处理程序中调用 showNotification() 感觉是重复的,因此你可能想将此逻辑放在副作用中:

¥Let’s say you have a product page with two buttons (Buy and Checkout) that both let you buy that product. You want to show a notification whenever the user puts the product in the cart. Calling showNotification() in both buttons’ click handlers feels repetitive so you might be tempted to place this logic in an 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,所以上面的副作用将调用 showNotification()

¥This Effect is unnecessary. It will also most likely cause bugs. For example, let’s say that your app “remembers” the shopping cart between the page reloads. If you add a product to the cart once and refresh the page, the notification will appear again. It will keep appearing every time you refresh that product’s page. This is because product.isInCart will already be true on the page load, so the Effect above will call showNotification().

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

¥When you’re not sure whether some code should be in an Effect or in an event handler, ask yourself why this code needs to run. Use Effects only for code that should run because the component was displayed to the user. In this example, the notification should appear because the user pressed the button, not because the page was displayed! Delete the Effect and put the shared logic into a function called from both event handlers:

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

这既消除了不必要的副作用又修复了错误。

¥This both removes the unnecessary Effect and fixes the bug.

发送 POST 请求

¥Sending a POST request

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

¥This Form component sends two kinds of POST requests. It sends an analytics event when it mounts. When you fill in the form and click the Submit button, it will send a POST request to the /api/register endpoint:

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

让我们应用与之前示例中相同的标准。

¥Let’s apply the same criteria as in the example before.

分析 POST 请求应保留在副作用中。这是因为发送分析事件的原因是表单已显示。(它会在开发中触发两次,但 看这里 如何处理它。)

¥The analytics POST request should remain in an Effect. This is because the reason to send the analytics event is that the form was displayed. (It would fire twice in development, but see here for how to deal with that.)

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

¥However, the /api/register POST request is not caused by the form being displayed. You only want to send the request at one specific moment in time: when the user presses the button. It should only ever happen on that particular interaction. Delete the second Effect and move that POST request into the event handler:

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

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

¥When you choose whether to put some logic into an event handler or an Effect, the main question you need to answer is what kind of logic it is from the user’s perspective. If this logic is caused by a particular interaction, keep it in the event handler. If it’s caused by the user seeing the component on the screen, keep it in the Effect.

计算链

¥Chains of computations

有时你可能会想链接副作用,每个副作用都根据其他状态调整一个状态:

¥Sometimes you might feel tempted to chain Effects that each adjust a piece of state based on other state:

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

// ...

这段代码有两个问题。

¥There are two problems with this code.

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

¥The first problem is that it is very inefficient: the component (and its children) have to re-render between each set call in the chain. In the example above, in the worst case (setCard → render → setGoldCardCount → render → setRound → render → setIsGameOver → render) there are three unnecessary re-renders of the tree below.

第二个问题是,即使它不慢,随着代码的发展,你也会遇到编写的 “链条” 不符合新要求的情况。想象一下,你正在添加一种逐步浏览游戏动作历史的方法。你可以通过将每个状态变量更新为过去的值来实现。但是,将 card 状态设置为过去的值会再次触发副作用链并更改你显示的数据。这样的代码通常是僵化和脆弱的。

¥The second problem is that even if it weren’t slow, as your code evolves, you will run into cases where the “chain” you wrote doesn’t fit the new requirements. Imagine you are adding a way to step through the history of the game moves. You’d do it by updating each state variable to a value from the past. However, setting the card state to a value from the past would trigger the Effect chain again and change the data you’re showing. Such code is often rigid and fragile.

在这种情况下,最好在渲染期间计算你能做什么,并在事件处理程序中调整状态:

¥In this case, it’s better to calculate what you can during rendering, and adjust the state in the event handler:

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!');
}
}
}
}

// ...

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

¥This is a lot more efficient. Also, if you implement a way to view game history, now you will be able to set each state variable to a move from the past without triggering the Effect chain that adjusts every other value. If you need to reuse logic between several event handlers, you can extract a function and call it from those handlers.

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

¥Remember that inside event handlers, state behaves like a snapshot. For example, even after you call setRound(round + 1), the round variable will reflect the value at the time the user clicked the button. If you need to use the next value for calculations, define it manually like const nextRound = round + 1.

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

¥In some cases, you can’t calculate the next state directly in the event handler. For example, imagine a form with multiple dropdowns where the options of the next dropdown depend on the selected value of the previous dropdown. Then, a chain of Effects is appropriate because you are synchronizing with network.

初始化应用

¥Initializing the application

一些逻辑应该只在应用加载时运行一次。

¥Some logic should only run once when the app loads.

你可能想将它放在顶层组件的副作用中:

¥You might be tempted to place it in an Effect in the top-level component:

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

但是,你很快就会发现它 在开发中运行两次。 这可能会导致问题 - 例如,它可能会使身份验证令牌无效,因为该函数未设计为被调用两次。通常,你的组件应该能够适应重新挂载。这包括你的顶层 App 组件。

¥However, you’ll quickly discover that it runs twice in development. This can cause issues—for example, maybe it invalidates the authentication token because the function wasn’t designed to be called twice. In general, your components should be resilient to being remounted. This includes your top-level App component.

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

¥Although it may not ever get remounted in practice in production, following the same constraints in all components makes it easier to move and reuse code. If some logic must run once per app load rather than once per component mount, add a top-level variable to track whether it has already executed:

let didInit = false;

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

你还可以在模块初始化期间和应用渲染之前运行它:

¥You can also run it during module initialization and before the app renders:

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

function App() {
// ...
}

当你的组件被导入时,顶层代码运行一次 - 即使它最终没有被渲染。为避免在导入任意组件时速度变慢或出现意外行为,请不要过度使用此模式。将应用作用域的初始化逻辑保留到根组件模块(如 App.js)或应用的入口点。

¥Code at the top level runs once when your component is imported—even if it doesn’t end up being rendered. To avoid slowdown or surprising behavior when importing arbitrary components, don’t overuse this pattern. Keep app-wide initialization logic to root component modules like App.js or in your application’s entry point.

通知父组件状态变化

¥Notifying parent components about state changes

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

¥Let’s say you’re writing a Toggle component with an internal isOn state which can be either true or false. There are a few different ways to toggle it (by clicking or dragging). You want to notify the parent component whenever the Toggle internal state changes, so you expose an onChange event and call it from an 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 运行副作用,它调用从父组件传递的 onChange 函数。现在父组件将更新自己的状态,开始另一个渲染过程。一次完成所有事情会更好。

¥Like earlier, this is not ideal. The Toggle updates its state first, and React updates the screen. Then React runs the Effect, which calls the onChange function passed from a parent component. Now the parent component will update its own state, starting another render pass. It would be better to do everything in a single pass.

删除副作用并在同一个事件处理程序中更新两个组件的状态:

¥Delete the Effect and instead update the state of both components within the same event handler:

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 批量更新 来自不同的组件,因此只有一个渲染通道。

¥With this approach, both the Toggle component and its parent component update their state during the event. React batches updates from different components together, so there will only be one render pass.

你也可以完全删除状态,而是从父组件接收 isOn

¥You might also be able to remove the state altogether, and instead receive isOn from the parent component:

// ✅ 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。这意味着父组件将不得不包含更多逻辑,但需要担心的总体状态会更少。每当你尝试使两个不同的状态变量保持同步时,请尝试提升状态!

¥“Lifting state up” lets the parent component fully control the Toggle by toggling the parent’s own state. This means the parent component will have to contain more logic, but there will be less state overall to worry about. Whenever you try to keep two different state variables synchronized, try lifting state up instead!

将数据传递给父级

¥Passing data to the parent

这个 Child 组件获取一些数据,然后将其传递给副作用中的 Parent 组件:

¥This Child component fetches some data and then passes it to the Parent component in an Effect:

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 中,数据从父组件流向它们的子级。当你在屏幕上看到错误时,你可以沿着组件链向上追踪信息的来源,直到你找到哪个组件传递了错误的属性或具有错误的状态。当子组件在副作用中更新其父组件的状态时,数据流变得很难追踪。由于子级和父组件都需要相同的数据,因此让父级获取该数据,然后将其传递给子级:

¥In React, data flows from the parent components to their children. When you see something wrong on the screen, you can trace where the information comes from by going up the component chain until you find which component passes the wrong prop or has the wrong state. When child components update the state of their parent components in Effects, the data flow becomes very difficult to trace. Since both the child and the parent need the same data, let the parent component fetch that data, and pass it down to the child instead:

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

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

这更简单,并使数据流可预测:数据从父级流向子级。

¥This is simpler and keeps the data flow predictable: the data flows down from the parent to the child.

订阅外部存储

¥Subscribing to an external store

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

¥Sometimes, your components may need to subscribe to some data outside of the React state. This data could be from a third-party library or a built-in browser API. Since this data can change without React’s knowledge, you need to manually subscribe your components to it. This is often done with an Effect, for example:

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

¥Here, the component subscribes to an external data store (in this case, the browser navigator.onLine API). Since this API does not exist on the server (so it can’t be used for the initial HTML), initially the state is set to true. Whenever the value of that data store changes in the browser, the component updates its state.

尽管为此使用副作用很常见,但 React 有一个专门构建的钩子,用于订阅首选的外部存储。删除副作用并将其替换为对 useSyncExternalStore 的调用:

¥Although it’s common to use Effects for this, React has a purpose-built Hook for subscribing to an external store that is preferred instead. Delete the Effect and replace it with a call to 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();
// ...
}

与使用副作用手动将可变数据同步到 React 状态相比,这种方法更不容易出错。通常,你会像上面的 useOnlineStatus() 一样编写一个自定义钩子,这样你就不需要在各个组件中重复此代码。阅读有关从 React 组件订阅外部存储的更多信息。

¥This approach is less error-prone than manually syncing mutable data to React state with an Effect. Typically, you’ll write a custom Hook like useOnlineStatus() above so that you don’t need to repeat this code in the individual components. Read more about subscribing to external stores from React components.

请求数据

¥Fetching data

许多应用使用副作用来启动数据获取。像这样编写数据获取副作用是很常见的:

¥Many apps use Effects to kick off data fetching. It is quite common to write a data fetching Effect like this:

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

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

¥You don’t need to move this fetch to an event handler.

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

¥This might seem like a contradiction with the earlier examples where you needed to put the logic into the event handlers! However, consider that it’s not the typing event that’s the main reason to fetch. Search inputs are often prepopulated from the URL, and the user might navigate Back and Forward without touching the input.

pagequery 来自哪里并不重要。当此组件可见时,你希望保持 results 与当前 pagequery 的网络数据 同步。这就是为什么它是一个副作用。

¥It doesn’t matter where page and query come from. While this component is visible, you want to keep results synchronized with data from the network for the current page and query. This is why it’s an Effect.

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

¥However, the code above has a bug. Imagine you type "hello" fast. Then the query will change from "h", to "he", "hel", "hell", and "hello". This will kick off separate fetches, but there is no guarantee about which order the responses will arrive in. For example, the "hell" response may arrive after the "hello" response. Since it will call setResults() last, you will be displaying the wrong search results. This is called a “race condition”: two different requests “raced” against each other and came in a different order than you expected.

要修复竞争条件,你需要 添加清理函数 以忽略旧的响应:

¥To fix the race condition, you need to add a cleanup function to ignore stale responses:

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

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

¥This ensures that when your Effect fetches data, all responses except the last requested one will be ignored.

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

¥Handling race conditions is not the only difficulty with implementing data fetching. You might also want to think about caching responses (so that the user can click Back and see the previous screen instantly), how to fetch data on the server (so that the initial server-rendered HTML contains the fetched content instead of a spinner), and how to avoid network waterfalls (so that a child can fetch data without waiting for every parent).

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

¥These issues apply to any UI library, not just React. Solving them is not trivial, which is why modern frameworks provide more efficient built-in data fetching mechanisms than fetching data in Effects.

如果你不使用框架(并且不想构建自己的框架)但希望从副作用获取数据更符合人机工程学,请考虑将你的获取逻辑提取到自定义钩子中,如下例所示:

¥If you don’t use a framework (and don’t want to build your own) but would like to make data fetching from Effects more ergonomic, consider extracting your fetching logic into a custom Hook like in this example:

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

你可能还想为错误处理添加一些逻辑并跟踪内容是否正在加载。你可以自己构建这样的钩子,也可以使用 React 生态系统中已有的众多解决方案之一。虽然单独这样做不如使用框架内置的数据请求机制那么高效,但是将数据获取逻辑移动到自定义钩子中将使以后更容易采用高效的数据获取策略。

¥You’ll likely also want to add some logic for error handling and to track whether the content is loading. You can build a Hook like this yourself or use one of the many solutions already available in the React ecosystem. Although this alone won’t be as efficient as using a framework’s built-in data fetching mechanism, moving the data fetching logic into a custom Hook will make it easier to adopt an efficient data fetching strategy later.

一般来说,无论何时你必须求助于编写副作用,请留意何时可以使用更具声明性和专用性的 API(如上面的 useData)将一部分功能提取到自定义钩子中。组件中的原始 useEffect 调用越少,维护应用就越容易。

¥In general, whenever you have to resort to writing Effects, keep an eye out for when you can extract a piece of functionality into a custom Hook with a more declarative and purpose-built API like useData above. The fewer raw useEffect calls you have in your components, the easier you will find to maintain your application.

回顾

  • 如果你可以在渲染期间计算某些东西,则不需要副作用。

    ¥If you can calculate something during render, you don’t need an Effect.

  • 要缓存昂贵的计算,请添加 useMemo 而不是 useEffect

    ¥To cache expensive calculations, add useMemo instead of useEffect.

  • 要重置整个组件树的状态,请将不同的 key 传递给它。

    ¥To reset the state of an entire component tree, pass a different key to it.

  • 要重置特定位的状态以响应属性更改,请在渲染期间设置它。

    ¥To reset a particular bit of state in response to a prop change, set it during rendering.

  • 因为显示组件而运行的代码应该在副作用中,其余的应该在事件中。

    ¥Code that runs because a component was displayed should be in Effects, the rest should be in events.

  • 如果你需要更新多个组件的状态,最好在单个事件期间执行。

    ¥If you need to update the state of several components, it’s better to do it during a single event.

  • 每当你尝试同步不同组件中的状态变量时,请考虑提升状态。

    ¥Whenever you try to synchronize state variables in different components, consider lifting state up.

  • 你可以使用副作用获取数据,但你需要实现清理以避免竞争条件。

    ¥You can fetch data with Effects, but you need to implement cleanup to avoid race conditions.

挑战 1 / 4:
不使用副作用转换数据

¥Transform data without Effects

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

¥The TodoList below displays a list of todos. When the “Show only active todos” checkbox is ticked, completed todos are not displayed in the list. Regardless of which todos are visible, the footer displays the count of todos that are not yet completed.

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

¥Simplify this component by removing all the unnecessary state and 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>
    </>
  );
}


React 中文网 - 粤ICP备13048890号