React 请求取消协议:利用 AbortController 在 React 组件卸载时自动中止待处理网络请求

大家好,我是你们的老朋友,那个发誓再也不写没有 AbortController 的代码的专家。

今天我们不聊那些花里胡哨的框架,也不搞那些虚头巴脑的设计模式。今天我们来聊聊一个稍微有点“脏”的话题:网络请求的身后事

在 React 的世界里,组件就像是一场短暂的派对。用户进来,狂欢,然后离开。派对结束了,是不是该把垃圾带走?是不是该把喝醉的朋友送回家?

如果你的网络请求不懂这个道理,那它就是最烦人的“派对肇事者”。它们在派对(组件)结束后依然赖在舞池里蹦迪,不仅浪费带宽,还可能导致你的应用出现莫名其妙的 Bug,比如“幽灵数据”。

来,把咖啡放下,我们开始这场关于“如何体面地终止请求”的讲座。


第一部分:幽灵请求与内存泄漏

首先,让我们想象一个场景。

你有一个搜索组件。用户在输入框里打字,每次输入,你就发一个请求去服务器查数据。这很正常,对吧?

现在,用户是个急性子,他在输入框里疯狂敲击键盘,输入了 “A”,然后 “B”,然后 “C”。如果没有任何处理,你的组件会瞬间发出去 3 个请求。这还没完,用户可能觉得手滑,又退格删掉了 “C”。这时候,组件卸载了。

但是,那两个没被删掉的请求还在服务器上跑呢。它们就像两个不知死活的幽灵,还在往你的组件里塞数据。

当你再次打开这个组件时,你会得到什么?可能是“C”的数据,可能是“B”的数据,甚至可能是一个乱码。这就是竞态条件

更糟糕的是,如果组件卸载后,服务器还在给这个组件发数据,那个组件其实已经挂了(Unmounted),但它的 state 还在,setState 还在执行。这会导致内存泄漏,甚至可能导致你的应用崩溃。

所以,我们的核心目标只有一个:当组件死亡时,我们要有办法给还在路上的请求发个信号:“嘿,别送了,我不想要了,下车!”

这就是 AbortController 登场的时候了。


第二部分:AbortController 是什么鬼?

在 ES2017 之前,HTTP 请求就像是一列没有刹车的火车。你发车了,就不管了,直到到达终点。如果火车(请求)中途脱轨了(组件卸载),它依然会撞向终点。

AbortController 就是那根刹车线。

它不是魔法,它是一个标准的 Web API。它的核心思想是“信号”。你可以创建一个 AbortSignal,然后把这个信号传递给你的请求。当你想取消请求时,你只需要调用 abort() 方法。

这个方法会触发 AbortSignal 的一个事件,告诉请求:“停!”

在 React 中,我们通常利用 useEffect 的清理函数(cleanup function)来实现这一点。


第三部分:原生 Fetch 的完美实践

让我们先从最标准的 fetch API 开始。这就像是用最原始的食材做饭,但做出来的菜往往最健康。

核心原则:

  1. useEffect 里创建 AbortController
  2. controller.signal 传给 fetch
  3. useEffect 返回的清理函数里调用 controller.abort()

下面是代码,请仔细阅读,这比任何教科书都管用:

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 1. 创建 AbortController 实例
    const controller = new AbortController();
    const signal = controller.signal;

    console.log(`请求用户 ${userId} 的数据...`);

    // 2. 发起请求,传入 signal
    fetch(`https://api.example.com/users/${userId}`, { signal })
      .then(response => {
        if (!response.ok) {
          throw new Error('网络请求失败');
        }
        return response.json();
      })
      .then(data => {
        // 3. 成功拿到数据,更新 state
        setUser(data);
        setError(null);
      })
      .catch(err => {
        // 4. 关键步骤:判断错误类型
        // 如果是 AbortError,说明是组件卸载导致的取消,这不算真正的错误
        if (err.name === 'AbortError') {
          console.log('请求被取消,组件已卸载');
        } else {
          // 真正的网络错误或其他异常
          setError(err.message);
        }
      });

    // 5. 清理函数:组件卸载时执行
    return () => {
      console.log('组件即将卸载,正在发送取消信号...');
      controller.abort();
    };
  }, [userId]); // 依赖项是 userId

  if (error) return <div>错误: {error}</div>;
  if (!user) return <div>加载中...</div>;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>邮箱: {user.email}</p>
    </div>
  );
}

export default UserProfile;

这里有几个点需要特别注意:

  1. controller.abort() 的位置:它必须在 useEffect 返回的函数里。React 会在组件卸载前,先执行这个清理函数。
  2. signal 参数:这是 fetch 接受的第二个参数对象。它告诉 fetch:“嘿,如果这个信号变真了,你就停下。”
  3. AbortError 的处理:这是新手最容易翻车的地方。当你调用 abort() 时,Promise 会 reject,错误对象的名字是 'AbortError'。如果你在 catch 里把它当成真正的网络错误(比如 404 或 500)来处理,你的 UI 就会疯狂闪烁“加载失败”,明明数据是正常的。你必须区分这是“我主动取消的”还是“服务器挂了”。

第四部分:Axios 的“叛逆”与驯服

说到网络请求,谁还没用过 Axios 呢?它是个好孩子,但在“请求取消”这件事上,它有点倔强,不像 Fetch 那么原生。

Fetch 是 W3C 的标准,AbortController 也是标准。但 Axios 是第三方库,它的设计初衷并没有把 AbortController 放在第一位。

不过,Axios 从 v0.22.0 开始就支持 AbortController 了。这就像是你给一匹老马装上了涡轮增压器。

驯服 Axios 的代码示例:

import axios from 'axios';
import React, { useState, useEffect } from 'react';

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const controller = new AbortController();

    const fetchData = async () => {
      setLoading(true);
      try {
        // 1. 传入 signal
        const response = await axios.get('https://api.example.com/products', {
          signal: controller.signal
        });
        setProducts(response.data);
      } catch (err) {
        // 2. 同样,判断 AbortError
        if (axios.isCancel(err)) {
          console.log('Axios 请求被取消:', err.message);
        } else {
          console.error('Axios 请求出错:', err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    return () => {
      controller.abort();
    };
  }, []);

  return (
    <div>
      <h2>商品列表</h2>
      {loading ? <p>正在加载,请稍候...</p> : <ul>{/* 渲染列表 */}</ul>}
    </div>
  );
}

注意那个 axios.isCancel(err) Axios 提供了一个工具函数来判断错误是否是因为取消操作。这比手动判断 err.name === 'AbortError' 要优雅一点,也更符合 Axios 的风格。


第五部分:竞态条件的终极解法

前面我们讲了怎么取消请求。但有时候,取消请求是为了解决更棘手的问题:竞态条件

让我们回到那个“快速点击”的场景。

用户点击了“加载详情”,请求 A 发出去了。然后用户手一抖,或者觉得无聊,又点了一次“加载详情”,请求 B 发出去了。

如果请求 A 的响应回来得比请求 B 慢,而组件又恰好在请求 A 回来之前卸载了(或者请求 B 回来后覆盖了 A),用户看到的可能就是错误的 A 的数据。

这时候,仅仅取消“旧”的请求是不够的。我们需要一种机制,确保我们只处理最新的请求的结果。

策略:

  1. 每次请求开始时,生成一个唯一的 requestId(可以用时间戳)。
  2. setState 或处理数据时,检查当前的数据是否属于这次请求。
  3. 如果不属于(说明是旧请求回来的),直接丢弃。

代码示例:

import React, { useState, useEffect } from 'react';

function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  const [currentRequestId, setCurrentRequestId] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;

    // 生成唯一 ID
    const requestId = Date.now();
    setCurrentRequestId(requestId);
    setLoading(true);
    setResults([]); // 清空旧数据,避免展示过时的结果

    const fetchSearch = async () => {
      try {
        const response = await fetch(`https://api.example.com/search?q=${query}`, { signal });
        const data = await response.json();

        // 核心逻辑:检查是否是最新请求
        if (requestId === currentRequestId) {
          setResults(data);
        } else {
          console.log('丢弃过时的结果:', data);
        }
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error('搜索失败', err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchSearch();

    return () => {
      controller.abort();
    };
  }, [query]); // 只有 query 变化时才发请求

  return (
    <div>
      <input type="text" value={query} onChange={(e) => setQuery(e.target.value)} />
      {loading && <p>搜索中...</p>}
      <ul>
        {results.map(item => <li key={item.id}>{item.title}</li>)}
      </ul>
    </div>
  );
}

export default SearchComponent;

在这个例子中,每次 query 变化,我们都更新 currentRequestId。当数据回来时,我们比对 requestId。如果发现回来的数据不是最新的(ID 不匹配),我们就直接 console.log 丢弃它。

这就像是你在排队买票,前面的人插队了,虽然你买了票,但如果插队的人先拿到了票,你就得把票扔了,因为那是别人的票。


第六部分:自定义 Hook 封装的艺术

如果你在多个组件里都这么写 useEffect + AbortController,你会发现代码重复率高达 80%。作为一名资深专家,我们怎么能容忍这种低级重复呢?

我们需要封装一个自定义 Hook。

这个 Hook 应该接收 urloptions,然后返回 data, error, loading

让我们来编写 useFetchWithAbort

import { useState, useEffect } from 'react';

/**
 * 一个支持 AbortController 的自定义 Hook
 * @param {string} url - 请求地址
 * @param {object} options - fetch 的额外配置
 */
export function useFetchWithAbort(url, options = {}) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const controller = new AbortController();
    const signal = controller.signal;

    const fetchData = async () => {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(url, { ...options, signal });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const json = await response.json();
        setData(json);
      } catch (err) {
        // 如果是取消错误,我们通常不设置 error 状态,因为这不是真正的错误
        if (err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    return () => {
      controller.abort();
    };
  }, [url, options]); // 依赖项

  return { data, error, loading };
}

使用示例:

import React from 'react';
import { useFetchWithAbort } from './hooks/useFetchWithAbort';

function Articles() {
  // 使用 Hook,代码瞬间清爽
  const { data: articles, loading, error } = useFetchWithAbort(
    'https://jsonplaceholder.typicode.com/posts'
  );

  if (loading) return <div>正在加载文章...</div>;
  if (error) return <div>加载失败: {error.message}</div>;

  return (
    <ul>
      {articles.map(article => (
        <li key={article.id}>{article.title}</li>
      ))}
    </ul>
  );
}

现在,你的代码里到处都是这种优雅的 Hook,而不再是那些像意大利面一样纠缠不清的 useEffect 块。


第七部分:那些不该中止的请求

虽然我们极力推崇“请求取消”,但凡事都有例外。在 React 的某些特殊场景下,过早地中止请求可能会导致问题。

1. 服务端渲染 (SSR) 的陷阱

如果你在 Next.js 或 Gatsby 中使用 useEffect,并依赖 useEffect 的清理函数来取消请求,你可能会遇到麻烦。

在 SSR(服务端渲染)过程中,useEffect 里的代码根本不会执行。这意味着清理函数也不会执行。如果组件在服务端渲染,然后被卸载(比如用户跳转了页面),那么在服务端发起的请求(如果有的话)可能永远不会被取消。

解决方案:
useEffect 中判断 typeof window !== 'undefined',或者使用 isMounted 标志位。

useEffect(() => {
  let isMounted = true;

  const fetchData = async () => {
    // ...
    if (isMounted) {
      setData(data);
    }
  };

  fetchData();

  return () => {
    isMounted = false;
    controller.abort();
  };
}, []);

2. 流式传输

如果你在处理像 SSE(Server-Sent Events)或 ReadableStream 这样的流式数据,AbortController 可能会切断数据流。一旦流被切断,你就无法再接收后续的数据了。

在这种情况下,你需要更精细的控制。你可能需要保留连接,只是停止处理数据,或者使用流式读取器来优雅地关闭。

3. 重复请求的“取消”逻辑

有时候,我们希望“旧”的请求被取消,而“新”的请求继续。但在某些复杂的业务逻辑中(比如订阅服务),你可能希望两个请求同时进行(虽然这通常不是好主意)。


第八部分:进阶技巧与最佳实践

作为一名“资深专家”,我觉得有必要分享一些我在生产环境踩过的坑和总结的经验。

1. 请求 ID 的管理

在处理复杂列表或无限滚动时,管理请求 ID 非常重要。不要只用 Date.now(),它可能不够精确。你可以用一个计数器,或者在组件外部维护一个 Map。

2. 不要滥用 AbortController

如果你发起了 100 个请求,然后组件卸载,你就要调用 100 次 abort()。这会触发 100 个 Promise 的 reject。虽然浏览器处理这个很快,但如果你在 catch 块里没有正确过滤 AbortError,你的控制台会瞬间被红色的错误日志淹没。

最佳实践: 在自定义 Hook 中封装好 AbortError 的过滤逻辑,不要把脏活累活暴露给业务组件。

3. TypeScript 类型支持

如果你使用 TS,记得给 AbortControllerAbortSignal 定义类型,或者在代码中明确注释。

interface AbortControllerLike {
  signal: {
    aborted: boolean;
    onabort: ((event: Event) => void) | null;
  };
  abort(): void;
}

4. 调试技巧

当你怀疑有请求没有被取消时,可以在 controller.abort() 之前加一个 console.log('Aborting request...')。然后在网络面板里观察请求的状态。

通常,你会发现请求的状态会变成 Cancel。这是一个很棒的调试信号。


第九部分:React Query / SWR 的视角

现在,很多项目都在用 TanStack Query (React Query) 或 SWR。它们会自动处理请求缓存、去重和取消。

但是,理解底层的 AbortController 机制对于调试 TanStack Query 的错误至关重要。

当你看到 TanStack Query 抛出一个错误,你可以检查错误对象里是否有 cause 属性。如果 cause 是一个 AbortError,那就意味着这个请求是因为组件卸载被取消的,而不是因为数据过期或网络错误。

结论:
不要因为用了 React Query 就觉得可以忽略 AbortController。了解它,能让你在面对那些“明明有缓存为什么还要重新请求”或者“为什么报错”的奇怪问题时,一眼看穿真相。


第十部分:总结与最后的唠叨

好了,朋友们,我们的时间差不多了。

我们今天深入探讨了 React 中网络请求的“身后事”。我们学会了如何使用 AbortController 来在组件卸载时优雅地终止请求。

记住这三点:

  1. 永远不要让请求在组件卸载后继续执行。 这是 React 开发的铁律。
  2. 永远要区分 AbortError 和真正的网络错误。 这能救你的 UI 于水火之中。
  3. 封装是王道。AbortController 的逻辑封装进 useEffect 里,或者封装进一个自定义 Hook,让你的业务代码保持干净。

网络请求就像是你的快递员。当你的房子(组件)被拆了,快递员还在往里面塞包裹,那不仅浪费钱,还会把你的地基搞坏。

所以,下次写代码的时候,记得给 fetchaxios 递上一根绳子(AbortController),在组件离开的时候,把它拉回来。

祝你们的网络请求都能体面地到达终点,绝不留下任何垃圾!

谢谢大家!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注