请求成功返回,只代表它完成了,不代表它现在仍然有效。
这是理解异步竞态最重要的一句话。许多异步 bug 并不是请求失败,而是一个已经过期的请求成功返回,并覆盖了更新的用户意图。
一个最常见的例子
用户在搜索框里依次输入:
a → ab → abc页面随之发出三个请求。我们希望最终显示 abc 的结果,但网络并不保证请求按发出顺序返回:
请求 abc 先返回
请求 a 随后返回
请求 ab 最后返回如果每个请求完成后都直接更新列表:
const result = await search(keyword)
setList(result)最后返回的 ab 就会覆盖 abc。三个请求都成功了,页面却展示了错误结果。
问题不在请求本身,而在于多个完成顺序不确定的操作都在争夺同一个状态。
竞态需要三个条件
不能简单地说“异步会导致竞态”。更准确的判断是:
- 存在多个操作。
- 它们的完成顺序不确定。
- 它们会修改同一个结果或资源。
单独的异步读取通常没有问题。真正需要警惕的是异步完成后的写入:更新 UI、写缓存、跳转路由、提交订单、修改数据库。
可以把两者的关系压缩成一句话:
异步制造不确定顺序,副作用把这种不确定性变成真实影响。
因此,看到 await 时不必立刻焦虑。应该继续向后看:它回来以后会写什么?还有谁可能写同一个状态?
异步回来时,世界可能已经变了
假设用户先打开商品 A,又很快切换到商品 B。A 的请求出发时完全合理,但它返回时,页面的当前目标已经变成 B。
所以异步代码不能只按顺序理解:
发出请求 → 等待 → 得到结果 → 更新状态还要加入时间变化:
请求出发时:记录它服务于哪个意图
请求执行中:用户和系统状态可能变化
请求返回时:检查它是否仍然有效
确认有效后:才允许产生副作用真正要问的不是“请求成功了吗”,而是:
这个结果现在还有资格更新状态吗?
先判断多个异步操作是什么关系
不同关系需要不同的处理策略。
替代关系
搜索词、筛选项、Tab 或详情对象发生变化时,新请求会替代旧请求。
这类场景通常只认最后一次请求,或者直接取消旧请求。
并列关系
页面初始化时同时加载用户、权限、菜单和通知数量。这些请求互不替代,可以并发执行:
const [user, permissions, menus] = await Promise.all([
getUser(),
getPermissions(),
getMenus(),
])如果它们写入不同状态,竞态风险通常较低。
依赖关系
先获取用户,再根据 userId 获取订单。后一步依赖前一步,但订单返回时仍要确认当前用户没有切换。
合并关系
分页加载不是互相覆盖,而是把多页结果合并。此时要处理页码乱序、重复加载和结果去重,不能简单地让最后一个请求覆盖列表。
五种常见处理方式
1. 只认最后一次请求
给请求递增编号,只有最新编号有权写入状态:
let currentRequestId = 0
async function loadData(params: Params) {
const requestId = ++currentRequestId
const data = await fetchData(params)
if (requestId !== currentRequestId) return
setData(data)
}这适合搜索、筛选和详情切换。旧请求可以完成,但它的写入资格已经失效。
2. 取消旧请求
当旧请求不再有价值时,可以通过 AbortController 终止它:
let controller: AbortController | null = null
async function loadData(params: Params) {
controller?.abort()
const nextController = new AbortController()
controller = nextController
try {
const response = await fetch('/api/data', {
signal: nextController.signal,
})
setData(await response.json())
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') return
throw error
}
}取消可以减少无效工作,但“取消请求”和“阻止过期结果写入”是两个不同目标。并非所有异步操作都能被真正取消,资格校验仍然是更通用的边界。
3. 把结果绑定到查询条件
数据不是孤立存在的,它属于某组参数。请求返回时,比较响应对应的参数和当前参数;如果两者已经不同,就丢弃结果。
这比只保存一个裸 data 更清楚:状态不仅要记录结果,也要记录结果属于谁。
4. 阻止重复提交
保存、支付、删除等操作不应该只靠 UI 禁用,但前端可以先减少重复请求:
let submitting = false
if (submitting) return
submitting = true
try {
await submitForm()
} finally {
submitting = false
}5. 让后端守住最终一致性
前端只能降低重复操作和错误展示,不能承担最终安全边界。涉及数据修改时,后端仍需使用幂等键、唯一约束、事务、乐观锁或状态机校验。
例如支付按钮在前端被禁用,并不代表请求不会因重试、超时或多端操作而重复到达服务器。
React Effect 中的资格失效
React 组件里的典型问题是:依赖变化以后,上一次 Effect 发出的请求仍可能返回。
useEffect(() => {
let ignore = false
async function run() {
const data = await fetchUser(userId)
if (!ignore) setUser(data)
}
run()
return () => {
ignore = true
}
}, [userId])这里不是假设旧请求一定会被取消,而是在 Effect 清理后撤销它更新当前组件状态的资格。React 官方文档也使用这一模式避免数据请求的竞态问题。
一条可用于代码审查的问题链
以后检查异步代码,可以依次问:
- 这个操作的完成时间是否不受我控制?
- 它完成后会不会更新 UI、缓存、路由或数据库?
- 是否还有其他操作会写同一个状态?
- 用户意图或查询参数在等待期间会不会变化?
- 结果回来时,代码如何证明它仍然有效?
如果前四个问题的答案都是“会”,而第五个问题没有答案,通常就存在竞态风险。
异步编程的核心不是记住更多 await 写法,而是管理过期结果和状态所有权:
异步开始时,记录我是谁;异步回来时,确认我是否仍然有效;确认之后,再允许副作用发生。