本文目录
搜索框里最让人困惑的一类 bug 是:用户明明已经输入了新的关键词,列表却突然跳回旧结果。网络面板里每个请求都显示成功,服务器也没有返回错误,可界面就是像倒着走。
先想象一个更容易观察的场景。用户先输入 vu,马上又输入 vue。vu 的请求在路上绕了一点远路,vue 反而先回来。只要两个回调都直接写同一个 results,旧的 vu 就会在最后一次写入,把正确的 vue 列表覆盖掉。
这不是 Promise 顺序失控,也不是 Fetch 把响应发错了。多个异步流程都正确完成,只是它们对同一份界面状态的写入顺序,和用户对输入的时间顺序不一致。
旧结果不是错的,只是已经不该显示
先写一个尽量短的 Vue 3 版本:
<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 回调暂停后,第二个回调可以继续启动请求;它们恢复的先后由网络和调度决定,不由注册顺序保证。
可以用一个带延迟的模拟器把问题固定下来:
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 | 输入 vu | A | vu |
| 20ms | 输入 vue | A、B | vue |
| 100ms | B 完成 | A、B | vue |
| 500ms | A 完成 | A、B | 仍然是 vue |
最后一行就是业务规则:A 虽然是一个成功的请求,但它已经不代表当前输入。JavaScript 不知道“当前输入”是什么意思,Fetch 也不会替你判断哪个响应更值得展示。有效性必须由调用方保存并检查。
防抖减少请求,不决定谁有资格更新界面
搜索框常常先加防抖:用户停止输入 250 毫秒后才开始请求。
let timer: number | undefined
function debounceSearch(value: string) {
window.clearTimeout(timer)
timer = window.setTimeout(() => {
void search(value)
}, 250)
}防抖解决的是“每个字符都发一次请求”的浪费。它让 A 和 B 更少同时存在,却没有改变一个事实:请求发出后,服务器和网络仍可以以任意顺序完成。用户在一次防抖等待结束后再次输入,仍然会产生竞态。
节流也一样。它限制启动频率,不负责结果提交资格。要保护界面,仍要给每次工作一个身份,或取消已经过期的工作。
给每次搜索一个版本号
最朴素的保护是版本号。每次开始搜索就增加一次 requestId;回调恢复时,只有自己的编号仍是最新编号,才可以修改状态:
<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,还要保护 error 和 loading。如果旧请求失败后把新请求的错误提示覆盖掉,或者旧请求的 finally 把新请求的 loading 提前关掉,用户仍会看到错乱状态。一次异步操作对共享状态的每次写入,都要问“我还是当前版本吗?”
取消旧请求:AbortController 真正停止了什么
如果底层 API 支持取消,应该在旧请求失去意义时把它停掉。Fetch 通过 AbortSignal 接收这个信号:
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 被停止时,调用清理函数。
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,你得到的是“忽略结果”,不是“停止工作”。两者都可能有用,但成本不同。
把版本号和取消放在同一个搜索函数里
实际代码可以把两层保护合起来:
<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,还要把状态拆成明确的 idle、loading、ready、forbidden 和 error,不能用一个布尔值猜测所有阶段。
写入竞态则更危险:用户连续点击保存,或者两个标签页同时提交同一份草稿。简单丢弃旧响应可能让本地看起来“最后一次成功”,但服务器其实已经按另一种顺序写入。这里需要幂等请求、服务端版本检查或冲突解决;AbortController 只能停止尚未完成的客户端观察,不能替代数据协议。
可以把策略选择写成三个问题:这个工作是否允许被取消?结果是否有副作用?多个完成结果是只保留最新、必须全部完成,还是需要合并?读取搜索通常回答“可以取消、无副作用、最新优先”;批量上传通常回答“不能随便取消、每项有结果、需要收集”;支付提交则往往需要“服务端幂等、客户端可重试但不能重复扣款”。先回答问题,代码结构才不会被一个通用的 cancel() 抽象绑架。
还有一种常见情况是竞态发生在非 Fetch 工作上:图片解码、Worker 计算、IndexedDB 查询和第三方 SDK 都可能晚于下一次输入完成。它们未必支持 AbortSignal,但仍可以使用版本号、任务 token 或“组件是否仍挂载”的检查。把“取消”和“无权提交结果”分开,是这篇总结最值得留下的判断。
这也说明为什么竞态通常要在状态层收口。请求函数只负责拿到结果,组件或状态管理层决定结果是否仍属于当前视图。边界越清楚,后续把 Fetch 换成 Worker、缓存或本地数据库时,过期保护就不必全部重写。
先保护提交边界,再讨论调度优化,通常更稳。顺序清楚,调试也更容易,也更容易复盘和沟通协作,减少误判和返工。