异步竞态的解决方案:让过期的请求失去修改状态的资格

有些 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 按照各自完成的时间返回

调用顺序只是过去,完成顺序才是异步世界真正发生的事情。

所以处理异步竞态,本质上不是让所有事情变得同步。

而是承认:

它们本来就会乱序。

然后给每一个异步任务一个明确的生命周期:

产生
→ 有效
→ 失效
→ 完成
→ 丢弃

当一个请求已经失去意义时,它不需要被强行阻止。

它只需要在回来以后发现:

我已经过期了。

然后什么都不做就好。

posted @ 2026-08-20 16:02  Syinho  阅读(4)  评论(0)    收藏  举报