搜索请求取消后,旧结果为什么还会覆盖新结果

51 阅读3分钟

搜索框先输入 cat,很快又改成 car。第一次请求慢,第二次请求快,如果两个响应都能更新页面,晚到的 cat 就可能覆盖 car。

AbortController 可以减少不需要的请求工作,页面还需要明确哪一次操作有资格提交结果。我对照 MDN 的取消机制整理这个例子,用递增版本保护数据、错误和 loading。

取消通知能影响哪些操作

fetch 支持 AbortSignal,请求或响应体读取尚未完成时,可以响应中止。已经完成的 Promise 不会因此重新回到等待状态。自行编写的后续异步转换,如果没有处理这个 signal,也可能继续运行。

所以每次输入变化,除了取消旧 controller,还要使旧版本失效。提交状态前再核对一次,才能避免旧任务把自己的结果写回当前页面。

把检查与状态更新放在同一处

下面是浏览器 TypeScript 示例。页面需要把 ui 状态绑定到实际界面,并提供返回 JSON 的 /api/search 接口。可以在一个独立搜索组件里使用这一组状态。

let version = 0;
let controller: AbortController | undefined;
let disposed = false;

const ui = {
  data: null as unknown,
  error: null as string | null,
  loading: false,
};

async function search(query: string): Promise<void> {
  if (disposed) return;
  const mine = ++version;
  controller?.abort();
  const current = new AbortController();
  controller = current;

  ui.loading = true;
  ui.error = null;

  const canCommit = () =>
    !disposed && mine === version && !current.signal.aborted;

  try {
    const response = await fetch(
      "/api/search?q=" + encodeURIComponent(query),
      { signal: current.signal },
    );
    if (!response.ok) throw new Error("HTTP " + response.status);
    const data: unknown = await response.json();
    if (canCommit()) ui.data = data;
  } catch (error) {
    if (canCommit()) {
      ui.error = error instanceof Error ? error.message : String(error);
    }
  } finally {
    if (canCommit()) ui.loading = false;
  }
}

function dispose(): void {
  disposed = true;
  ++version;
  controller?.abort();
  ui.loading = false;
}

每次 search 都生成新的版本和 controller。旧任务即使进入 catch 或 finally,也要先通过 canCommit。组件卸载时调用 dispose,使所有未完成操作失去提交资格。重新挂载组件时,应重新创建这一组状态。

这段代码讨论浏览器 fetch,Python 没有同名的 AbortController 官方接口,异步客户端需要按自身的取消规则实现,不能直接照搬。

loading 也需要保护

只检查成功结果,还会遗漏一个很隐蔽的问题。旧请求被取消后进入 finally,如果它无条件把 loading 设为 false,新请求还在运行,界面就已经显示加载结束。

错误状态同样如此。旧请求晚到的错误,不应该覆盖最新一次搜索的成功结果。把三个状态的提交规则放在同一处,可以避免不同分支采用不同版本条件。

如果拿到结果之后还需要 await 一次额外转换,就把提交前检查放到转换之后。检查完版本,又等待一段异步工作,最后直接更新,仍然可能提交过时的数据。

用乱序完成检查这个行为

验证时,让 cat 的处理停在某段异步转换里,再启动 car。car 完成以后才放行 cat,最后页面应该仍然保留 car。这个用例能覆盖请求已经返回、后续处理却还没结束的情况。

再让旧请求先结束、新请求继续等待,检查 loading 是否仍然为 true。最后测试卸载,所有未完成任务都不应继续提交状态。

防抖可以减少请求次数,版本检查决定结果能否提交。这两项可以一起使用。浏览器取消也不会自动撤销服务端已经执行的写操作,提交任务或创建订单时还要单独处理业务状态与幂等。

我是程序员Sunday。完整机制和更多追问见 sunday面试指南的 AbortController 与请求竞态解析。

参考 MDN AbortController 文档。