异步竞态的解决方案:让过期的请求失去修改状态的资格
有些 Bug 很难复现。
它们通常发生在一个人操作得足够快的时候。
点击一次,搜索开始。
再点击一次,另一个搜索开始。
然后地图上的结果偶尔不是最后一次点击对应的结果,loading 偶尔一直存在,搜索框里的文字偶尔又被改回了上一次的内容。
如果慢一点操作,一切正常。
如果足够快,一切开始变得混乱。
这类问题有一个很准确的名字:
异步竞态(Race Condition)。
它并不一定意味着代码写错了。
恰恰相反,所有代码单独看起来都可能是合理的。真正的问题在于:多个异步任务同时存在,而它们完成的顺序并不受调用顺序控制。
1. 请求的顺序,并不等同响应的顺序
假设页面上有一个企业搜索。
用户依次选择:
企业 A
↓
企业 B
↓
企业 C
代码大概是这样的:
async function handleSelect(company) {
const result = await search(company.address)
searchResult.value = result
}
看起来没有什么问题。
但真实世界里的时间线可能是:
用户点击 A
│
├── request A ────────────────┐
│ │
用户点击 B │
│ │
├── request B ────────┐ │
│ │ │
用户点击 C │ │
│ │ │
├── request C ──┐ │ │
│ │ │ │
│ ▼ │ │
│ response C │
│ ▼ │
│ response B │
│ ▼
│ response A
最终的执行顺序可能变成:
C → B → A
但用户真正想要的状态是:
C
于是出现了一个很隐蔽的问题:
A 并没有执行错误。B 也没有执行错误。C 同样没有执行错误。
它们只是都认为:
“我是这一次请求的结果,所以我可以更新页面。”
而这恰恰是错误的。
因为在异步系统里:
请求发起时有效,不代表请求完成时仍然有效。
2. 我遇到的实际问题:高德搜索中的快速切换
之前在地图业务中遇到过一个非常典型的场景。
左侧是地点列表,用户可以快速点击不同地点。
点击地点之后,需要完成一系列异步操作:
选择地点
↓
获取地点名称
↓
高德搜索地点
↓
根据搜索结果采取一系列的异步请求
↓
地图绘制相关覆盖物
↓
更新搜索框
↓
关闭 loading
正常情况下:
企业 A
→ 搜索 A
→ 绘制 A
→ 完成
用户如果操作得快一些,就会变成:
A
├─ 搜索 A
│
B
├─ 搜索 B
│
C
├─ 搜索 C
问题开始出现。
例如:
搜索 A:800ms
搜索 B:300ms
搜索 C:100ms
最终返回:
C
B
A
如果代码直接把结果写进响应式状态:
const result = await searchAddress(keyword)
mapLocation.value = result
那么最后一次写入的实际上是:
A
但用户最后选择的是:
C
于是地图显示了 A。
从用户视角来看:就变成了选择的是C, 但显示的不是C相关的图层与覆盖物
关键点在于, 当用户点击C的那一刻, A的相关异步请求就已经失去了更新当前UI的资格
3. 最简单的方案:请求序号
不需要复杂的取消机制,也不需要把整个业务改造成 Promise 队列。
只需要给每一次异步请求一个请求id
let requestId = 0
async function handleSearch(keyword) {
const currentId = ++requestId
const result = await searchAddress(keyword)
if (currentId !== requestId) {
return
}
searchResult.value = result
}
这里最重要的是:
const currentId = ++requestId
假设连续执行三次:
A → requestId = 1
B → requestId = 2
C → requestId = 3
那么:
A 的 currentId = 1
B 的 currentId = 2
C 的 currentId = 3
如果最终返回顺序是:
C
B
A
C 返回:
3 === 3
允许更新。
B 返回:
2 !== 3
直接丢弃。
A 返回:
1 !== 3
直接丢弃。
于是最终状态永远只能由:
C
写入。
这样的话, 无论派遣了多少请求出去, 永远都只采用当前最后一次请求的响应数据
4. 如果异步流程不止一个呢?
真实业务里往往不是:
搜索 → 返回
而是:
搜索地址
↓
获取坐标
↓
计算地图范围
↓
绘制 Marker
↓
绘制 Polygon
↓
设置中心点
这时候竞态会更加明显。
例如:
async function handleCompany(company) {
const version = ++operationVersion
const location = await searchAddress(company.address)
if (version !== operationVersion) return
const polygon = await getPolygon(location)
if (version !== operationVersion) return
drawPolygon(polygon)
if (version !== operationVersion) return
map.setCenter(location)
}
这里每一次 await 都可以看作一个潜在的时间断点。
因为:
await
意味着:
“我现在把执行权交出去,等未来某个时间再回来。”
而在这个“未来”到来之前,用户完全可能已经做了其他操作。
所以一个非常实用的原则是:
凡是会跨越
await的状态,都应该重新确认当前操作是否仍然有效。
5. AbortController:真正取消请求
如果底层 API 支持取消,那么还有另一种方案:
let controller = null
async function search(keyword) {
controller?.abort()
controller = new AbortController()
const result = await fetch(`/api/search?q=${keyword}`, {
signal: controller.signal
})
return result.json()
}
这样当 B 开始时:
A
↓
abort A
B
↓
request B
A 会被主动取消。
这比单纯的版本号更进一步:
版本号:
旧请求可以继续跑,但不能提交结果。
AbortController:
旧请求直接停止。
但是这两种方案并不是互斥的。
实际工程里,我更倾向于:
能取消 → 取消
不能取消 → 版本校验
因为不是所有第三方 SDK 都提供可靠的取消机制。
尤其是一些地图 SDK、第三方搜索服务、内部封装方法,你并不一定拥有底层 Promise 的控制权。
6. 一个更完整的实现
最终可以把这套思路抽象成一个简单的异步任务模型:
let operationId = 0
async function executeLatest(task) {
const currentId = ++operationId
try {
const result = await task()
if (currentId !== operationId) {
return
}
return result
} catch (error) {
if (currentId !== operationId) {
return
}
throw error
}
}
使用时:
const result = await executeLatest(() => {
return searchAMap(company.address)
})
if (!result) {
return
}
drawLocation(result)
它的思想非常简单:
任务产生
↓
获得版本
↓
执行异步操作
↓
等待
↓
重新确认版本
↓
仍然有效?
┌───────┴───────┐
是 否
↓ ↓
提交结果 丢弃结果
这其实已经形成了一种很稳定的工程模式。
结语
异步代码真正麻烦的地方,并不是 Promise 本身。
而是时间。
同步代码的世界里:
A()
B()
C()
通常意味着:
A → B → C
但异步代码里:
await A()
await B()
await C()
真正发生的事情是:
A 发起
↓
世界继续运行
↓
用户产生新的操作
↓
B 发起
↓
又产生新的操作
↓
C 发起
↓
某个时刻
↓
A / B / C 按照各自完成的时间返回
调用顺序只是过去,完成顺序才是异步世界真正发生的事情。
所以处理异步竞态,本质上不是让所有事情变得同步。
而是承认:
它们本来就会乱序。
然后给每一个异步任务一个明确的生命周期:
产生
→ 有效
→ 失效
→ 完成
→ 丢弃
当一个请求已经失去意义时,它不需要被强行阻止。
它只需要在回来以后发现:
我已经过期了。
然后什么都不做就好。

浙公网安备 33010602011771号