当前位置:首页 > 文章列表 > 文章 > 前端 > Fetch 请求取消后,超时与用户中断要怎样区分

Fetch 请求取消后,超时与用户中断要怎样区分

来源:17golang原创 2026-10-07 10:18:55 0浏览 收藏

我第一次认真处理这个问题,是在一个“搜索建议”组件里:用户切换关键词时要取消旧请求,接口超过几秒又要提示超时。两种情况都会让 fetch() 失败,但产品处理完全不同——用户取消应该安静结束,超时需要提示并允许重试,网络错误则应该记录和上报。

最直接的答案是:不要靠“请求失败了”来猜原因,而要让不同的 AbortSignal 自己携带原因。AbortSignal.timeout() 到期时使用 TimeoutError,AbortController.abort() 未显式传入原因时使用 AbortError,再由 AbortSignal.any() 合并信号并保留最先触发的 reason。官方说明可参考 https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static 与 https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/any_static。

背景:取消不是同一种业务结果

从 Fetch 的角度看,取消意味着停止请求以及后续响应体消费;从业务角度看,原因至少有三类。第一类是用户点击取消、关闭弹窗或组件卸载,这通常不是故障。第二类是超过等待上限,用户需要看到“请求超时”。第三类是 DNS、连接、跨域或服务异常,它们不是取消,不能被静默吞掉。

如果这三类结果都落进同一个 catch,统计会把正常交互算成错误,界面可能在用户主动离开后又弹出失败提示,重试策略也会失去依据。因此,区分原因不是为了把异常名写得更漂亮,而是为了让界面、日志和重试各自拿到正确语义。

旧写法的问题:手写计时器只负责调用 abort

常见旧写法是创建一个控制器,计时到期后调用 abort()。它确实能停下请求,但默认原因仍是 AbortError。如果用户按钮也调用同一个 abort(),捕获处就无法知道是谁触发了取消。

async function loadWithOldTimeout(url, timeoutMs) {
  const controller = new AbortController();

  // 旧写法只在到期时发出默认取消原因
  const timerId = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await fetch(url, { signal: controller.signal });
  } finally {
    // 无论成功或失败都释放本地计时器
    clearTimeout(timerId);
  }
}

这段代码还有一个设计问题:调用方拿不到独立的“用户取消入口”。把用户操作和超时揉进一个控制器后,谁先触发、应该显示什么提示,全被隐藏在函数内部。可以给 abort(reason) 传自定义原因修补,但当运行环境支持标准静态方法时,拆成两个信号会更清楚。

新规则:让不同信号保留自己的 reason

AbortSignal.timeout(ms) 返回一个自动取消的信号,到期后的 reason 是名为 TimeoutError 的 DOMException。用户操作继续使用自己的 AbortController;如果不传自定义原因,它的默认原因是名为 AbortError 的 DOMException。

AbortSignal.any([...]) 把多个输入信号组合成一个信号。哪个输入最先取消,组合信号就采用哪个输入的原因。这里的“最先”是唯一可靠的判定依据;不要在 catch 中根据当前时间反推,因为超时与用户点击接近同时发生时,时间推断很容易与真实胜出信号不一致。

用户取消控制器、超时信号、组合信号与取消原因之间的静态关系
图1:Fetch 取消原因静态结构图。用户取消与超时各自携带 reason,合并信号只保留最先触发的原因;这是说明图,不是运行截图。
function createRequestSignal(userSignal, timeoutMs) {
  // 超时信号到期后携带 TimeoutError
  const timeoutSignal = AbortSignal.timeout(timeoutMs);

  // 组合信号保留最先取消的输入信号原因
  return AbortSignal.any([userSignal, timeoutSignal]);
}

如果需要把“点击取消”和“组件卸载”继续拆开,可以给 abort() 传入自定义 Error,或者为两者分别创建控制器。判断时优先读取组合信号的 reason,而不是只看捕获对象的字符串消息;消息适合展示,name 或业务错误类型才适合分支。

代码对比:把取消语义封装进请求函数

下面的封装接收调用方的用户取消信号,再追加超时信号。它返回结构化结果,让界面层不必在每个页面重复解析异常。

async function requestJson(url, { userSignal, timeoutMs = 8000 } = {}) {
  // 没有外部用户信号时,使用永不主动取消的新信号
  const fallbackController = new AbortController();
  const externalSignal = userSignal ?? fallbackController.signal;

  // 用户取消和超时保持为两个独立来源
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
  const combinedSignal = AbortSignal.any([externalSignal, timeoutSignal]);

  try {
    const response = await fetch(url, { signal: combinedSignal });

    // HTTP 非 2xx 不属于 AbortSignal 取消,需要单独处理
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}`);
    }

    // 读取响应体期间同样受取消信号影响
    const data = await response.json();
    return { ok: true, kind: "success", data };
  } catch (error) {
    // 以组合信号记录的首个 reason 为取消事实来源
    if (combinedSignal.aborted) {
      const reason = combinedSignal.reason;

      if (reason?.name === "TimeoutError") {
        return { ok: false, kind: "timeout", error: reason };
      }

      return { ok: false, kind: "user-cancel", error: reason };
    }

    // 未被信号取消的失败继续作为网络或业务错误交给上层
    return { ok: false, kind: "failure", error };
  }
}

调用方只负责创建属于当前交互的控制器。每次发起新请求都应创建新控制器,因为 AbortSignal 一旦进入 aborted 状态就不会恢复;复用旧信号会让后续请求立即失败。

const userController = new AbortController();

// 用户点击取消时,只触发本次交互的控制器
cancelButton.addEventListener("click", () => {
  userController.abort();
});

const result = await requestJson("/api/search?q=fetch", {
  userSignal: userController.signal,
  timeoutMs: 5000,
});

// 根据业务结果选择提示、静默结束或错误上报
if (result.kind === "timeout") showToast("请求超时,请重试");
if (result.kind === "failure") reportError(result.error);
Fetch 成功、用户取消、请求超时和网络失败对应不同业务策略的静态分类
图2:Fetch 结果分类结构图。取消原因与网络错误分开后,界面提示、重试和错误上报可以采用不同策略;这是静态说明图。

兼容注意:标准方法、竞态与资源清理

AbortSignal.timeout() 和 AbortSignal.any() 已进入现代浏览器的 Baseline 2024 范围,但较旧设备仍可能缺少静态方法。发布前应按目标浏览器矩阵查看兼容表。若必须兼容旧环境,可以用 AbortController、setTimeout() 和显式自定义 reason 做降级,并在请求结束后 clearTimeout()。

还要注意,AbortSignal.timeout() 依据的是活动时间。页面进入往返缓存或 worker 被暂停时,计时可能暂停;它不等同于服务端的绝对截止时间。如果业务要求跨页面挂起仍按墙上时间截止,应由服务端超时、请求头中的截止时间或可清理的自定义计时方案共同保证。

AbortSignal.any() 只把输入信号组合起来,不会因为组合信号已取消就反向取消其他输入,也不会替你取消另一个超时。若代码额外给组合信号注册了监听器,操作成功结束时也要移除;只使用 { once: true } 不能清理“从未发生取消”的成功路径。

采用建议:提示、重试和日志各走各的分支

我倾向于把 user-cancel 当作正常控制流:不弹错误、不自动重试,只在需要分析交互时记录低级别事件。timeout 可以显示短提示,并仅在请求满足幂等条件、重试次数受控时自动重试。failure 则保留原始错误,上报网络环境、接口和请求标识,但不要把令牌或完整敏感参数写进日志。

如果一个页面只有“用户取消”和“固定超时”两种取消来源,判断 TimeoutError 与 AbortError 已经够用。如果还要区分路由切换、组件卸载和按钮点击,就为每种来源设置独立 reason,并让业务层读取 combinedSignal.reason。这比解析错误消息稳定,也更容易写单元测试。

常见问题

只判断 catch 中 error.name 可以吗?

简单场景可以,但组合多个信号时更推荐同时确认 combinedSignal.aborted 并读取 combinedSignal.reason。这样能避免把普通网络错误误判为取消,也能保留自定义取消原因。

HTTP 404 或 500 会变成 Fetch 异常吗?

不会。Fetch 通常会正常返回 Response,需要检查 response.ok 或状态码。它与用户取消、超时和传输层失败是不同分支。

超时后还能复用同一个 controller 吗?

不应复用已经 aborted 的信号。控制器和信号都是一次性的生命周期对象,新请求创建新实例更清晰。

用户点击取消和超时几乎同时发生怎么办?

以组合信号记录的第一个 reason 为准。不要比较按钮时间戳和定时器时间戳再覆盖结果,否则可能产生与实际取消来源不一致的业务状态。

版本声明
本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
单元测试、集成测试和端到端测试的边界如何划分单元测试、集成测试和端到端测试的边界如何划分
上一篇
单元测试、集成测试和端到端测试的边界如何划分
利用 TestMain 管理共享资源又不污染单个用例
下一篇
利用 TestMain 管理共享资源又不污染单个用例
查看更多
最新文章
查看更多
课程推荐
  • 前端进阶之JavaScript设计模式
    前端进阶之JavaScript设计模式
    设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
    543次学习
  • GO语言核心编程课程
    GO语言核心编程课程
    本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
    516次学习
  • 简单聊聊mysql8与网络通信
    简单聊聊mysql8与网络通信
    如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
    500次学习
  • JavaScript正则表达式基础与实战
    JavaScript正则表达式基础与实战
    在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
    487次学习
  • 从零制作响应式网站—Grid布局
    从零制作响应式网站—Grid布局
    本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
    485次学习
查看更多
AI推荐
  • PubMedQA数据集详解:生物医学问答基准、功能与应用指南
    PubMedQA
    深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
    363次使用
  • H2O EvalGPT:开源LLM大模型评估与排行榜工具
    H2O EvalGPT
    H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
    418次使用
  • LMArena是什么?伯克利AI模型评估平台使用指南与功能解析
    LMArena
    LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
    432次使用
  • 斯坦福HELM:大语言模型Holistic Evaluation整体评估框架详解
    HELM
    深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
    385次使用
  • MMBench详解:多模态大模型基准测试、功能特点与使用指南
    MMBench
    MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
    210次使用
微信登录更方便
  • 密码登录
  • 注册账号
登录即同意 用户协议 和 隐私政策
返回登录
  • 重置密码