← 全部文章

技术笔记 · 2026-09-14

异步请求乱序:如何保证页面只接受最新结果

聚焦异步结果的归属判断:请求取消为何还不够,如何用请求代次处理连续切换、重复刷新、旧异常和组件卸载。

  • Vue
  • 异步竞态
  • AbortController
  • 状态管理

关联项目:企业沟通与售后协作平台。本文提炼自项目中的会话切换与消息加载工作;示例去除了内部协议,并增加请求代次来说明更完整的边界。

一个看起来像“消息发错了”的问题

聊天页面同时维护当前会话、消息列表、输入草稿、分页位置和加载状态。只要这些状态对“自己属于哪个会话”理解不一致,用户就可能在 B 会话里看到 A 的消息。

慢网络把这种问题暴露得很明显:用户打开 A,随后切到 B;B 的首屏先返回,A 的旧请求后返回。如果回调直接覆盖共用列表,最后完成的请求就会决定页面显示什么。

选择 A → 请求 A ──────────────────→ A 返回
         选择 B → 请求 B → B 返回
页面应该显示 B;返回顺序却是 B、A。

这里需要守住的规则是:只有当前有效请求,才有资格更新当前页面状态。 网络是否已返回成功,是另一个问题。

取消请求和拒绝旧结果,各有职责

AbortController 可以中止支持信号的 Fetch 请求及响应体读取,实际调用方式见 MDN Fetch 文档。取消有助于减少无用工作,但业务层仍可能存在缓存 Promise、异步格式转换或不响应取消的适配器。

因此我会同时检查请求结果的归属。项目中已有请求取消和异步步骤后的会话检查;进一步抽象时,还要考虑两种情况:

  • A → B → A: 很早之前的 A 请求返回时,会话 ID 又变成 A,单靠 ID 检查可能放行。
  • 同会话刷新: 两次都请求 A,但第一次拿到的旧快照不能覆盖第二次的新快照。

递增的请求代次可以区分这些请求。它表示一次加载意图,比会话 ID 更细。

一个不依赖 Vue 的最小实现

下面的加载器只负责当前会话的首屏替换。read 是注入的数据读取与转换函数,不代表新增业务接口;emit 同步更新界面状态。每个独立聊天面板创建自己的实例。

type State = {
  sessionId: string
  status: 'loading' | 'ready' | 'error'
  messages: string[]
  error?: string
}

type ReadMessages = (
  sessionId: string,
  signal: AbortSignal
) => Promise<string[]>

export function createMessageLoader(
  read: ReadMessages,
  emit: (state: State) => void
) {
  let revision = 0
  let controller: AbortController | undefined
  let disposed = false

  return {
    async load(sessionId: string) {
      if (disposed) return
      // 同一会话的不同请求,也必须拥有不同代次。
      const currentRevision = ++revision
      controller?.abort()
      const current = new AbortController()
      controller = current
      const isCurrent = () =>
        !disposed && currentRevision === revision

      emit({ sessionId, status: 'loading', messages: [] })
      try {
        // read 包含取数和所有异步转换步骤。
        const messages = await read(sessionId, current.signal)
        if (!isCurrent()) return
        emit({ sessionId, status: 'ready', messages })
      } catch (error) {
        if (!isCurrent() || current.signal.aborted) return
        emit({
          sessionId,
          status: 'error',
          messages: [],
          error: error instanceof Error
            ? error.message : '消息加载失败',
        })
      }
    },
    dispose() {
      disposed = true
      revision += 1
      controller?.abort()
    },
  }
}

在 Vue 中,可以由 watcher 调用 load,在组件卸载时调用 dispose。React 中也可以把加载器绑定到组件生命周期,避免在每次渲染时重新创建。

示例没有在旧请求的 finally 中无条件执行 loading = false。否则,旧请求即使没有覆盖消息,也可能提前结束新请求的加载提示。错误反馈也要经过相同的归属检查。

守护的是一次界面意图

这个加载器针对“新请求替换旧内容”。它可以用于聊天首屏,也可以用于搜索建议、切换详情和附件预览。判断粒度应覆盖真正相互竞争的那一组请求,每个独立面板保留自己的代次。

并行加载的两个独立区域不能共用一个全局代次,否则请求互相失效。需要同时保留的多个分页结果也不能直接照搬“只接受最后一次”的规则;它们属于另一个合并问题。

我会怎样验证

用可控制完成顺序的 Promise,比随手点击几次更容易复现竞态:

  1. 发起 A、B 两个请求,先完成 B,再完成 A;当前结果只能来自 B。
  2. 发起 A1、B、A2,最后才完成 A1;页面应保留 A2。
  3. 同会话连续刷新,旧响应和旧异常都不能覆盖新状态。
  4. 在读取或转换过程中卸载面板,之后不得再更新界面。
  5. 模拟“不响应 abort 的读取器”,确认归属检查仍然有效。

这套思路也适用于搜索建议、切换详情、附件预览和联动表单。决定是否接受结果的,应是最新的用户意图,而不是请求抵达终点的先后顺序。