登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

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

来源:17golang原创

时间:2026-10-07 10:18:55 466浏览 收藏

我第一次认真处理这个问题,是在一个“搜索建议”组件里:用户切换关键词时要取消旧请求,接口超过几秒又要提示超时。两种情况都会让 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删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>