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 区分。
// 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,没有取消作用。
// 原生 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 传递取消信号:
// 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 发出新请求。它清除上一份结果,因此不会把旧商品列在新关键词下面;需要保留旧数据的界面应明确标出其查询来源。
<!-- 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 控制顺序,至少检查:
- B 成功后 A 成功,数据仍为 B。
- B 成功后 A 失败,当前 error 不被污染。
- A 结束而 B 仍在等待,loading 仍为 true。
- 输入变化后的防抖空档中,A 到达也不能写回。
- 取消或卸载后,忽略 signal 的 fetcher 到达也不能写回。
- 清空输入后没有残留 timer 再发出搜索。
这些测试分别覆盖数据、错误、等待状态和生命周期;L21 / L23 已提供相应实现,本文的页面可用于观察输入与取消的衔接。
8. 请求的两个职责
取消尽量减少不再需要的工作;序号与作用域检查决定哪些结果仍有权更新界面。防抖调整发起时机,服务端幂等保护写入语义。这些机制各有职责,需要按具体操作组合。