异步请求乱序:如何保证页面只接受最新结果
聚焦异步结果的归属判断:请求取消为何还不够,如何用请求代次处理连续切换、重复刷新、旧异常和组件卸载。
- 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,比随手点击几次更容易复现竞态:
- 发起 A、B 两个请求,先完成 B,再完成 A;当前结果只能来自 B。
- 发起 A1、B、A2,最后才完成 A1;页面应保留 A2。
- 同会话连续刷新,旧响应和旧异常都不能覆盖新状态。
- 在读取或转换过程中卸载面板,之后不得再更新界面。
- 模拟“不响应 abort 的读取器”,确认归属检查仍然有效。
这套思路也适用于搜索建议、切换详情、附件预览和联动表单。决定是否接受结果的,应是最新的用户意图,而不是请求抵达终点的先后顺序。