清理 useEffect Hooks 中的异步请求
在我之前的文章中,我们讨论了如何用钩子替换一些组件生命周期函数useEffect
,useReducer
同时使资源获取逻辑在应用程序中可重复使用。
我们最后得到的自定义钩子如下所示:
export const useGet = ({ url }) => {
const [state, dispatch] = useReducer(reducer, {
isLoading: true,
data: null,
error: null,
});
useEffect(() => {
const fetchData = async () => {
dispatch(requestStarted());
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`${response.status} ${response.statusText}`
);
}
const data = await response.json();
dispatch(requestSuccessful({ data }));
} catch (e) {
dispatch(requestFailed({ error: e.message }));
}
};
fetchData();
}, [url]);
return state;
};
看起来挺简洁的,对吧?然而它有一个严重的缺陷——如果fetch
请求很慢,并且异步请求完成时组件已经卸载,你将看到来自 React 的以下错误消息:
或者——它可能会出现一个严重的问题——想象一下,使用这个钩子的组件在请求完成之前收到了一个不同的 ID——所以它尝试从新的请求中获取数据url
,而第二个请求在第一个请求之前几毫秒就完成了——会发生什么?你的组件将显示第一个请求的数据!
这些“伟大”的async/await
语法糖可能会让你的代码看起来像是同步的,但实际上它们只是语法糖——await
即使你的组件不再存在于页面上,你之后的代码仍然会被执行。每当我们想要在异步函数中更新状态时,都应该小心谨慎。
我们如何防止这种情况发生?首先,我们应该始终尝试清理我们的效果。
清理功能
如果你还不知道的话,你可以在钩子函数的末尾返回一个函数useEffect
。每当该 effect 再次触发时(例如,当其依赖项的值发生更改时),以及在组件卸载之前,都会调用该函数。所以,如果你有一个useEffect
如下所示的钩子函数:
useEffect(() => {
// logic here
return () => {
// clean up
};
}, []); // no dependencies!
它实际上做的事情和下面的代码完全相同:
class SomeComponent extends React.Component {
componentDidMount() {
// logic here
}
componentWillUnmount() {
// clean up
}
}
如果您将事件监听器附加到window
、document
或其他 DOM 元素,则可以removeEventListener
在清理函数中使用 来移除它们。同样,您可以使用setTimeout
/setInterval
来清理clearTimeout
/ clearInterval
。
一个简单的解决方案
了解了这一点,您可能会想:哦,好吧,这太好了,我们可以设置一个标志,当组件卸载时将其设置为 false,这样我们就可以跳过所有状态更新。
你是对的,这确实是解决这个问题的一个非常简单的方法:
useEffect(() => {
let isCancelled = false;
const fetchData = async () => {
dispatch(requestStarted());
try {
// fetch logic omitted...
const data = await response.json();
if (!isCancelled) {
dispatch(requestSuccessful({ data }));
}
} catch (e) {
if (!isCancelled) {
dispatch(requestFailed({ error: e.message }));
}
}
};
fetchData();
return () => {
isCancelled = true;
};
}, [url]);
在此代码中,每当运行新的 effect(或卸载组件)时,前一个 effect 的isCancelled
都会设置为true
,并且我们仅在其状态为 时才更新状态false
。这确保您的requestSuccessful
和requestFailed
操作仅在最新请求时分派。
任务完成!...?
但你真的应该这样做
不过,还有更好的方法。上面的代码没问题,但是,如果你的fetch
请求真的很慢,即使你不再需要结果,它仍然会在后台运行,等待响应。你的用户可能会四处点击,留下一堆过时的请求——你知道吗?同时处理的并发请求数量是有限制的——通常为 6 到 8 个,具体取决于用户使用的浏览器。(不过,这仅适用于 HTTP 1.1,由于 HTTP/2 和多路复用技术,情况正在发生变化,但这是另一个话题。)过时的请求会阻塞浏览器执行的新请求,从而使你的应用速度更慢。
值得庆幸的是,DOM API 中新增了一项功能,AbortController
允许你取消fetch
请求!大多数浏览器都支持该功能(IE11 除外),我们绝对应该好好利用它。
AbortController
使用起来非常简单。你可以像这样创建一个新的:
const myAbortController = new AbortController();
您将在实例上发现两个字段:myAbortController.signal
和myAbortController.abort()
。signal
将提供给fetch
您想要取消的调用,并且当abort
调用时该fetch
请求将被取消。
fetch(url, { signal: myAbortController.signal });
// call the line below to cancel the fetch request above.
myAbortController.abort();
如果请求已经完成,abort()
则不会执行任何操作。
太棒了,现在我们可以将它应用到我们的钩子上:
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
dispatch(requestStarted());
try {
fetch(url, { signal: abortController.signal });
// code omitted for brevity
dispatch(requestSuccessful({ data }));
} catch (e) {
dispatch(requestFailed({ error: e.message }));
}
};
fetchData();
return () => {
abortController.abort();
};
}, [url]);
现在,我们的fetch
请求将会针对每个新效果或在组件卸载之前被立即取消。
处理已取消的请求
不过还有一点小问题——当请求被取消时,它实际上会抛出一个错误,所以我们的catch
块会被执行。在这种情况下,我们可能不想分发action。幸运的是,我们可以通过检查实例上的requestFailed
来判断请求是否已被中止。signal
AbortController
让我们在我们的catch
区块中做到这一点:
try {
// ...
} catch (e) {
// only call dispatch when we know the fetch was not aborted
if (!abortController.signal.aborted) {
dispatch(requestFailed({ error: e.message }));
}
}
总结
现在我们的钩子可以正确地自行清理了!如果你的钩子执行了一些异步操作,大多数情况下都应该进行适当的清理,以避免任何不必要的副作用。
如果您正在使用fetch
,则abort
您的请求将在清理函数中执行。一些第三方库也提供了取消请求的方法(例如CancelToken
from axios
)。
如果您想要支持旧版浏览器,或者您的效果不使用fetch
,而是使用一些其他异步操作(如Promise
),在可取消Promise
s 成为现实之前,请改用isCancelled
flag 方法。
资源
https://developer.mozilla.org/en-US/docs/Web/API/AbortController
https://reactjs.org/docs/hooks-effect.html
鏂囩珷鏉ユ簮锛�https://dev.to/pallymore/clean-up-async-requests-in-useeffect-hooks-90h