你可能不需要Effect
Effects 是 React 范式的逃生通道。 它们让你“走出”React 并将你的组件与一些外部系统同步,比如非 React 小部件、网络或浏览器 DOM。 如果不涉及外部系统(例如,如果您想在某些道具或状态更改时更新组件的状态),则不需要 Effect。 删除不必要的 Effects 将使您的代码更易于理解、运行速度更快并且更不容易出错。
你将学习
- 为什么以及如何从您的组件中删除不必要的效果
- 如何在没有 Effects 的情况下缓存昂贵的计算
- 如何在没有 Effects 的情况下重置和调整组件状态
- 如何在事件处理程序之间共享逻辑
- 哪些逻辑应该移至事件处理程序
- 如何将更改通知父组件
如何删除不必要的效果
有两种常见情况不需要 Effects:
- 您不需要 Effects 来转换数据以进行渲染。 例如,假设您想在显示列表之前对其进行过滤。 您可能很想编写一个 Effect 在列表更改时更新状态变量。 然而,这是低效的。 当您更新组件的状态时,React 将首先调用您的组件函数来计算屏幕上应该显示的内容。 然后 React 会将这些更改“提交”到 DOM,更新屏幕。 然后 React 将运行您的 Effects。 如果您的 Effect 也立即更新状态,则整个过程将从头开始! 为避免不必要的渲染过程,请转换组件顶层的所有数据。 只要您的属性或状态发生变化,该代码就会自动重新运行。
- 您不需要 Effects 来处理用户事件。 例如,假设您要发送 /api/buy POST 请求并在用户购买产品时显示通知。 在 Buy 按钮单击事件处理程序中,您确切地知道发生了什么。 在 Effect 运行时,您不知道用户做了什么(例如,单击了哪个按钮)。 这就是为什么您通常会在相应的事件处理程序中处理用户事件。
您确实需要 Effects 才能与外部系统同步。 例如,您可以编写一个 Effect,使 jQuery 小部件与 React 状态保持同步。 您还可以使用 Effects 获取数据:例如,您可以将搜索结果与当前搜索查询同步。 请记住,与直接在组件中编写 Effects 相比,现代框架提供了更高效的内置数据获取机制。
为了帮助您获得正确的直觉,让我们看一些常见的具体示例!
根据属性或状态更新状态
假设您有一个包含两个状态变量的组件:firstName 和 lastName。 你想通过连接它们来计算一个 fullName 。 此外,您希望 fullName 在 firstName 或 lastName 更改时更新。 您的第一直觉可能是添加一个 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;
// ...
}
当某些东西可以从现有的属性或状态中计算出来时,不要把它放在状态中。 相反,在渲染期间计算它。 这使您的代码更快(您避免了额外的“级联”更新)、更简单(您删除了一些代码)并且更不容易出错(您避免了由于不同状态变量彼此不同步而导致的错误)。 如果你觉得这种方法很新,Thinking in React有一些关于什么应该进入状态的指导。
缓存昂贵的计算
该组件通过获取 props 接收到的 todos 并根据 filter prop 过滤它们来计算 visibleTodos。 您可能想将结果存储在状态变量中并在 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]);
// ...
}
与前面的示例一样,这既不必要又低效。 首先,删除状态和效果:
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ This is fine if getFilteredTodos() is not slow.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
在很多情况下,这段代码是没问题的! 但也许 getFilteredTodos() 很慢或者你有很多待办事项。 在这种情况下,如果一些不相关的状态变量(如 newTodo)发生了变化,你不想重新计算 getFilteredTodos() 。
您可以通过将昂贵的计算包装在 useMemo Hook 中来缓存(或“记忆化”)它:
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 你不希望内部函数重新运行,除非 todos 或 filter 发生变化。 React 将在初始渲染期间记住 getFilteredTodos() 的返回值。 在下一次渲染期间,它将检查待办事项或过滤器是否不同。 如果它们与上次相同,useMemo 将返回它存储的最后一个结果。 但如果它们不同,React 将再次调用包装函数(并存储该结果)。
您包装在 useMemo 中的函数在渲染期间运行,因此这仅适用于纯计算。
深度阅读:如何判断计算是否昂贵?
一般而言,除非您要创建或遍历数千个对象,否则它可能并不昂贵。 如果你想获得更多的信心,你可以添加一个控制台日志来衡量一段代码所花费的时间:
console.time('filter array'); const visibleTodos = getFilteredTodos(todos, filter); console.timeEnd('filter array');
执行您正在测量的交互(例如,输入输入)。 然后,您将在控制台中看到类似 filter array: 0.15ms 的日志。 如果记录的总时间加起来很大(比如 1 毫秒或更多),那么记住该计算可能是有意义的。 作为实验,您可以将计算包装在 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 Throttling 选项。
另请注意,在开发中衡量性能不会为您提供最准确的结果。 (例如,当启用严格模式时,您会看到每个组件呈现两次而不是一次。)要获得最准确的时间,请构建您的生产应用程序并在用户拥有的设备上对其进行测试。
当属性改变时重置所有状态
这个 ProfilePage 组件接收一个 userId 属性。 该页面包含评论输入,您使用评论状态变量来保存它的值。 有一天,您注意到一个问题:当您从一个配置文件导航到另一个配置文件时,评论状态不会重置。 因此,很容易不小心在错误的用户个人资料上发表评论。 要解决此问题,您需要在 userId 更改时清除评论状态变量:
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 一个明确的键来告诉 React 每个用户的个人资料在概念上是不同的个人资料。 将您的组件一分为二,并将关键属性从外部组件传递到内部组件:
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 作为键传递给 Profile 组件,您要求 React 将具有不同 userId 的两个 Profile 组件视为不应共享任何状态的两个不同组件。 每当键(您已设置为 userId)更改时,React 将重新创建 DOM 并重置 Profile 组件及其所有子组件的状态。 因此,在配置文件之间导航时,评论字段将自动清除。
请注意,在此示例中,只有外部 ProfilePage 组件被导出并且对项目中的其他文件可见。 呈现 ProfilePage 的组件不需要将密钥传递给它:它们将 userId 作为常规道具传递。 ProfilePage 将其作为密钥传递给内部 Profile 组件的事实是一个实现细节。
当属性改变时调整一些状态
有时,您可能希望在 prop 更改时重置或调整部分状态,但不是全部。
此 List 组件接收项目列表作为属性,并在选择状态变量中维护所选项目。 每当 items 属性接收到不同的数组时,您想将选择重置为空:
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]);
// ...
}
这也不理想。 每次项目更改时,List 及其子组件将首先使用陈旧的选择值呈现。 然后 React 将更新 DOM 并运行 Effects。 最后,setSelection(null) 调用将导致再次重新渲染 List 及其子组件,再次重新启动整个过程。
从删除效果开始。 相反,在渲染期间直接调整状态:
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 将在返回语句退出后立即重新渲染 List。 到那时,React 还没有渲染 List children 或更新 DOM,所以这让 List children 跳过渲染陈旧的选择值。
当您在渲染期间更新组件时,React 会丢弃返回的 JSX 并立即重试渲染。 为了避免非常缓慢的级联重试,React 只允许您在渲染期间更新同一组件的状态。 如果您在渲染期间更新另一个组件的状态,您将看到一个错误。 像 items !== prevItems 这样的条件对于避免循环是必要的。 您可以像这样调整状态,但任何其他副作用(如更改 DOM 或设置超时)都应保留在事件处理程序或 Effects 中,以使您的组件可预测。
尽管此模式比 Effect 更有效,但大多数组件也不需要它。 不管你怎么做,基于 props 或其他状态调整状态都会使你的数据流更难理解和调试。 始终检查您是否可以使用键重置所有状态或在渲染期间计算所有内容。 例如,您可以存储所选项目 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 的项目在列表中,它将保持选中状态。 如果不是,则渲染期间计算的选择将为空,因为未找到匹配项。 这种行为有点不同,但可以说它更好,因为现在对项目的大多数更改都保留了选择。 但是,您需要在下面的所有逻辑中使用选择,因为可能不存在具有 selectedId 的项目。
在事件处理程序之间共享逻辑
假设您有一个带有两个按钮(购买和结帐)的产品页面,这两个按钮都可以让您购买该产品。 您希望在用户将产品放入购物车时显示通知。 将 showNotification() 调用添加到两个按钮的点击处理程序感觉很重复,因此您可能想将此逻辑放在 Effect 中:
function ProductPage({ product, addToCart }) {
// 🔴 Avoid: Event-specific logic inside an Effect
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
这个效果是不必要的。 它也很可能会导致错误。 例如,假设您的应用程序在页面重新加载之间“记住”了购物车。 如果您将产品添加到购物车一次并刷新页面,通知将再次出现。 每次您刷新该产品的页面时,它都会继续出现。 这是因为 product.isInCart 在页面加载时已经为真,所以上面的 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 中。
链式计算
有时您可能会想链式 Effects,每个 Effects 都根据其他状态调整一个状态:
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 → render → setGoldCardCount → render → setRound → render → setIsGameOver → render)下面的树有三个不必要的重新渲染。
即使它不慢,随着代码的发展,您也会遇到您编写的“链”不符合新要求的情况。 想象一下,您正在添加一种逐步浏览游戏动作历史的方法。 您可以通过将每个状态变量更新为过去的值来实现。 但是,将卡片状态设置为过去的值会再次触发效果链并更改您显示的数据。 像这样的代码通常是僵化和脆弱的。
在这种情况下,最好在渲染期间计算你能做什么,并在事件处理程序中调整状态:
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!');
}
}
}
}
// ...
这样效率更高。 此外,如果您实施一种查看游戏历史的方法,现在您将能够将每个状态变量设置为过去的移动,而不会触发调整每个其他值的效果链。 如果您需要在多个事件处理程序之间重用逻辑,您可以提取一个函数并从这些处理程序中调用它。
请记住,在事件处理程序内部,状态的行为类似于快照。 例如,即使在调用 setRound(round + 1) 之后,round 变量仍将反映用户单击按钮时的值。 如果您需要使用下一个值进行计算,请手动定义它,如 const nextRound = round + 1。
在某些情况下,您无法直接在事件处理程序中计算下一个状态。 例如,想象一个具有多个下拉菜单的表单,其中下一个下拉菜单的选项取决于上一个下拉菜单的选定值。 然后,一个 Effects 链获取数据是合适的,因为你正在与网络同步。
初始化应用
一些逻辑应该只在应用程序加载时运行一次。 您可以将它放在顶级组件的 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。 这意味着父组件将不得不包含更多逻辑,但需要担心的总体状态会更少。 每当您尝试使两个不同的状态变量保持同步时,这就是尝试提升状态的标志!
将数据传递给父级
此子组件获取一些数据,然后将其传递给 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 或具有错误的状态。 当子组件在 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 状态相比,这种方法更不容易出错。 通常,您将编写一个像上面的 useOnlineStatus() 这样的自定义 Hook,这样您就不需要在各个组件中重复此代码。 阅读有关从 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);
}
// ...
}
您不需要将此提取移动到事件处理程序。
这似乎与之前需要将逻辑放入事件处理程序的示例相矛盾! 但是,考虑到获取的主要原因不是键入事件。 搜索输入通常是从 URL 中预先填充的,用户可以在不触摸输入的情况下向后和向前导航。 页面和查询来自何处并不重要。 当此组件可见时,您希望根据当前页面和查询使结果与来自网络的数据保持同步。 这就是为什么它是一个效果。
但是,上面的代码有一个错误。 想象一下,您快速输入“hello”。 然后查询将从“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,请留意何时可以使用更具声明性和专用性的 API(如上面的 useData)将一部分功能提取到自定义 Hook 中。 组件中的原始 useEffect 调用越少,维护应用程序就越容易。
回顾
- 如果您可以在渲染期间计算某些东西,则不需要 Effect。
- 要缓存昂贵的计算,请添加 useMemo 而不是 useEffect。
- 要重置整个组件树的状态,请将不同的键传递给它。
- 要重置特定位的状态以响应属性更改,请在渲染期间设置它。
- 因为显示组件而需要运行的代码应该在 Effects 中,其余的应该在事件中。
- 如果您需要更新多个组件的状态,最好在单个事件期间执行。
- 每当您尝试同步不同组件中的状态变量时,请考虑提升状态。
- 您可以使用 Effects 获取数据,但您需要实施清理以避免竞争条件。
网友评论