AbortController 深度实战:从 fetch 取消到复杂异步流程控制

全面解析 JavaScript AbortController 与 AbortSignal 的高级用法,涵盖 fetch 取消、竞态条件处理、超时控制、信号组合、React 清理集成等生产级模式,附完整代码示例与性能对比。

前端开发 2026-05-30 18 分钟

你写过多少次 fetch 却从来没处理过取消逻辑?根据 HTTP Archive 2025 年的统计,超过 87% 的前端 fetch 调用没有关联 AbortSignal,这意味着用户快速切换页面时,旧请求仍在后台运行,轻则浪费带宽和服务器资源,重则导致状态竞态(Race Condition)——用户看到的数据来自过期请求,界面展示的内容与预期完全不一致。AbortController 是浏览器原生提供的异步取消基础设施,从 ES2017 引入到如今已经经历了近十年的演进,新增了 AbortSignal.timeout()AbortSignal.any() 等强大方法。但大多数开发者只知道 signal 传给 fetch 就完事了,完全没有意识到它在整个异步编程体系中的战略地位。本文将从核心机制到生产级模式,系统性地拆解 AbortController 的全部能力,让你的异步代码从此具备优雅的取消和清理能力。

🔌 一、AbortController 核心机制

AbortController 与 AbortSignal 的关系

AbortController 是一个极其精简的设计模式——Controller 负责触发取消,Signal 负责传播取消。这种发布-订阅(Publish-Subscribe)模式的核心优势在于解耦:触发取消的一方不需要知道有多少个消费者在监听,而消费者也不需要了解取消是如何产生的。这与 Go 语言的 context.Context 的设计理念一脉相承,都是为了让取消信号能够在复杂的调用链中自然传播。

从实现层面看,AbortController 内部只持有一个 AbortSignal 实例的引用。当你调用 controller.abort() 时,它会将 signal 的 aborted 标志设为 true,保存 reason,然后触发 signal 上所有注册的 abort 事件监听器。这个过程是同步的——一旦调用 abort(),所有监听器会在同一个微任务中依次执行。

// AbortController 基础结构与信号传播机制
const controller = new AbortController();
const signal = controller.signal;

// signal 是一个标准的 EventTarget,可以监听 'abort' 事件
// 多个监听器可以同时注册,它们会按照注册顺序依次执行
signal.addEventListener('abort', () => {
  console.log('第一个监听器 - 已取消:', signal.reason);
});

signal.addEventListener('abort', () => {
  console.log('第二个监听器 - aborted 状态:', signal.aborted);
});

// controller.abort() 触发取消,参数作为 reason 存储
controller.abort('用户主动取消');

// abort 之后,signal 永久处于已取消状态
console.log(signal.aborted);  // true
console.log(signal.reason);   // '用户主动取消'

// 再次调用 abort() 不会报错,但也不会再次触发事件
controller.abort('第二次调用无效');

以下是 AbortController 和 AbortSignal 的完整 API 一览:

属性/方法 所属 类型 说明
controller.abort(reason?) Controller 方法 触发取消,可传入任意类型的 reason
controller.signal Controller 属性 返回关联的 AbortSignal 实例
signal.aborted Signal 只读属性 布尔值,表示是否已取消
signal.reason Signal 只读属性 取消原因,未取消时为 undefined
signal.throwIfAborted() Signal 方法 若已取消则抛出 reason(默认 AbortError)
signal.onabort Signal 事件处理器 取消事件的回调属性
AbortSignal.timeout(ms) Signal 静态 方法 创建超时自动取消的信号
AbortSignal.any(signals) Signal 静态 方法 组合多个信号,任一触发即触发
AbortSignal.abort(reason?) Signal 静态 方法 创建一个立即已取消的信号

📌 记住: AbortController 是一次性消费品——一旦调用 abort(),信号永远处于 aborted 状态,无法重置或复用。如果你需要"可重置"的取消逻辑(比如一个可反复启动和停止的定时器),每次操作都必须创建全新的 Controller。不要试图缓存或复用已经 abort 过的 controller。

fetch 的取消语义与错误处理

当 fetch 请求关联的 AbortSignal 被触发时,fetch 的行为取决于请求当时所处的阶段。理解这些阶段对正确处理错误至关重要:

阶段一:请求尚未发出——如果在 fetch 调用之前 signal 已经处于 aborted 状态,fetch 会立即 reject 一个 AbortError,网络层不会发出任何请求。这个行为对于那些在组件卸载后才到达的延迟操作特别有用。

阶段二:请求已发出,等待响应中——浏览器会中断底层的 HTTP 连接(发送 RST 或关闭 TCP 连接),然后 reject 一个 AbortError。此时服务器可能已经收到了请求并在处理中,但客户端不会再接收响应。

阶段三:响应已到达,正在读取 body——如果在 response.json()response.text() 或通过 ReadableStream 读取响应体的过程中 signal 被触发,读取操作会被中断并 reject。

// fetch 取消的完整错误处理模式
async function fetchWithProperAbort(url, signal) {
  try {
    const response = await fetch(url, { signal });

    // 注意:response.json() 也可能被取消
    // 如果 signal 在 json() 执行期间被触发
    const data = await response.json();
    return data;
  } catch (err) {
    // 统一用 err.name 判断,不要匹配 err.message
    // 因为不同浏览器的错误消息完全不同:
    // Chrome:  "The user aborted a request."
    // Firefox: "The operation was aborted."
    // Safari:  "The operation couldn't be completed. (kCFErrorDomainCFNetwork error -999.)"
    if (err.name === 'AbortError') {
      console.log('请求被取消:', signal.reason);
      return null;
    }

    // AbortSignal.timeout() 产生的错误类型是 TimeoutError
    // 注意这不是 AbortError,需要单独处理
    if (err.name === 'TimeoutError') {
      console.log('请求超时,服务端可能繁忙或网络不稳定');
      return null;
    }

    // 其他网络错误正常抛出
    throw err;
  }
}

⚠️ 警告: AbortErrorerr.message 在不同浏览器中完全不同。Chrome 是 “The user aborted a request.”,Firefox 是 “The operation was aborted.”,Safari 的措辞又不一样。永远用 err.name === 'AbortError' 来判断取消,绝对不要匹配 err.message。同理,超时错误用 err.name === 'TimeoutError' 判断。

🏎️ 二、六大生产级模式

模式一:搜索框防抖 + 竞态消除

这是 AbortController 最经典也最实用的场景。想象一个电商网站的搜索框:用户快速输入 “mackbook” 时,每个字符都会触发搜索请求。如果没有取消机制,可能出现以下竞态——用户输入 “mack” 时发出请求 A,继续输入到 “mackbook” 后发出请求 B。如果请求 A 因为网络延迟反而比请求 B 更晚返回,那么最终渲染的是 “mack” 的搜索结果,而非用户期望的 “mackbook” 结果。防抖(Debounce)减少请求次数,取消(Abort)消除请求竞态,两者配合使用才能保证搜索体验既高效又正确。

// 搜索框竞态消除 —— 生产环境中最常用的 AbortController 模式
let searchController = null;

async function searchProducts(query) {
  // 取消上一次未完成的请求
  // 如果上一次请求已经完成,abort() 是无害的空操作
  if (searchController) {
    searchController.abort('新搜索取代旧搜索');
  }

  // 每次搜索创建新的 controller
  // 旧 controller 会被 GC 自动回收
  searchController = new AbortController();

  try {
    const response = await fetch(
      `/api/search?q=${encodeURIComponent(query)}&limit=20`,
      { signal: searchController.signal }
    );

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

    const results = await response.json();

    // 渲染搜索结果
    renderSearchResults(results);
    updateResultCount(results.total);
  } catch (err) {
    // AbortError 表示被新请求取代,静默忽略
    if (err.name === 'AbortError') {
      return;
    }
    // 真正的错误才提示用户
    showError('搜索失败,请重试');
  }
}

// 配合防抖使用:用户停止输入 300ms 后才发起搜索
const debouncedSearch = debounce((query) => {
  if (query.length >= 2) {
    searchProducts(query);
  } else {
    clearSearchResults();
    hideSuggestions();
  }
}, 300);

document.getElementById('search-input')
  .addEventListener('input', (e) => debouncedSearch(e.target.value));

// 简单但可靠的防抖实现
function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

模式二:AbortSignal.timeout() 超时控制

AbortSignal.timeout() 出现之前,实现请求超时需要手动创建 controller 和 setTimeout,还要在 finally 中清理 timer,代码繁琐且容易出错。ES2023 引入的这个静态方法彻底改变了局面:一行代码就能创建超时信号,无需手动清理,底层由浏览器自动管理定时器的生命周期。

// ✅ 现代写法:AbortSignal.timeout() 一行搞定
// 浏览器自动管理定时器,无需手动清理
async function fetchWithTimeout(url, timeoutMs = 5000) {
  const response = await fetch(url, {
    signal: AbortSignal.timeout(timeoutMs)
  });
  return response.json();
}

// ❌ 旧写法:手动管理 setTimeout(容易泄漏且代码臃肿)
async function fetchWithTimeoutOld(url, timeoutMs = 5000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } finally {
    // 如果忘记 clearTimeout,timer 会阻止 GC 回收 controller
    clearTimeout(timer);
  }
}

超时信号的一个关键细节需要特别注意:AbortSignal.timeout() 创建的信号的 reason 类型是 TimeoutError,而不是 AbortError。这是因为它们代表不同的取消原因——超时是自动的系统行为,而 AbortError 通常是用户或代码主动触发的。在实际项目中,你可能需要向用户展示不同的错误信息:超时时说"请求超时,请检查网络",手动取消时则完全不展示错误。

// AbortSignal.timeout 的错误类型是 TimeoutError,不是 AbortError
const signal = AbortSignal.timeout(100);
signal.addEventListener('abort', () => {
  console.log(signal.reason.name); // 'TimeoutError'
  console.log(signal.reason instanceof DOMException); // true
});

// 在 catch 中精确区分超时和其他取消,给用户不同反馈
async function robustFetch(url, timeoutMs = 5000) {
  try {
    return await fetch(url, {
      signal: AbortSignal.timeout(timeoutMs)
    });
  } catch (err) {
    if (err.name === 'TimeoutError') {
      // 超时:提示用户检查网络或稍后重试
      showToast('请求超时,服务端可能繁忙');
    } else if (err.name === 'AbortError') {
      // 手动取消:不展示错误,静默处理
      return null;
    } else {
      // 其他网络错误
      showToast('网络错误: ' + err.message);
    }
    throw err;
  }
}

模式三:AbortSignal.any() 信号组合

AbortSignal.any() 是一个强大的组合原语,它接收一个 AbortSignal 数组,返回一个新的组合信号——数组中任意一个信号触发时,组合信号也会立即触发。这解决了"我的操作需要同时响应多种取消原因"的需求,比如一个 API 请求可能因为用户手动取消、请求超时、或页面卸载而需要终止。

// 典型场景:用户手动取消 + 10秒超时,任意一个触发就取消
async function fetchCriticalData(url) {
  const userController = new AbortController();

  // 组合两个信号:用户取消 和 超时
  // 任意一个先触发,整个请求就会被取消
  const combinedSignal = AbortSignal.any([
    userController.signal,
    AbortSignal.timeout(10_000)
  ]);

  // 显示取消按钮供用户手动操作
  const cancelBtn = showCancelButton(() => {
    userController.abort('用户点击取消');
  });

  try {
    const response = await fetch(url, { signal: combinedSignal });
    return await response.json();
  } finally {
    cancelBtn.remove();
  }
}

信号组合的另一个实用模式是级联取消:当父操作被取消时,所有关联的子操作也应该被取消。这在复杂的多请求场景中非常常见,比如一个页面同时发起了用户信息、订单列表、推荐内容三个请求,用户离开页面时应该一次取消所有请求。

// 级联取消:一个页面的多个请求绑定同一个 controller
class PageRequestManager {
  #controller = null;

  // 页面初始化时创建 controller
  init() {
    this.#controller = new AbortController();
  }

  // 所有请求共享同一个 signal
  // controller.abort() 时,所有未完成的请求都会被取消
  async fetchUsers() {
    return fetch('/api/users', {
      signal: this.#controller.signal
    }).then(r => r.json());
  }

  async fetchOrders() {
    return fetch('/api/orders', {
      signal: this.#controller.signal
    }).then(r => r.json());
  }

  async fetchProducts() {
    return fetch('/api/products', {
      signal: this.#controller.signal
    }).then(r => r.json());
  }

  // 页面离开时一次取消所有未完成的请求
  destroy() {
    this.#controller?.abort('页面已卸载');
  }
}

// 使用示例:并行请求,统一取消
const pageRequests = new PageRequestManager();
pageRequests.init();

const [users, orders, products] = await Promise.all([
  pageRequests.fetchUsers(),
  pageRequests.fetchOrders(),
  pageRequests.fetchProducts(),
]);

// 页面卸载时清理所有请求
window.addEventListener('beforeunload', () => pageRequests.destroy());

⚠️ 警告: AbortSignal.any() 在 Node.js 20.3+ 和 Chrome 116+ 中可用。如果你的项目需要兼容更旧的环境,可以使用以下 polyfill:创建一个新 controller,遍历所有传入的 signal,为每个注册 abort 事件监听器,监听器中调用新 controller 的 abort() 方法。同时检查是否有 signal 已经处于 aborted 状态,如果是则立即取消。

模式四:React useEffect 清理中的 AbortController

React 的 useEffect 清理函数是 AbortController 在前端项目中最容易被忽视的使用场景。在 React 18 的 Strict Mode 下,开发环境中 effect 会执行两次(mount → unmount → mount),这是为了帮助开发者发现缺少清理函数的 bug。如果不正确处理 AbortController,第一次请求的结果会在 unmount 后到达并尝试更新已卸载的组件,导致"幽灵更新"——界面上显示了不属于当前状态的数据。

// ✅ 正确的 React 数据获取模式:每个 effect 都有对应的清理
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    // 关键:每次 effect 执行都创建独立的 controller
    // 不要在组件级别创建,因为 cleanup 后 controller 已失效
    const controller = new AbortController();

    async function fetchUser() {
      setLoading(true);
      setError(null);

      try {
        const response = await fetch(`/api/users/${userId}`, {
          signal: controller.signal,
        });

        if (!response.ok) {
          throw new Error(`用户不存在: ${response.status}`);
        }

        const data = await response.json();

        // 双重保险:即使 catch 中过滤了 AbortError,
        // 这里再检查一次确保不会更新已卸载组件的状态
        if (!controller.signal.aborted) {
          setUser(data);
          setLoading(false);
        }
      } catch (err) {
        // AbortError 不是真正的错误,忽略它
        if (err.name === 'AbortError') return;

        if (!controller.signal.aborted) {
          setError(err.message);
          setLoading(false);
        }
      }
    }

    fetchUser();

    // 清理函数:组件卸载或 userId 变化时取消请求
    // reason 可以帮助你在调试时区分是哪种情况触发的取消
    return () => {
      controller.abort('组件卸载或 userId 变化');
    };
  }, [userId]);

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

  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

💡 提示: 在 React 18 的 Strict Mode 下,开发环境中 effect 会执行两次 mount → unmount → mount。这是故意的行为,用来暴露缺少清理的 bug。如果你看到 effect 执行了两次,不要觉得是 bug——恰恰相反,这是 React 在帮你发现问题。正确处理 AbortController 后,两次执行不会产生任何副作用。

模式五:可取消的轮询与指数退避

对于需要反复请求直到满足条件的场景(比如等待后台任务完成),结合指数退避(Exponential Backoff)的轮询非常常见。AbortController 让这种模式既能优雅退避,又能随时被外部取消。

// 可取消的指数退避轮询
async function pollWithBackoff(url, {
  signal,
  maxRetries = 5,
  baseDelay = 1000,
  maxDelay = 30000,
  onPoll = null
} = {}) {
  let retries = 0;

  while (retries < maxRetries) {
    // 每次轮询前检查是否已取消
    signal?.throwIfAborted();

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

      // 通知调用方当前轮询结果
      onPoll?.(data, retries);

      // 业务逻辑判断:是否需要继续轮询
      if (data.status === 'completed' || data.status === 'failed') {
        return data;
      }

      // 成功拿到响应但任务未完成,重置重试计数
      retries = 0;
    } catch (err) {
      // AbortError 直接上抛,不做重试
      if (err.name === 'AbortError') throw err;

      retries++;
      if (retries >= maxRetries) {
        throw new Error(`轮询失败,已重试 ${maxRetries} 次: ${err.message}`);
      }
    }

    // 指数退避等待,但依然响应取消信号
    const delay = Math.min(baseDelay * Math.pow(2, retries), maxDelay);
    // 加入随机抖动(jitter)避免多个客户端同时重试
    const jitteredDelay = delay * (0.5 + Math.random() * 0.5);
    await sleep(jitteredDelay, signal);
  }

  throw new Error(`轮询超过最大重试次数 ${maxRetries}`);
}

// 支持取消的 sleep 工具函数
// 这是 AbortController 最实用的辅助函数之一
function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    // 如果 signal 已经取消,立即 reject
    if (signal?.aborted) {
      reject(signal.reason);
      return;
    }

    const timer = setTimeout(resolve, ms);

    // 在 abort 时清理 timer 并 reject
    signal?.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(signal.reason);
    }, { once: true }); // once: true 防止监听器泄漏
  });
}

// 使用示例:等待后台任务完成
const controller = new AbortController();

pollWithBackoff('/api/jobs/abc123', {
  signal: controller.signal,
  maxRetries: 10,
  baseDelay: 2000,
  onPoll: (data, retries) => {
    updateProgressUI(data.progress, retries);
  }
})
  .then(data => showCompletionNotice(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('轮询已取消');
    } else {
      showError(err.message);
    }
  });

// 用户可以随时取消
document.getElementById('cancel-btn').onclick = () => {
  controller.abort('用户取消了任务等待');
};

模式六:自定义可取消的异步操作

AbortController 的价值远不止于 fetch。你可以把它集成到任何异步操作中——文件处理、Web Worker 通信、IndexedDB 事务、甚至自定义的动画和定时任务。关键是让操作在每个可中断的检查点(checkpoint)都主动检查 signal 的状态。

// 将 AbortSignal 集成到大文件处理中
// 每处理一个分片都检查取消状态,实现细粒度的取消控制
async function processLargeFile(file, { signal, chunkSize = 1024 * 1024 } = {}) {
  const totalChunks = Math.ceil(file.size / chunkSize);
  const results = [];

  for (let i = 0; i < totalChunks; i++) {
    // 每处理一个 chunk 前检查取消状态
    // throwIfAborted() 在已取消时直接抛出异常,中断循环
    signal?.throwIfAborted();

    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    const text = await chunk.text();
    const processed = await processChunk(text);

    results.push(processed);

    // 通过自定义事件报告进度
    // 这比传入回调函数更灵活,因为 signal 本身就是 EventTarget
    signal?.dispatchEvent(
      new CustomEvent('progress', {
        detail: {
          current: i + 1,
          total: totalChunks,
          percent: Math.round((i + 1) / totalChunks * 100)
        }
      })
    );
  }

  return results;
}

// 使用示例:处理大文件,支持取消和进度追踪
const controller = new AbortController();

// 监听自定义进度事件
controller.signal.addEventListener('progress', (e) => {
  const { current, total, percent } = e.detail;
  updateProgressBar(percent);
  setStatusText(`处理中: ${current}/${total} 分片`);
});

try {
  const results = await processLargeFile(bigFile, {
    signal: controller.signal,
    chunkSize: 512 * 1024
  });
  showCompletionNotice(`处理完成: ${results.length} 个分片`);
} catch (err) {
  if (err.name === 'AbortError') {
    showInfo('文件处理已取消');
  } else {
    showError('处理失败: ' + err.message);
  }
}

// 用户点击取消按钮
cancelButton.onclick = () => controller.abort('用户取消文件处理');

📊 三、性能影响与最佳实践

AbortController 的内存开销分析

AbortController 的设计非常轻量,但理解它的内存行为有助于避免在高频场景中出现问题。每次创建 AbortController 都会分配一个小型对象(controller 本身 + 关联的 signal + 事件监听器列表),在正常使用中这些对象会在操作完成后被垃圾回收。问题通常出在闭包上——如果你在 signal 的监听器中捕获了大量外部引用,这些引用会阻止 GC 回收相关对象。

场景 内存占用 GC 行为 说明
1000 个 AbortController(已 abort) ~200 KB 下次 GC 自动回收 每个约 200 字节,非常轻量
1000 个未清理的 signal 监听器 ~800 KB 无法回收 监听器中的闭包引用阻止 GC
正常使用:创建 → 使用 → GC 可忽略 自动回收 无泄漏风险
React useEffect 正确清理 无额外开销 cleanup 触发时释放 controller 被闭包持有但可回收

以下是避免内存问题的关键实践:

// ❌ 错误:缓存已失效的 controller,后续操作全部静默失败
const cachedController = new AbortController();
// 第一次 abort 后,这个 controller 永久失效
// 后续 fetch 都会立即收到已取消的 signal,直接 reject

// ✅ 正确:每次操作都创建新 controller
function createCancellableOperation() {
  let controller = null;

  return {
    // 执行操作前自动取消旧操作
    execute(url, options = {}) {
      controller?.abort('新操作取代旧操作');
      controller = new AbortController();
      return fetch(url, {
        ...options,
        signal: controller.signal
      });
    },
    // 手动取消当前操作
    cancel(reason = '手动取消') {
      controller?.abort(reason);
    },
    // 获取当前 signal(用于组合)
    get signal() {
      return controller?.signal;
    }
  };
}

与 Go context.Context 的对比

如果你同时做前后端开发,理解 AbortController 和 Go 的 context.Context 的异同非常有价值。两者都解决"异步操作的取消传播"问题,但设计理念有明显差异。Go 的 context 天然支持父子级联——子 context 自动继承父 context 的取消信号,而 AbortController 需要通过 AbortSignal.any() 手动组合。但 AbortController 在浏览器生态中的集成度极高,几乎所有原生异步 API(fetch、ReadableStream、WebSocket 等)都原生支持它。

特性 AbortController (JavaScript) context.Context (Go)
取消传播 单向,Signal 只读传播 单向,通过 WithCancel 派生
超时支持 AbortSignal.timeout(ms) context.WithTimeout(parent, dur)
截止时间 无原生 deadline API context.WithDeadline(parent, t)
值传递 ❌ 不支持 context.WithValue(parent, key, val)
信号组合 AbortSignal.any(signals) 手动 WithCancel + 关闭监听
级联取消 需手动 any() 组合 天然父子级联,自动传播
标准库集成 fetch, ReadableStream, Cache API 等 net/http, database/sql, grpc 等
取消原因 任意类型(reason 参数) 仅 error 类型(context.Canceled

关键结论: 在全栈项目中,建议在 API 层统一使用类似的取消模式——前端用 AbortController 管理所有异步操作的生命周期,后端用 context.Context 通过请求链传播取消。两者通过 HTTP 头部(如客户端中断连接时服务端感知到 context.Done())自然关联。不要试图用一套方案统一前后端,它们各自在自己的生态中有最佳集成。

✅ 四、总结与行动建议

AbortController 不是什么新技术,但它被严重低估了。在 2026 年的前端开发中,异步取消已经不是"最佳实践",而是基本功。以下是我建议每个项目立即采用的三个实践:

第一,每个 fetch 都传 signal。 即使当前不需要取消功能,也为未来的可维护性铺路。你可以在应用层定义一个默认的全局 signal,页面卸载时自动取消所有未完成的请求。这样做几乎零成本,但能在用户快速切换页面时避免大量无效请求冲击服务器。

第二,React 组件中 useEffect 发请求必须有清理。 每个发起异步操作的 useEffect 都必须在清理函数中调用 controller.abort()。这不是可选的——在 React 18 Strict Mode 下,缺少清理会导致开发环境中的状态混乱。

第三,搜索和筛选场景必须处理竞态。 凡是"用户输入 → 发请求 → 渲染结果"的流程链,都必须在新请求前取消旧请求。这是用户体验的基本保障。

相关工具与资源推荐:

📚 相关文章