Skip to content

D13 · 竞态条件与解决方案 ​

对应主课: L23 商品列表(竞态处理) 适用版本: Vue 3.5、Axios 1.x 最后核对: 2026-09-23


1. 什么是竞态条件 ​

多个异步操作的完成顺序可能不同于发起顺序。较新的请求先完成后,较旧的请求又把结果写回页面,就会出现搜索框与结果不一致。下图的时长只是说明顺序的假设值。

同样的问题也会出现在错误与 loading:旧请求的失败不该覆盖当前成功界面,旧请求的 finally 也不该把新请求的 loading 清掉。


2. 解决方案 1:请求序号 ​

序号决定哪个请求有权写回。下面是独立教学 composable,只做逻辑失效,不取消底层工作;在组件 setup 中同步调用。可保存为 src/composables/useLatestResult.ts,与 L21 的完整 useRequest 区分。

typescript
// src/composables/useLatestResult.ts
import { onScopeDispose, ref, shallowRef } from 'vue'

export function useLatestResult<T, Args extends unknown[]>(
  fetcher: (...args: Args) => Promise<T>,
) {
  const data = shallowRef<T | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)
  let generation = 0
  let disposed = false

  function invalidate() {
    generation++
    loading.value = false
  }

  async function execute(...args: Args): Promise<T | null> {
    if (disposed) return null
    const current = ++generation
    loading.value = true
    error.value = null
    try {
      const result = await fetcher(...args)
      if (disposed || current !== generation) return null
      data.value = result
      return result
    } catch (cause) {
      if (!disposed && current === generation) {
        error.value = cause instanceof Error ? cause.message : String(cause)
      }
      return null
    } finally {
      if (!disposed && current === generation) loading.value = false
    }
  }

  onScopeDispose(() => {
    disposed = true
    invalidate()
  })
  return { data, loading, error, execute, invalidate }
}

序号必须属于这一组请求的实例,不能放到模块顶层让无关组件互相作废。参数或会话已变化、但新请求还没有发出时,也可调用 invalidate(),立即禁止旧结果写回。底层任务仍会继续,因此它解决的是 UI 正确性,而非网络资源回收。


3. 解决方案 2:AbortController ​

AbortSignal 必须一直传到执行请求的层。仅在卸载时调用一个从未交给 fetcher 的 controller,没有取消作用。

typescript
// 原生 fetch 的最小传输函数:调用方负责创建/中止 controller
export async function readText(url: string, signal: AbortSignal): Promise<string> {
  const response = await fetch(url, { signal })
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
  return response.text()
}

每次新请求创建新的 controller,并中止旧 controller。已中止的 signal 不能重置后再次使用。abort() 可以中止 fetch 与响应体读取等支持取消的客户端操作;它不保证远端停止处理,更不回滚已经完成的下单或付款。AbortController.abort

取消也不能取代序号检查:某些适配器、缓存或后续计算不响应 signal,回调仍可能到达。L21 因而同时使用 controller、请求序号和作用域退出标记。

Axios 版本 ​

下面是商城中直接使用 Axios 的独立传输函数,沿用 L21 的 VITE_API_URL 与类型文件。它访问公开商品列表,用 signal 传递取消信号:

typescript
// client/src/api/readProductsDirectly.ts
import axios from 'axios'
import type { PaginatedResponse } from '@/types/api'
import type { Product } from '@/types/product'

export async function readProductsDirectly(query: string, signal: AbortSignal): Promise<Product[]> {
  const response = await axios.get<PaginatedResponse<Product>>('/products', {
    baseURL: import.meta.env.VITE_API_URL || 'http://127.0.0.1:3000/api',
    params: { search: query, page: 1 },
    signal,
  })
  return response.data.data
}

这里第一层 .data 是 AxiosResponse 的 body,第二层是课程 API envelope 的商品数组。L21 的 productApi 已由包装层解开 AxiosResponse,所以调用它时只需读取一次 .data。取消错误也应交给调用层识别,不能吞掉后当成成功响应。Axios 取消请求


4. 解决方案 3:防抖 ​

防抖减少输入过程中的请求次数,不决定响应的有效性。尤其要留意等待防抖的空档:查询已经从 A 变为 B,B 尚未发出时,A 的响应也应失效。

下面是可直接放入商城的演示页面,复用 L21 的 useRequest。每次 query 改变时先 cancel,再等待 300ms 发出新请求。它清除上一份结果,因此不会把旧商品列在新关键词下面;需要保留旧数据的界面应明确标出其查询来源。

vue
<!-- client/src/views/RaceSearchDemo.vue -->
<script setup lang="ts">
import { ref, watch } from 'vue'
import { productApi } from '@/api/products'
import { useRequest } from '@/composables/useRequest'

const query = ref('')
const waiting = ref(false)
const { data, loading, error, execute, cancel } = useRequest(
  async (signal: AbortSignal, search: string) => {
    return (await productApi.getList({ search, page: 1 }, signal)).data
  },
)

watch(query, (value, _old, onCleanup) => {
  cancel()
  data.value = null
  error.value = null
  waiting.value = false
  const search = value.trim()
  if (!search) return

  waiting.value = true
  const timer = setTimeout(() => {
    waiting.value = false
    void execute(search)
  }, 300)
  onCleanup(() => clearTimeout(timer))
}, { flush: 'sync' })
</script>

<template>
  <section>
    <label>搜索商品 <input v-model="query" /></label>
    <p v-if="waiting">等待输入结束…</p>
    <p v-else-if="loading">正在搜索…</p>
    <p v-if="error" role="alert">{{ error }}</p>
    <ul><li v-for="product in data ?? []" :key="product._id">{{ product.name }}</li></ul>
    <p v-if="data && data.length === 0 && !loading">没有匹配商品</p>
  </section>
</template>

这个 sync watcher 只监听一个输入字符串,用于立即作废旧请求;不要照搬到高频数组批量变更。watch 清理会停止尚未触发的 timer,useRequest 的 scope 清理会中止已经发出的请求。Vue 3.5 的 onWatcherCleanup 也可注册清理,但必须在同步执行阶段调用,不能放到 await 之后;本例使用回调参数 onCleanup 并立即登记。Watcher 清理


5. 解决方案 4:统一请求状态 ​

商城使用 L21 已实现的 useRequest,不要把它替换成只比较成功结果的缩减版。完整实现的写回规则如下:

阶段需要处理的边界
execute已退出的作用域不再发起;中止旧 controller;递增序号
成功序号仍匹配且作用域有效,才写 data 或执行成功回调
失败先过滤旧请求、退出和取消,再写当前 error
finally只结束当前请求的 loading,不能影响后来请求
cancel递增序号并 abort,即使底层忽略 abort,旧响应也失效
scope 退出标记 disposed,再 cancel,拒绝晚到结果与再次 execute

如果请求结束后还有异步转换或缓存读取,检查应放在最终写回之前。不同任务互不替代时,则不应共用这一套“只接受最新”的状态,比如同时加载两个独立区域。


6. 常见竞态场景 ​

场景表现处理方式
搜索自动补全旧结果覆盖新关键词防抖 + 立即失效 + 取消 + 序号
快速翻页页码与列表不一致参数快照 + 只接受当前请求
Tab / 路由参数切换复用组件显示上一项结果参数变化即失效;不能只靠卸载
切换账号旧账号响应污染新会话会话版本或独立作用域检查,清除受保护缓存
连续提交表单服务端重复创建前端提交锁,加业务需要的服务端幂等 / 唯一约束

客户端按钮禁用不能替代服务端约束:请求可能来自另一标签页,也可能因网络重试重复到达。写操作是否能安全重试,要看业务协议。


7. 组件退出与验证 ​

组件卸载、KeepAlive 停用、路由参数变化是不同事件。setup 中创建的作用域会在卸载时结束,但 KeepAlive 停用不会自动卸载组件;若停用期间不应请求,需要在 onDeactivated 中显式 cancel,并在激活时按需刷新。

验证不要只发两个请求并期待网络恰好乱序。用可手动 resolve / reject 的 Promise 控制顺序,至少检查:

  1. B 成功后 A 成功,数据仍为 B。
  2. B 成功后 A 失败,当前 error 不被污染。
  3. A 结束而 B 仍在等待,loading 仍为 true。
  4. 输入变化后的防抖空档中,A 到达也不能写回。
  5. 取消或卸载后,忽略 signal 的 fetcher 到达也不能写回。
  6. 清空输入后没有残留 timer 再发出搜索。

这些测试分别覆盖数据、错误、等待状态和生命周期;L21 / L23 已提供相应实现,本文的页面可用于观察输入与取消的衔接。

8. 请求的两个职责 ​

取消尽量减少不再需要的工作;序号与作用域检查决定哪些结果仍有权更新界面。防抖调整发起时机,服务端幂等保护写入语义。这些机制各有职责,需要按具体操作组合。