原文链接:Fixing Race Conditions in React with useEffect,by Max Rozen (@RozenMD)
你有一个在 React 组件中获取数据的需求。这个组件接受 id
作为 prop
,使用 id
通过 useEffect
获取并展示数据。
你注意到了一些奇怪的事情:这个组件有时候会正确地展示数据,而有时候展示的却是无效数据或旧数据。
很有可能,你是遇到了 竞态条件 问题。
通常,在两个差别不大的数据请求发出后,如果应用程序是根据哪个请求先完成就显示哪个数据的话,就会遇到竞态条件问题。
问题复现
在 使用 [useEffect](https://maxrozen.com/fetching-data-react-with-useeffect/)
获取数据 时,如果 id
变化得足够快,那么我们编写的组件就可能会存在竞态条件问题:
import React, { useEffect, useState } from 'react';
export default function DataDisplayer(props) {
const [data, setData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`);
const newData = await response.json();
setData(newData);
};
fetchData();
}, [props.id]);
if (data) {
return <div>{data.name}</div>;
} else {
return null;
}
}
上面的代码片段就很容易受到竞态条件的影响。因为看起来可能不是很明显,所以我创建了一个 CodeSandbox 示例方便大家来观察(我为了每个请求添加了随机响应时间(最多 12 秒))。
如果只是简单点击了一次“Fetch data!”按钮,我们能看到预期行为——一个简单的组件,在单击后顺利展示了响应回来的数据。
但是如果你快速点击“Fetch data!”按钮几次,情况会变得复杂。这个组件会同时发出多个请求,并且这些请求都是以随机顺序响应的,组件最终展示的是最后结束的那个请求的响应数据。
示例中的 DataDisplayer
组件代码如下:
export default function DataDisplayer(props) {
const [data, setData] = useState(null);
const [fetchedId, setFetchedId] = useState(null);
useEffect(() => {
const fetchData = async () => {
+ setTimeout(async () => {
const response = await fetch(
`https://swapi.dev/api/people/${props.id}/`
);
const newData = await response.json();
setFetchedId(props.id);
setData(newData);
+ }, Math.round(Math.random() * 12000));
};
fetchData();
}, [props.id]);
if (data) {
return (
<div>
<p style={{ color: fetchedId === props.id ? 'green' : 'red' }}>
Displaying Data for: {fetchedId}
</p>
<p>{data.name}</p>
</div>
);
} else {
return null;
}
}
修复 Effect 中的竞态条件问题
我们可以采用两种方法来解决竞态条件问题,都用到了 useEffect
的清理函数:
- 如果我们能够接受发出多个请求,只呈现最后一个结果,则可以借助一个布尔标志
- 或者,如果无需支持 Internet Explorer 的用户,就可以使用 AbortController 取消旧请求
使用带有布尔标志的 useEffect 清理函数
首先,我们在代码中修复的要点是:
import React, { useEffect, useState } from 'react';
export default function DataDisplayer(props) {
const [data, setData] = useState(null);
useEffect(() => {
+ let ignore = false;
const fetchData = async () => {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`);
const newData = await response.json();
+ if (!ignore) {
setData(newData);
}
};
fetchData();
+ return () => {
+ ignore = true;
+ }
}, [props.id]);
if (data) {
return <div>{data.name}</div>;
} else {
return null;
}
}
这个修复依赖于 React Hooks API 参考文档 中经常被忽视的一句话:
此外,如果一个组件被渲染多次(通常是这样的),在执行下一个 effect 之前会清除上一个 effect。 (译注:即对于非首次渲染,React 会先执行清理函数(cleanup function),再去执行启动函数(setup function))
在上面的示例中:
- 更改
props.id
将导致组件重新渲染 - 每次重新渲染都会触发清理函数执行,将
ignore
设置为true
- 由于
ignore
被设置为true
,旧请求响应数据会被忽略设置,无法更新状态了
当然竞争条件问题仍然存在,因为多个请求仍在进行中,只不过只有最后一个请求的结果会被使用。
清理函数的效果看起来可能不是很明显,所以我创建了一个 CodeSandbox 示例方便大家来观察(我还添加了计数器来跟踪活动的请求数量和一些辅助函数)。
使用 AbortController 的 useEffect 清理函数
再次,让我们从代码开始:
import React, { useEffect, useState } from 'react';
export default function DataDisplayer(props) {
const [data, setData] = useState(null);
useEffect(() => {
+ const abortController = new AbortController();
const fetchData = async () => {
try {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`, {
+ signal: abortController.signal,
});
const newData = await response.json();
setData(newData);
} catch (error) {
+ if (error.name === 'AbortController') {
+ // 中止获取操作会抛出一个错误
+ // 因此我们不会更新旧响应数据
+ }
// 在此处理其他请求错误
}
};
fetchData();
+ return () => {
+ abortController.abort();
+ }
}, [props.id]);
if (data) {
return <div>{data.name}</div>;
} else {
return null;
}
}
和前面的例子一样,我们利用了 React 在执行下一个 effect 之前运行清理函数的特性。你也可以查看 CodeSandbox(这次我们不计算请求数量,因为同一时间只会有一个请求)。
然而,这一次我们:
- 在 effect 开始时初始化了一个
AbortController
实例 - 通过 options 参数将
AbortController.signal
传递给fetch
- 在清理函数中调用
abort()
函数 - 捕获任何抛出的 AbortErrors(当调用
abort()
时,fetch()
将返回一个携带 AbortError 的 rejected 状态的 Promise,具体 请参阅 MDN 文档)
使用这种方案我们就要放弃对 Internet Explorer 的支持;或者使用 polyfill,获得取消正在进行中的 HTTP 请求的能力。
就我个人而言,我很幸运地为一家不再支持 Internet Explorer 的公司工作,因此我更喜欢避免浪费用户带宽,并使用 AbortController。