清理 useEffect Hooks 中的异步请求

2025-06-10

清理 useEffect Hooks 中的异步请求

在我之前的文章中,我们讨论了如何用钩子替换一些组件生命周期函数useEffectuseReducer同时使资源获取逻辑在应用程序中可重复使用。

https://dev.to/pallymore/refactoring-an-old-react-app-creating-a-custom-hook-to-make-fetch-related-logic-reusable-2cd9

我们最后得到的自定义钩子如下所示:



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;
};


Enter fullscreen mode Exit fullscreen mode

看起来挺简洁的,对吧?然而它有一个严重的缺陷——如果fetch请求很慢,并且异步请求完成时组件已经卸载,你将看到来自 React 的以下错误消息:

反应错误

或者——它可能会出现一个严重的问题——想象一下,使用这个钩子的组件在请求完成之前收到了一个不同的 ID——所以它尝试从新的请求中获取数据url,而第二个请求在第一个请求之前几毫秒就完成了——会发生什么?你的组件将显示第一个请求的数据!

这些“伟大”的async/await语法糖可能会让你的代码看起来像是同步的,但实际上它们只是语法糖——await即使你的组件不再存在于页面上,你之后的代码仍然会被执行。每当我们想要在异步函数中更新状态时,都应该小心谨慎。

我们如何防止这种情况发生?首先,我们应该始终尝试清理我们的效果。

清理功能

如果你还不知道的话,你可以在钩子函数的末尾返回一个函数useEffect。每当该 effect 再次触发时(例如,当其依赖项的值发生更改时),以及在组件卸载之前,都会调用该函数。所以,如果你有一个useEffect如下所示的钩子函数:



useEffect(() => {
  // logic here

  return () => {
    // clean up
  };
}, []); // no dependencies!


Enter fullscreen mode Exit fullscreen mode

它实际上做的事情和下面的代码完全相同:



class SomeComponent extends React.Component {
  componentDidMount() {
    // logic here
  }

  componentWillUnmount() {
    // clean up
  }
}


Enter fullscreen mode Exit fullscreen mode

如果您将事件监听器附加到windowdocument或其他 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]);


Enter fullscreen mode Exit fullscreen mode

在此代码中,每当运行新的 effect(或卸载组件)时,前一个 effect 的isCancelled都会设置为true,并且我们仅在其状态为 时才更新状态false。这确保您的requestSuccessfulrequestFailed操作仅在最新请求时分派。

任务完成!...?

但你真的应该这样做

不过,还有更好的方法。上面的代码没问题,但是,如果你的fetch请求真的很慢,即使你不再需要结果,它仍然会在后台运行,等待响应。你的用户可能会四处点击,留下一堆过时的请求——你知道吗?同时处理的并发请求数量是有限制的——通常为 6 到 8 个,具体取决于用户使用的浏览器。(不过,这仅适用于 HTTP 1.1,由于 HTTP/2 和多路复用技术,情况正在发生变化,但这是另一个话题。)过时的请求会阻塞浏览器执行的新请求,从而使你的应用速度更慢。

值得庆幸的是,DOM API 中新增了一项功能,AbortController允许你取消fetch请求!大多数浏览器都支持该功能(IE11 除外),我们绝对应该好好利用它。

AbortController使用起来非常简单。你可以像这样创建一个新的



const myAbortController = new AbortController();


Enter fullscreen mode Exit fullscreen mode

您将在实例上发现两个字段:myAbortController.signalmyAbortController.abort()signal将提供给fetch您想要取消的调用,并且当abort调用时该fetch请求将被取消。



fetch(url, { signal: myAbortController.signal });

// call the line below to cancel the fetch request above.
myAbortController.abort(); 


Enter fullscreen mode Exit fullscreen mode

如果请求已经完成,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]);


Enter fullscreen mode Exit fullscreen mode

现在,我们的fetch请求将会针对每个新效果或在组件卸载之前被立即取消。

处理已取消的请求

不过还有一点小问题——当请求被取消时,它实际上会抛出一个错误,所以我们的catch块会被执行。在这种情况下,我们可能不想分发action。幸运的是,我们可以通过检查实例上的requestFailed来判断请求是否已被中止signalAbortController

让我们在我们的catch区块中做到这一点:



try {
// ...
} catch (e) {
// only call dispatch when we know the fetch was not aborted
if (!abortController.signal.aborted) {
dispatch(requestFailed({ error: e.message }));
}
}

Enter fullscreen mode Exit fullscreen mode




总结

现在我们的钩子可以正确地自行清理了!如果你的钩子执行了一些异步操作,大多数情况下都应该进行适当的清理,以避免任何不必要的副作用。

如果您正在使用fetch,则abort您的请求将在清理函数中执行。一些第三方库也提供了取消请求的方法(例如CancelTokenfrom axios)。

如果您想要支持旧版浏览器,或者您的效果不使用fetch,而是使用一些其他异步操作(如Promise),在可取消Promises 成为现实之前,请改用isCancelledflag 方法。

资源

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
PREV
Async Await 从初学者到高级功能包括并发 Async Await 教程为什么?
NEXT
CSR、SSR、SSG 和 ISR 的直观解释和比较