竞态(处理重复请求、解决响应乱序问题)

阶段 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
}

八、六个坑 ⚠️

  1. 只 abort() 不校验 —— 极端时序仍可能赋值(见第三节)。
  2. finally 里没判 isStale() —— 旧请求把新请求的 loading 关掉,页面提前"加载完成"。
  3. 闭包捕获旧的 seq —— isStale 必须是闭包返回的函数,别在赋值时才读全局 seq。
  4. 缓存层也要防 —— 别把过期响应写进缓存(否则下次命中还是旧数据)。
  5. 多个独立列表别共用一个 guard —— 会互相取消;每个数据源一个 guard。
  6. 取消与错误要分开 —— 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:竞态防护的三要素是什么?

取消上一次(省资源)+ 递增序号(标识身份)+ 回调时丢弃过期响应(保数据正确)。

posted @ 2026-10-14 11:48  寅信  阅读(10)  评论(0)    收藏  举报