你可能不需要React的Effect

引言

在React的应用中,useEffect钩子是一个让我们能够“跳出”React体系,与外部系统如非React组件、网络请求或浏览器DOM等同步的机制。如果没有任何外部系统参与(例如,当一些props或state变化时你想要更新组件的状态),你就不应该需要Effect。移除不必要的Effect将使你的代码更容易理解、运行更快,并且更少出错。

你将学到

  • 为什么要以及如何从你的组件中移除不必要的Effect
  • 如何在没有Effect的情况下缓存昂贵的计算
  • 如何在没有Effect的情况下重置和调整组件状态
  • 如何在事件处理器之间共享逻辑
  • 哪些逻辑应该移动到事件处理器
  • 如何通知父组件关于变化的信息

如何移除不必要的Effect

你不需要Effect的两种常见情况:

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

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

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

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

基于props或state更新状态

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

1
2
3
4
5
6
7
8
9
10
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 避免:多余的状态和不必要的Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}

这比必要的更复杂。它也太低效了:它先用fullName的旧值进行一次完整的渲染通过,然后立即用更新后的值重新渲染。移除状态变量和Effect:

1
2
3
4
5
6
7
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 好:在渲染期间计算
const fullName = firstName + ' ' + lastName;
// ...
}

当某件事可以从现有的props或state计算出来时,不要把它放在状态中。相反,在渲染期间计算它。 这使你的代码更快(你避免了额外的“级联”更新),更简单(你移除了一些代码),并且更少出错(你避免了由于不同的状态变量彼此不同步而引起的错误)。如果这种方法对你来说是新的,Thinking in React解释了什么应该放入状态。

缓存昂贵的计算

这个组件通过接收到的todos props并根据filter prop进行过滤来计算visibleTodos。你可能会想要在状态中存储结果并从Effect中更新它:

1
2
3
4
5
6
7
8
9
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 避免:多余的状态和不必要的Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}

就像前面的例子一样,这既不必要也不高效。首先,移除状态和Effect:

1
2
3
4
5
6
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 好:如果getFilteredTodos()不慢的话,这样是可以的
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}

通常,这段代码就可以了!但也许getFilteredTodos()很慢,或者你有很多todos。在这种情况下,如果一些不相关的状态变量(如newTodo)发生了变化,你不想重新计算getFilteredTodos()

你可以通过将useMemo Hook包装在内来缓存(或“记忆”)昂贵的计算:

1
2
3
4
5
6
7
8
9
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// 好:除非todos或filter变化,否则不会重新运行
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}

或者,写成单行:

1
2
3
4
5
6
7
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 好:除非todos或filter变化,否则不会重新运行getFilteredTodos()
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}

这告诉React,你不希望内部函数在todosfilter变化时重新运行。 React将在初始渲染期间记住getFilteredTodos()的返回值。在下一次渲染时,它将检查todosfilter是否不同。如果它们和上次一样,useMemo将返回它存储的最后一个结果。但如果它们不同,React将再次调用内部函数(并存储其结果)。

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

如何判断一个计算是否昂贵?

通常情况下,除非你正在创建或循环遍历成千上万个对象,否则它可能并不昂贵。如果你想获得更多的信心,你可以添加一个控制台日志来测量代码段花费的时间:

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

执行你正在测量的交互(例如,输入到输入框)。然后你将在控制台看到像filter array: 0.15ms的日志。如果记录的总时间加起来达到了一个显著的数量(比如说,1ms或更多),那么对那个计算进行记忆可能是有意义的。作为一个实验,你然后将计算包装在useMemo中,以验证那次交互的总记录时间是否减少了:

1
2
3
4
5
console.time('filter array');
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter); // 如果todos和filter没有变化则跳过
}, [todos, filter]);
console.timeEnd('filter array');

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

请记住,你的机器可能比你的用户快,所以使用人工减慢进行性能测试是一个好主意。例如,Chrome提供了CPU节流选项。

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

当prop变化时重置所有状态

这个ProfilePage组件接收一个userId prop。页面包含一个注释输入,你使用一个comment状态变量来保存它的值。有一天,你注意到一个问题:当你从一个配置文件导航到另一个时,comment状态没有被重置。因此,很容易在错误的用户配置文件上意外发布评论。为了解决这个问题,你希望在userId变化时清除comment状态变量:

1
2
3
4
5
6
7
8
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 避免:在Effect中重置prop变化的状态
useEffect(() => {
setComment('');
}, [userId]);
// ...
}

这是低效的,因为ProfilePage及其子组件将首先使用旧值渲染,然后再次渲染。这也很复杂,因为你会需要在ProfilePage内部的每个组件中这样做。例如,如果注释UI是嵌套的,你也会想要清除嵌套的注释状态。

相反,你可以通过给它一个明确的key来告诉React,每个用户配置文件在概念上是不同的配置文件。将你的组件分成两部分,并将外部组件的key属性传递到内部组件:

1
2
3
4
5
6
7
8
9
10
11
12
13
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// 好:key变化时,此组件及以下任何状态将自动重置
const [comment, setComment] = useState('');
// ...
}

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

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

当prop变化时调整部分状态

有时,你可能想在prop变化时重置或调整部分状态,而不是全部。

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

1
2
3
4
5
6
7
8
9
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 避免:在Effect中根据prop变化调整状态
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}

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

首先删除Effect。相反,在渲染期间直接调整状态:

1
2
3
4
5
6
7
8
9
10
11
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 更好:在渲染期间调整状态
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}

像这样存储来自先前渲染的信息可能难以理解,但这比在Effect中更新相同的状态要好。在上面的例子中,setSelection是在渲染期间直接调用的。React将在退出时立即重新渲染List,并且React还没有渲染List的子组件或更新DOM,这允许List的子组件跳过渲染旧的selection值。

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

尽管这种模式比Effect更有效率,但大多数组件也不应该需要它。 无论你怎么做,基于props或其他状态调整状态会使你的数据流更难以理解和调试。始终检查你是否能通过一个key重置所有状态或在渲染期间计算一切。例如,而不是存储(和重置)选定的_项_,你可以存储选定的_项ID_:

1
2
3
4
5
6
7
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// 最好:在渲染期间计算一切
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}

现在没有必要“调整”状态了。如果列表中有选定ID的项,它将保持被选中。如果没有,由于没有找到匹配的项,渲染期间计算的selection将是null。这种行为是不同的,但可以说是更好的,因为大多数对items的更改都会保留选择。

在事件处理器之间共享逻辑

假设你有一个产品页面,上面有两个按钮(购买和结账),它们都可以让你购买该产品。你希望每当用户将产品放入购物车时都显示通知。在两个按钮的点击处理器中调用showNotification()感觉重复,所以你可能会被诱惑将这个逻辑放在Effect中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function ProductPage({ product, addToCart }) {
// 避免:Effect中的事件特定逻辑
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function ProductPage({ product, addToCart }) {
// 好:事件特定逻辑是从事件处理器调用的
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请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// 好:因为组件被展示,所以这个逻辑应该运行
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
// 避免: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请求移动到事件处理器中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// 好:因为组件被展示,所以这个逻辑运行
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// 好:事件特定逻辑在事件处理器中
post('/api/register', { firstName, lastName });
}
// ...
}

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

计算链

有时你可能会想要链式Effect,每个Effect基于其他状态调整一部分状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 避免:仅触发彼此的链式Effect
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);

useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1);
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}
// ...
}

这段代码有两个问题。

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

即使它不慢,随着你的代码发展,你会遇到你写的“链”不适应新要求的情况。想象一下,你正在添加一种通过游戏移动历史的方式。你将通过更新每个状态变量到过去值来实现。然而,将card状态设置为过去的值将再次触发Effect链并更改你正在显示的数据。这样的代码通常很死板且脆弱。

在这种情况下,最好是在渲染期间计算你能计算的一切,并在事件处理器中调整状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// 好:在渲染期间计算你能计算的一切
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// 好:在事件处理器中计算所有下一个状态
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
// ...
}

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

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

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

初始化应用程序

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

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

1
2
3
4
5
6
7
8
function App() {
// 避免:应该只运行一次的Effect逻辑
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}

然而,你很快就会发现它在开发中运行了两次。这可能会导致问题 - 例如,也许它因为该函数没有设计成被调用两次而使认证令牌无效。通常,你的组件应该能够承受被重新挂载。这包括你的顶层App组件。

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

1
2
3
4
5
6
7
8
9
10
11
12
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// 好:每个应用程序加载只运行一次
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}

你还可以在使用应用程序之前在模块初始化期间运行它:

1
2
3
4
5
6
7
8
if (typeof window !== 'undefined') { // 检查我们是否在浏览器中运行。
// 好:每个应用程序加载只运行一次
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}

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

通知父组件状态变化

假设你正在编写一个Toggle组件,它有一个内部的isOn状态,可以是truefalse。有几种不同的方法可以切换它(通过点击或拖动)。你想在Toggle内部状态变化时通知父组件,所以你公开了一个onChange事件,并在Effect中调用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 避免:onChange处理程序运行得太晚了
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,而是在同一个事件处理器中更新两个组件的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
// 好:在引起它们的事件期间执行所有更新
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
// ...
}

这种方法,Toggle组件和它的父组件在事件期间更新它们的状态。React将不同组件的更新批次在一起,所以只会有一次渲染通过。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 好:组件完全由其父组件控制
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}
// ...
}

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

向父组件传递数据

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 避免:在Effect中向父组件传递数据
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}

在React中,数据从父组件流向子组件。当你在屏幕上看到错误的东西时,你可以通过沿着组件链向上追溯,直到找到传递错误prop或有错误状态的组件。当子组件在Effect中更新其父组件的状态时,数据流变得非常难以追踪。由于子组件和父组件都需要相同的数据,让父组件获取数据,并将其_传递下去_给子组件:

1
2
3
4
5
6
7
8
9
function Parent() {
const data = useSomeAPI();
// ...
// 好:向子组件传递数据
return <Child data={data} />;
}
function Child({ data }) {
// ...
}

这更简单,并保持了数据流的可预测性:数据从父组件流向子组件。

订阅外部存储

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function use
OnlineStatus() {
// 不理想:Effect中手动存储订阅
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

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

尽管使用Effect进行此操作很常见,但React有一个专门用于订阅外部存储的Hook,这是首选。删除Effect,用调用useSyncExternalStore替换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// 好:使用内置Hook订阅外部存储
return useSyncExternalStore(
subscribe, // React只要你传递相同的函数就不会重新订阅
() => navigator.onLine, // 如何在客户端获取值
() => true // 如何在服务器端获取值
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}

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

获取数据

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 避免:没有清理逻辑的获取
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}

不需要将这个获取移动到事件处理器。

这可能看起来与你之前的例子相矛盾,在那里你需要将逻辑放入事件处理器!然而,考虑到不是打字事件是获取的主要原因。搜索输入通常从URL预填充,用户可能在没有触摸输入的情况下导航后退和前进。

pagequery来自哪里并不重要。只要这个组件可见,你就想保持results与当前pagequery的网络数据同步。这就是为什么它是一个Effect。

然而,上述代码有一个错误。想象一下你快速地输入”hello”。然后query将从”h”变为”he”,”hel”,”hell”和”hello”。这将启动单独的获取,但没有保证响应将按什么顺序到达。例如,”hell”的响应可能在”hello”响应之后到达。由于它将最后一次调用setResults(),你将显示错误的搜索结果。这被称为“竞态条件”:两个不同的请求“竞争”并以你预期之外的顺序到达。

为了修复竞态条件,你需要添加一个清理函数来忽略过时的响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}

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

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}

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

总的来说,每当你不得不使用Effect时,要留意何时可以将某个功能提取到自定义Hook中,自定义Hook具有更声明性和专门构建的API,如上面的useData。你的组件中原始useEffect调用越少,你就会发现应用程序越容易维护。

总结

  • 如果你可以在渲染期间计算某件事,你就不需要Effect。
  • 要缓存昂贵的计算,请添加useMemo而不是useEffect
  • 要重置整个组件树的状态,传递不同的key
  • 要响应prop变化重置特定状态位,请在渲染期间设置它。
  • 因为组件被_显示_而运行的代码应该在Effect中,其余的应该在事件中。
  • 如果你需要更新几个组件的状态,最好在单个事件中进行。
  • 每当你尝试在不同组件中同步状态变量时,考虑提升状态。
  • 你可以使用Effect获取数据,但你需要实现清理以避免竞态条件。