副作用是从 React 范式中逃逸的出口。它们让你可以“跳出” React,将你的组件与某些外部系统同步,比如非 React 的小部件、网络或浏览器 DOM。如果没有涉及外部系统(例如,如果你想在某些 props 或 state 变化时更新组件的 state),你不需要使用副作用。移除不必要的副作用将使你的代码更易于理解、运行更快,并且更不容易出错。
🌐 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.
你将学习到
- 为什么以及如何从你的组件中删除不必要的副作用
- 如何在没有副作用的情况下缓存昂贵的计算
- 如何在没有副作用的情况下重置和调整组件状态
- 如何在事件处理程序之间共享逻辑
- 哪些逻辑应该移至事件处理程序
- 如何将更改通知父组件
如何删除不必要的副作用
🌐 How to remove unnecessary Effects
有两种常见情况不需要副作用:
🌐 There are two common cases in which you don’t need Effects:
- 你不需要使用 Effects 来转换用于渲染的数据。 例如,假设你想在显示列表之前对其进行过滤。你可能会想通过编写 Effect,在列表变化时更新状态变量。然而,这是低效的。当你更新状态时,React 会首先调用你的组件函数来计算屏幕上应该显示的内容。然后,React 会将这些更改提交到 DOM,更新屏幕。接着,React 才会运行你的 Effects。如果你的 Effect 也 立即更新状态,这将从头重新启动整个过程!为了避免不必要的渲染过程,请在组件的顶层转换所有数据。每当你的 props 或 state 发生变化时,这些代码就会自动重新运行。
- 你不需要使用 Effects 来处理用户事件。 例如,假设你想在用户购买产品时发送一个
/api/buyPOST 请求并显示通知。在购买按钮的点击事件处理器中,你完全清楚发生了什么。当 Effect 运行时,你并不知道用户具体做了什么(例如,点击了哪个按钮)。这就是为什么你通常会在相应的事件处理器中处理用户事件的原因。
你确实需要 Effects 来与外部系统同步。例如,你可以编写一个 Effect,使 jQuery 小部件与 React 状态保持同步。你也可以使用 Effects 获取数据:例如,你可以将搜索结果与当前搜索查询同步。请记住,现代框架提供比在组件中直接编写 Effects 更高效的内置数据获取机制。
🌐 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
假设你有一个组件,它有两个状态变量:firstName 和 lastName。你想通过将它们连接起来来计算 fullName。此外,你希望每当 firstName 或 lastName 发生变化时,fullName 也会更新。你的第一反应可能是添加一个 fullName 状态变量,并在 Effect 中更新它:
🌐 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 值进行完整的渲染,然后立即使用更新后的值重新渲染。删除状态变量和 Effect:
🌐 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;
// ...
}当某些内容可以从现有的 props 或 state 计算出来时,不要把它放入 state。 相反,在渲染过程中计算它。 这样可以让你的代码更快(避免额外的“级联”更新)、更简单(删除一些代码)并且更不容易出错(避免不同 state 变量之间不同步导致的 bug)。如果这种方法对你来说很新,《React 思维》 解释了什么应该放入 state。
缓存昂贵的计算
🌐 Caching expensive calculations
该组件通过接收的 todos props 并根据 filter prop 进行过滤来计算 visibleTodos。你可能会想将结果存储在状态中并通过 Effect 来更新它:
🌐 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]);
// ...
}就像在前面的例子中,这既不必要也低效。首先,移除状态和 Effect:
🌐 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 Hook 中来缓存(或 “记忆化”(“memoize”))它:
🌐 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,除非 todos 或 filter 改变,否则你不希望内部函数重新运行。 React 会在初次渲染时记住 getFilteredTodos() 的返回值。在接下来的渲染中,它会检查 todos 或 filter 是否不同。如果它们与上次相同,useMemo 会返回之前存储的结果。但如果它们不同,React 会再次调用内部函数(并存储其结果)。
你在 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 不会让第一次渲染更快。它只会帮助你在更新时跳过不必要的工作。
请记住,你的机器可能比用户的机器更快,因此用人为减速来测试性能是一个好主意。例如,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 内部状态的组件中都这样做。例如,如果评论界面是嵌套的,你也想清除嵌套的评论状态。
🌐 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 一个明确的 key,让它理解每个用户的资料在概念上是一个_不同的_资料。将你的组件分成两个,并从外部组件向内部组件传递一个 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 组件视为两个不同的组件,它们不应共享任何状态。 每当 key(你已经设置为 userId)发生变化时,React 将重新创建 DOM 并 重置状态 Profile 组件及其所有子组件。现在在不同个人资料之间切换时,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 的组件不需要将 key 传递给它:它们将 userId 作为普通的 prop 传递。事实上,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 并执行 Effects。最后,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);
}
// ...
}从之前的渲染中存储信息 可能很难理解,但它比在 Effect 中更新相同的状态要好。在上面的示例中,setSelection 在渲染过程中被直接调用。React 会在它以 return 语句退出后立即重新渲染 List。React 还没有渲染 List 子组件或更新 DOM,所以这让 List 子组件可以跳过渲染过时的 selection 值。
当你在渲染期间更新组件时,React 会丢弃返回的 JSX 并立即重试渲染。为了避免非常慢的级联重试,React 只允许你在渲染期间更新相同组件的状态。如果你在渲染期间更新另一个组件的状态,你将看到错误。像 items !== prevItems 这样的条件对于避免循环是必要的。你可以像这样调整状态,但任何其他副作用(比如更改 DOM 或设置定时器)应该保留在事件处理器或 Effects 中,以保持组件纯粹。
🌐 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.
尽管这种模式比 Effect 更高效,但大多数组件也不需要它。 无论采用何种方式,根据 props 或其他 state 调整 state 都会让你的数据流更难以理解和调试。始终检查是否可以使用 key 重置所有 state或者在渲染期间计算所有内容。例如,与其存储(并重置)选中的 item,你可以存储选中的 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() 会显得重复,因此你可能会想把这个逻辑放在一个 Effect 中:
🌐 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,所以上面的 Effect 会调用 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().
当你不确定某些代码应该放在 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');
}
// ...
}这既消除了不必要的副作用又修复了错误。
🌐 This both removes the unnecessary Effect and fixes the bug.
发送 POST 请求
🌐 Sending a POST request
这个 Form 组件发送两种类型的 POST 请求。它在挂载时发送一个分析事件。当你填写表单并点击提交按钮时,它会发送一个 POST 请求到 /api/register 端点:
🌐 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 请求应该保留在 Effect 中。这是因为发送分析事件的_原因_是表单已显示。(在开发中它会触发两次,但请参见这里了解如何处理。)
🌐 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 请求并不是由表单被 显示 引起的。你只想在一个特定的时间点发送请求:当用户按下按钮时。它应该只在 那次特定的交互 中发生。删除第二个 Effect,并将该 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 });
}
// ...
}当你选择将一些逻辑放入事件处理器还是 Effect 时,你需要回答的主要问题是从用户的角度来看,这是什么类型的逻辑。如果这个逻辑是由特定的交互引起的,就将其保留在事件处理器中。如果它是由用户“看到”屏幕上的组件引起的,就将其保留在 Effect 中。
🌐 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 状态设置为过去的某个值会再次触发 Effect 链,并改变你显示的数据。这类代码通常是僵硬且脆弱的。
🌐 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.
在某些情况下,你不能在事件处理程序中直接计算下一个状态。例如,想象一个有多个下拉菜单的表单,其中下一个下拉菜单的选项取决于上一个下拉菜单的选定值。那么,一系列 Effects 是合适的,因为你正在与网络进行同步。
🌐 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
假设你正在编写一个 Toggle 组件,它有一个内部的 isOn 状态,该状态可以是 true 或 false。有几种不同的方式可以切换它(通过点击或拖动)。每当 Toggle 内部状态发生变化时,你希望通知父组件,因此你暴露了一个 onChange 事件并从 Effect 中调用它:
🌐 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 执行 Effect,它调用从父组件传入的 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.
删除 Effect,而是在同一个事件处理器中更新两个组件的状态:
🌐 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);
}
}
// ...
}“提升状态”(/learn/sharing-state-between-components)让父组件可以通过切换父组件自身的状态来完全控制 Toggle。这意味着父组件将需要包含更多的逻辑,但总体上需要关注的状态会更少。每当你尝试保持两个不同的状态变量同步时,尝试提升状态吧!
将数据传递给父级
🌐 Passing data to the parent
这个 Child 组件获取一些数据,然后通过 Effect 将其传递给 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 中,数据是从父组件流向子组件的。当你在屏幕上看到问题时,你可以通过沿着组件链向上追踪信息的来源,直到找到哪个组件传递了错误的 prop 或者哪个组件的 state 有问题。当子组件在 Effects 中更新父组件的 state 时,数据流就变得很难追踪。由于子组件和父组件都需要相同的数据,因此让父组件获取这些数据,然后传递给子组件,而不是让子组件自己去获取:
🌐 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 不知情的情况下发生变化,你需要手动将组件订阅到这些数据。这通常通过使用 Effect 来完成,例如:
🌐 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.
虽然通常会使用 Effects 来实现这一点,但 React 有一个专门的 Hook 用于订阅外部存储,这个 Hook 更加推荐使用。删除 Effect 并用调用 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();
// ...
}这种方法比手动通过 Effect 将可变数据同步到 React 状态更不容易出错。通常,你会像上面的 useOnlineStatus() 那样编写一个自定义 Hook,这样就不需要在各个组件中重复这段代码。阅读更多关于从 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
许多应用使用 Effects 来启动数据获取。编写这样的数据获取 Effect 是很常见的:
🌐 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);
}
// ...
}你不需要将这个 fetch 移动到事件处理程序中。
🌐 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.
无论 page 和 query 来自哪里都无关紧要。当这个组件可见时,你希望保持 results 与当前 page 和 query 的网络数据同步。这就是它是一个 Effect 的原因。
🌐 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.
要修复竞争条件,你需要添加清理函数以忽略过时的响应:
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。解决这些问题并不简单,这就是为什么现代的框架提供比在 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;
}你可能还想添加一些错误处理的逻辑,以及跟踪内容是否正在加载。你可以自己像这样构建一个 Hook,或者使用 React 生态系统中已有的许多解决方案。虽然仅这样做不会像使用框架内置的数据获取机制那样高效,但将数据获取逻辑移入自定义 Hook,会使以后采用高效的数据获取策略更容易。
🌐 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.
一般来说,每当你不得不使用 Effects 时,要注意是否可以将一部分功能提取到一个自定义 Hook 中,使用更声明式和专用的 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.
回顾
- 如果你可以在渲染期间计算某些东西,则不需要副作用。
- 为了缓存高开销的计算,添加
useMemo而不是useEffect。 - 要重置整个组件树的状态,请传递一个不同的
key给它。 - 要重置特定位的状态以响应属性更改,请在渲染期间设置它。
- 因为组件被显示而运行的代码应该放在 Effects 中,其余的应该放在事件中。
- 如果你需要更新多个组件的状态,最好在单个事件期间执行。
- 每当你尝试同步不同组件中的状态变量时,请考虑提升状态。
- 你可以使用副作用获取数据,但你需要实现清理以避免竞争条件。
挑战 1 of 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> </> ); }