原创

AbortSignal.any 实战:把用户取消和超时接进同一条退出链

一个请求同时有两种停止入口:用户点取消,或者等待预算用完。若只在 catch 里统一显示“请求失败”,用户看不出自己取消和系统超时的差别;若给两种入口各写一套监听,又很容易漏掉最终的清理。

AbortSignal.any() 可以把多个信号组合成一个退出入口。实际执行者只监听这一个信号,组合信号保留触发中止的原因。它解决传播关系,不替任务实现停止动作,也不替业务判断远端操作有没有生效。

这次先做一个不依赖网络的页面,把执行延时、超时和人工取消都固定下来。需要支持 AbortSignal.any、AbortSignal.timeout 与 throwIfAborted 的现代浏览器,页面会先检查能力,避免把兼容性问题误当成任务异常。

完整页面:快任务、慢任务和取消共用一条退出链

保存为 combined-abort.html,浏览器打开后先运行快任务,再运行慢任务,最后在慢任务进行时点击取消。

<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>组合取消实验</title>
<button id="fast" type="button">快任务:100ms</button>
<button id="slow" type="button">慢任务:900ms</button>
<button id="cancel" type="button" disabled>取消当前任务</button>
<p>本地演示预算为 300ms,所有时间只用于控制实验。</p>
<p id="status" role="status">等待开始</p>
<pre id="detail"></pre>
<script>
function delayedResult(milliseconds, signal) {
  signal.throwIfAborted();
  return new Promise((resolve, reject) => {
    function cleanup() {
      clearTimeout(timer);
      signal.removeEventListener('abort', onAbort);
    }
    function onAbort() {
      cleanup();
      reject(signal.reason);
    }
    const timer = setTimeout(() => {
      cleanup();
      resolve({ ok: true, simulatedDelay: milliseconds });
    }, milliseconds);
    signal.addEventListener('abort', onAbort, { once: true });
  });
}

const fast = document.querySelector('#fast');
const slow = document.querySelector('#slow');
const cancel = document.querySelector('#cancel');
const status = document.querySelector('#status');
const detail = document.querySelector('#detail');
let controller = null;
const supported = typeof AbortSignal.any === 'function' &&
  typeof AbortSignal.timeout === 'function' &&
  typeof AbortSignal.prototype.throwIfAborted === 'function';
if (!supported) {
  fast.disabled = slow.disabled = true;
  status.textContent = '当前浏览器缺少本实验使用的取消 API';
}
async function start(milliseconds) {
  if (controller !== null) return;
  controller = new AbortController();
  const signal = AbortSignal.any([
    controller.signal,
    AbortSignal.timeout(300)
  ]);
  fast.disabled = slow.disabled = true;
  cancel.disabled = false;
  status.textContent = '正在执行';
  detail.textContent = '';
  try {
    const result = await delayedResult(milliseconds, signal);
    status.textContent = '完成';
    detail.textContent = JSON.stringify(result);
  } catch (error) {
    if (signal.aborted && signal.reason?.name === 'TimeoutError') {
      status.textContent = '超过本地等待预算';
    } else if (signal.aborted && signal.reason?.name === 'AbortError') {
      status.textContent = '用户取消';
    } else {
      status.textContent = '任务异常';
    }
    detail.textContent = String(error);
  } finally {
    controller = null;
    fast.disabled = slow.disabled = false;
    cancel.disabled = true;
  }
}
fast.onclick = () => start(100);
slow.onclick = () => start(900);
cancel.onclick = () => controller?.abort(
  new DOMException('cancelled by user', 'AbortError')
);
</script>
</html>

快任务应完成,慢任务应显示超过预算,及时点击取消则显示用户取消。这里的 100、300、900 毫秒都是人为安排的演示值,不是网络耗时、服务器能力或性能结论。

delayedResult 真正做了停止工作:中止时清除定时器,拒绝 Promise,并移除监听。若只是创建组合信号,却没有让执行函数响应它,慢任务仍然会在后台继续完成。接上 signal 参数与实现合作停止,是两个都必须落实的位置。

成功路径也要移除 abort 监听

{ once: true } 只在事件真的触发以后移除监听。任务先成功,300ms 超时后来才发生,若没有主动清理,监听器仍可能拿着任务闭包等到那个时点。

例子把成功和中止都交给同一个 cleanup,保证定时器与监听成对释放。任务复杂以后,可以继续把拥有的 reader、订阅和临时资源登记进同一退出流程,不要只在 catch 里清理失败路径。

进入函数时调用 throwIfAborted,是为了处理调用之前就已经中止的信号。abort 不是一份给未来监听者补发的普通事件:监听注册得晚,不能指望它再触发一次。先检查状态,再建立后续动作,能把“已经结束”与“正在等待”分开。

这个示例的检查和监听注册之间没有 await,普通页面任务不会在它们之间插入另一个用户事件。换成有异步准备阶段的实现,就必须重新检查准备完成时的状态,或者在更早位置建立完整监听,不能机械复制两行代码到任何上下文。

原因只选一次,不会自动汇总所有中止

组合信号一旦中止,后续其他信号再中止不会改写它的 reason。因此用户已经取消以后,稍晚到达的超时不应把提示改成“系统超时”。页面只处理当前 await 的一次结束,也没有让两个监听器竞争改界面。

如果传入的信号在调用 any 之前已经中止,组合方法会按其输入处理规则选择已有原因;不要把这当成一份能够追溯全部历史时间的事件日志。需要诊断多种原因出现顺序时,应另外记录实际事件,而不是从最终 reason 反推出所有先后。

reason 本身也可以是任意值,并不保证总是带 name 的 DOMException。例子为人工取消明确构造 AbortError,timeout 方法使用 TimeoutError,所以界面能按这份约定分类。接收第三方 signal 时,应保留不认识的原因,不要让错误分类代码自己再次抛异常。

每次运行都创建新 controller 和组合信号。已经中止的 signal 不能“恢复成可用”,把同一个 controller 缓存在组件里反复使用,会让第二次任务刚开始就被拒绝。重试需要新的一次生命周期,而不是把 aborted 属性试图写回 false。

接入 fetch 时保留这条合同

将 delayedResult 换成 fetch,可以把组合 signal 直接传给请求。读取响应正文仍可能受同一个信号影响,因此错误处理不能仅围住“收到响应头”那一行;正文尚未读完,网络操作就还没完成。

HTTP 4xx、5xx 通常不会让 fetch 自动拒绝 Promise,需要继续检查响应状态。把它们与 AbortError 混在一起重试,既会误报用户取消,也可能反复提交不该重试的业务请求。

AbortSignal.timeout 在浏览器语义中使用活动时间,页面或文档被挂起等情况会影响观察到的墙钟时长。它不是服务端跨进程共享的绝对截止时间。需要整条服务链预算时,应在服务端另行定义,并让每一跳接收剩余时间。

用户取消本地等待也不能证明远端没有执行。支付、发布等写入操作若结果未知,应按稳定业务编号查询或按幂等协议恢复;组合信号只是让浏览器端结束得有原因,不能替远端完成撤销。

任务成功之后继续等待超过300ms,页面应仍然显示完成;用户取消以后再等待,也不应被迟到的超时覆盖。若提示被改写,就继续检查是否还有独立监听绕过了当前任务的统一结束流程。

正文到此结束
评论插件初始化中...
Loading...
本文目录