竞态(处理重复请求、解决响应乱序问题)
阶段 6.4|竞态(stale response)★ 阶段 6 最重要
一、什么是竞态
先发的请求后返回,把后发请求的结果覆盖了 —— 页面显示的是过期数据。
时间线:
用户输入 "a" → 请求 A 发出(网络拥堵)
用户输入 "abc" → 请求 C 发出(很快)
← C 返回(200ms)→ 显示 abc 的结果 ✅
← A 返回(800ms)→ 覆盖!显示 a 的结果 ❌
最终:输入框是 "abc",表格却是 "a" 的数据
为什么危险:不报错、不崩溃、页面看起来正常 —— 但数据是错的,而且复现靠运气。
二、防抖解决不了它(最大误区)
const onSearch = debounce(load, 300) // 很多人以为够了
防抖只减少请求次数,管不了响应顺序。网络抖动、后端负载不均时,先发的照样可能后到。
结论:防抖降低概率,竞态防护消除问题。两者要一起用。
三、三种防护手段
| 手段 | 做法 | 解决什么 | 单独够吗 |
|---|---|---|---|
| ① 请求序号 | 每次请求递增 seq,回调时比对 | 丢弃过期响应 | ✅ 能 |
| ② 取消上一次 | 新请求前 abort() 旧的 |
省流量、省服务端资源 | ⚠️ 差一点 |
| ③ 丢弃过期响应 | 同上(① 的实现) | 数据正确性 | ✅ |
| ④ ②+③ 组合 | 既中止又校验 | 最优 | ✅✅ 推荐 |
只用「取消」为什么还不够
controller.abort() // 发出取消
await fetch(...) // 但响应可能已经在微任务队列里排队了
// → catch 到 AbortError 之前,极端时序下仍可能走到赋值逻辑
且 abort() 不保证服务端没处理完。所以必须再校验一次序号。
四、通用防护器(复制即用)
/** 请求守卫:取消上一次 + 序号校验 */
export function createRequestGuard() {
let seq = 0
let controller: AbortController | null = null
return {
/** 每次请求前调用:取消上一次,返回本次的 signal 与 isStale */
begin() {
controller?.abort('superseded') // ① 取消上一次
controller = new AbortController()
const current = ++seq // ② 递增序号
return {
signal: controller.signal,
/** 回调时判断:本次响应是否已过期 */
isStale: () => current !== seq,
}
},
/** 主动取消(组件卸载 / 路由切换) */
abort(reason?: unknown) {
controller?.abort(reason)
},
get aborted() { return controller?.signal.aborted ?? false },
}
}
五、三个实战场景
场景 1:搜索框输入
const guard = createRequestGuard()
const keyword = ref('')
async function load() {
const { signal, isStale } = guard.begin()
try {
const res = await findPage({ keyword: keyword.value }, { signal })
if (isStale()) return // ⭐ 丢弃过期响应
tableData.value = res.records
} catch (e) {
if (isAbortError(e)) return // ⭐ 取消:静默
throw e
}
}
// 防抖 + 竞态防护一起用
const onSearch = debounce(load, 300)
场景 2:Tab / 筛选切换
const guard = createRequestGuard()
const activeTab = ref('all')
watch(activeTab, async (tab) => {
const { signal, isStale } = guard.begin()
const res = await findPage({ tab }, { signal })
if (isStale()) return
list.value = res.records
})
场景 3:分页快速连点
const guard = createRequestGuard()
const loading = ref(false)
async function goPage(page: number) {
const { signal, isStale } = guard.begin()
loading.value = true
try {
const res = await findPage({ pageNumber: page, pageSize: 20 }, { signal })
if (isStale()) return
pageNumber.value = page // ⭐ 页码也只在最新响应时更新
tableData.value = res.records
} catch (e) {
if (!isAbortError(e)) throw e
} finally {
if (!isStale()) loading.value = false // ⭐ 别让旧请求关掉新 loading
}
}
finally里也要判isStale(),否则旧请求会把新请求的 loading 关掉。
六、另一种校验方式:比对参数
不递增序号,而是比对发起时的参数与当前参数:
async function load() {
const kw = keyword.value // 发起时快照
const res = await findPage({ keyword: kw }, { signal })
if (keyword.value !== kw) return // 期间用户改了输入 → 丢弃
tableData.value = res.records
}
| 方式 | 适用 |
|---|---|
序号 seq |
通用,推荐 |
| 参数比对 | 参数就是唯一标识时更直观 |
两者可以叠加(最稳)。
七、Promise.all 里的竞态
并行请求也可能有过期数据:
async function refreshAll() {
const { signal, isStale } = guard.begin()
const [a, b] = await Promise.all([getA(signal), getB(signal)])
if (isStale()) return // ⭐ 两个结果一起校验
dataA.value = a; dataB.value = b
}
八、六个坑 ⚠️
- 只
abort()不校验 —— 极端时序仍可能赋值(见第三节)。 finally里没判isStale()—— 旧请求把新请求的 loading 关掉,页面提前"加载完成"。- 闭包捕获旧的
seq——isStale必须是闭包返回的函数,别在赋值时才读全局 seq。 - 缓存层也要防 —— 别把过期响应写进缓存(否则下次命中还是旧数据)。
- 多个独立列表别共用一个 guard —— 会互相取消;每个数据源一个 guard。
- 取消与错误要分开 ——
isAbortError只管静默,isStale只管丢弃,职责不同。
九、与 6.6(卸载清理)的分工
| 触发时机 | 目的 | |
|---|---|---|
| 6.4 竞态防护 | 每次发起新请求时 | 防止旧响应覆盖新数据 |
| 6.6 卸载清理 | 组件销毁时 | 防止已卸载组件被赋值 / 内存泄漏 |
onUnmounted(() => guard.abort('unmounted')) // 6.6
两者用同一个 guard,一套机制覆盖两个场景。
✅ 自检
Q1:防抖能解决竞态吗?为什么?
不能。防抖只减少请求次数,网络抖动时先发的请求仍可能后返回,覆盖新结果。
Q2:为什么「只 abort 上一次」还不够?
响应可能已在微任务队列中排队,极端时序下仍会执行赋值;且 abort 不代表服务端没处理。必须再校验序号。
Q3:finally 里为什么也要判 isStale()?
否则过期请求会关掉新请求的 loading,页面提前显示"加载完成"。
Q4:两个独立的列表能共用一个请求守卫吗?
不能,会互相取消。每个数据源应各自持有一个 guard。
Q5:竞态防护的三要素是什么?
取消上一次(省资源)+ 递增序号(标识身份)+ 回调时丢弃过期响应(保数据正确)。

浙公网安备 33010602011771号