K 的一隅

JavaScript

旧请求为什么最后回来:前端请求竞态、取消与过期结果

用 Vue 搜索框复现旧响应覆盖新结果的问题,拆开请求竞态、防抖、AbortController、watch 清理与错误状态。

9 分钟阅读 更新于 2026-07-24
本文目录

搜索框里最让人困惑的一类 bug 是:用户明明已经输入了新的关键词,列表却突然跳回旧结果。网络面板里每个请求都显示成功,服务器也没有返回错误,可界面就是像倒着走。

先想象一个更容易观察的场景。用户先输入 vu,马上又输入 vuevu 的请求在路上绕了一点远路,vue 反而先回来。只要两个回调都直接写同一个 results,旧的 vu 就会在最后一次写入,把正确的 vue 列表覆盖掉。

这不是 Promise 顺序失控,也不是 Fetch 把响应发错了。多个异步流程都正确完成,只是它们对同一份界面状态的写入顺序,和用户对输入的时间顺序不一致。

旧结果不是错的,只是已经不该显示

先写一个尽量短的 Vue 3 版本:

vue
<script setup lang="ts">
import { ref, watch } from 'vue'

const keyword = ref('')
const results = ref<string[]>([])
const loading = ref(false)

watch(keyword, async (value) => {
  if (!value.trim()) {
    results.value = []
    return
  }

  loading.value = true
  const response = await fakeSearch(value)
  results.value = response
  loading.value = false
})
</script>

这段代码的问题不在 watch,而在它默认每个回调都有资格写入结果。第一个 watcher 回调暂停后,第二个回调可以继续启动请求;它们恢复的先后由网络和调度决定,不由注册顺序保证。

可以用一个带延迟的模拟器把问题固定下来:

ts
function fakeSearch(value: string) {
  const delay = value === 'vu' ? 500 : 80
  return new Promise<string[]>((resolve) => {
    setTimeout(() => resolve([`${value} 结果 1`, `${value} 结果 2`]), delay)
  })
}

先输入 vu,再输入 vue,你会先看到 vue,80 毫秒后又被 vu 覆盖。两个 Promise 都 fulfilled;最后一次完成的,不一定是最后一次输入产生的请求。

先复现:输入越快,列表越像在倒着走

给每次输入写一张时间表会更清楚:

时间事件当前请求允许写入的结果
0ms输入 vuAvu
20ms输入 vueA、Bvue
100msB 完成A、Bvue
500msA 完成A、B仍然是 vue

最后一行就是业务规则:A 虽然是一个成功的请求,但它已经不代表当前输入。JavaScript 不知道“当前输入”是什么意思,Fetch 也不会替你判断哪个响应更值得展示。有效性必须由调用方保存并检查。

防抖减少请求,不决定谁有资格更新界面

搜索框常常先加防抖:用户停止输入 250 毫秒后才开始请求。

ts
let timer: number | undefined

function debounceSearch(value: string) {
  window.clearTimeout(timer)
  timer = window.setTimeout(() => {
    void search(value)
  }, 250)
}

防抖解决的是“每个字符都发一次请求”的浪费。它让 A 和 B 更少同时存在,却没有改变一个事实:请求发出后,服务器和网络仍可以以任意顺序完成。用户在一次防抖等待结束后再次输入,仍然会产生竞态。

节流也一样。它限制启动频率,不负责结果提交资格。要保护界面,仍要给每次工作一个身份,或取消已经过期的工作。

给每次搜索一个版本号

最朴素的保护是版本号。每次开始搜索就增加一次 requestId;回调恢复时,只有自己的编号仍是最新编号,才可以修改状态:

vue
<script setup lang="ts">
import { ref, watch } from 'vue'

const keyword = ref('')
const results = ref<string[]>([])
const error = ref<string | null>(null)
const loading = ref(false)
let requestId = 0

watch(keyword, async (value) => {
  const currentRequestId = ++requestId

  if (!value.trim()) {
    results.value = []
    error.value = null
    loading.value = false
    return
  }

  loading.value = true
  error.value = null

  try {
    const nextResults = await fakeSearch(value)
    if (currentRequestId !== requestId) return
    results.value = nextResults
  } catch (cause) {
    if (currentRequestId !== requestId) return
    error.value = cause instanceof Error ? cause.message : '搜索失败'
  } finally {
    if (currentRequestId === requestId) loading.value = false
  }
})
</script>

这里的 requestId 不是请求在服务器上的编号,而是本地 UI 版本。A 完成时发现自己是旧版本,就安静地丢弃结果。它仍然可能消耗网络和服务器资源,但至少不会污染当前界面。

版本号还有一个容易漏掉的地方:不能只保护 results,还要保护 errorloading。如果旧请求失败后把新请求的错误提示覆盖掉,或者旧请求的 finally 把新请求的 loading 提前关掉,用户仍会看到错乱状态。一次异步操作对共享状态的每次写入,都要问“我还是当前版本吗?”

取消旧请求:AbortController 真正停止了什么

如果底层 API 支持取消,应该在旧请求失去意义时把它停掉。Fetch 通过 AbortSignal 接收这个信号:

ts
const controller = new AbortController()

fetch('/api/search?q=vue', { signal: controller.signal })
  .then((response) => {
    if (!response.ok) throw new Error(`HTTP ${response.status}`)
    return response.json()
  })
  .catch((cause) => {
    if (cause instanceof DOMException && cause.name === 'AbortError') return
    console.error('真正的请求失败', cause)
  })

controller.abort()

abort() 会让 Fetch 终止或尽快停止自己的工作,并让相关 Promise rejected。它不是把已经发送到服务器的请求从世界上抹掉,也不保证任何第三方 API 都理解 signal。服务器可能已经处理了请求,计时器也不会因为拿到一个 signal 就自动停止。

取消还不能替代结果保护。取消和完成可能发生在相邻时机,底层实现也可能在收到取消前已经给出结果;版本号仍是 UI 写入的最后一道门。取消负责减少无用工作,版本号负责确认结果资格。

Vue watch 的清理时机

Vue 的 watch 提供了一个很适合放取消逻辑的生命周期边界:下一次 watcher 重新运行前,或 watcher 被停止时,调用清理函数。

ts
watch(keyword, (value, _, onCleanup) => {
  const controller = new AbortController()

  onCleanup(() => controller.abort())
  void search(value, controller.signal)
})

当关键词从 vu 变成 vue,Vue 会先执行 A 的清理函数,再运行 B 的 watcher。组件卸载、手动停止 watcher 也会走清理。这样“用户开始下一次搜索”和“页面不再需要这个搜索”都能复用同一个取消入口。

清理函数不是一个神奇的撤回按钮。它只能执行你注册的清理动作,而且必须真正把 signal 传到底层请求。若只是设置一个 cancelled = true,你得到的是“忽略结果”,不是“停止工作”。两者都可能有用,但成本不同。

把版本号和取消放在同一个搜索函数里

实际代码可以把两层保护合起来:

vue
<script setup lang="ts">
import { ref, watch } from 'vue'

type SearchState = { items: string[]; error: string | null }

const keyword = ref('')
const state = ref<SearchState>({ items: [], error: null })
const loading = ref(false)
let requestId = 0

watch(keyword, (value, _, onCleanup) => {
  const currentRequestId = ++requestId
  const controller = new AbortController()
  onCleanup(() => controller.abort())

  if (!value.trim()) {
    state.value = { items: [], error: null }
    loading.value = false
    return
  }

  loading.value = true
  state.value = { items: [], error: null }

  void (async () => {
    try {
      const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
        signal: controller.signal,
      })
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      const items = await response.json() as string[]
      if (currentRequestId !== requestId) return
      state.value = { items, error: null }
    } catch (cause) {
      if (currentRequestId !== requestId) return
      if (cause instanceof DOMException && cause.name === 'AbortError') return
      state.value = { items: [], error: cause instanceof Error ? cause.message : '请求失败' }
    } finally {
      if (currentRequestId === requestId) loading.value = false
    }
  })()
})
</script>

这里的顺序有意义:先产生版本和 controller,再注册清理;空关键词也会让版本前进,使刚才的请求失去写入资格。finally 只关闭仍属于当前版本的 loading,避免旧请求结束时把新请求的加载提示关掉。

loading、错误与空结果也会发生竞态

很多实现只盯着列表,却忘了 UI 状态还有其他共享字段。旧请求可能造成四种错觉:

  • 旧请求的结果覆盖新列表。
  • 旧请求的错误覆盖新请求正在加载的状态。
  • 旧请求的 finally 让新请求看起来已经结束。
  • 用户清空输入后,旧响应又把列表填回来。

所以“忽略旧结果”应理解为忽略旧请求对整组状态的写入,而不只是忽略数组。把列表、错误和 loading 放在同一个带版本检查的提交点,通常比在多个分支里各自打补丁更容易审查。

HTTP 404 或 500 也要单独处理。Fetch 通常会为它们返回 fulfilled 的 Response,因此 try/catch 不会自动把业务失败拦住;要先检查 response.ok。相反,用户主动取消通常是预期控制流,不应显示成红色“搜索失败”。

容易踩的坑:abort 不是万能的“撤回”

第一种误解是“加了防抖就没有竞态”。防抖只延迟和合并启动,无法规定网络响应顺序。

第二种误解是“调用 abort 后 catch 不会执行”。取消 Fetch 通常正是通过 rejection 通知调用方,所以要在 catch 中识别 AbortError,而不是把它当成未处理异常。

第三种误解是“只要取消请求,就不需要 requestId”。取消不是原子时间机器;响应可能已经完成,或底层 API 根本不支持取消。提交前的版本检查仍然便宜且可靠。

第四种误解是“最后一次请求永远最重要”。搜索输入通常是 latest-wins,但保存、支付、消息发送的语义可能完全不同。先写出业务要什么,再选取消、排队、幂等或合并策略。

先判断是哪一种竞态

“请求竞态”不是一个只对应搜索框的单一 bug。至少可以把它分成三种:读取竞态、展示竞态和写入竞态。

读取竞态发生在用户不断改变查询条件时,例如搜索联想、城市筛选、文章目录。旧读取结果没有副作用,最常见策略是版本号加取消,目标是让界面只展示当前条件。

展示竞态发生在多个来源都能改变同一块 UI 时。例如一个请求加载详情,另一个请求刷新权限;详情先回来不代表已经可以展示,界面还要等权限状态确定。此时除了 requestId,还要把状态拆成明确的 idleloadingreadyforbiddenerror,不能用一个布尔值猜测所有阶段。

写入竞态则更危险:用户连续点击保存,或者两个标签页同时提交同一份草稿。简单丢弃旧响应可能让本地看起来“最后一次成功”,但服务器其实已经按另一种顺序写入。这里需要幂等请求、服务端版本检查或冲突解决;AbortController 只能停止尚未完成的客户端观察,不能替代数据协议。

可以把策略选择写成三个问题:这个工作是否允许被取消?结果是否有副作用?多个完成结果是只保留最新、必须全部完成,还是需要合并?读取搜索通常回答“可以取消、无副作用、最新优先”;批量上传通常回答“不能随便取消、每项有结果、需要收集”;支付提交则往往需要“服务端幂等、客户端可重试但不能重复扣款”。先回答问题,代码结构才不会被一个通用的 cancel() 抽象绑架。

还有一种常见情况是竞态发生在非 Fetch 工作上:图片解码、Worker 计算、IndexedDB 查询和第三方 SDK 都可能晚于下一次输入完成。它们未必支持 AbortSignal,但仍可以使用版本号、任务 token 或“组件是否仍挂载”的检查。把“取消”和“无权提交结果”分开,是这篇总结最值得留下的判断。

这也说明为什么竞态通常要在状态层收口。请求函数只负责拿到结果,组件或状态管理层决定结果是否仍属于当前视图。边界越清楚,后续把 Fetch 换成 Worker、缓存或本地数据库时,过期保护就不必全部重写。

先保护提交边界,再讨论调度优化,通常更稳。顺序清楚,调试也更容易,也更容易复盘和沟通协作,减少误判和返工。