前端请求竞态是什么?如何避免旧响应覆盖新状态?
简化版
请求竞态是多个请求先后发出,但响应顺序不确定,导致旧请求晚返回后覆盖新请求结果。典型场景是搜索框、分页切换、筛选条件变化和路由切换。解决方式包括取消旧请求、给请求打序号只接收最后一次、按参数 key 写入独立缓存、组件卸载时忽略结果,以及服务端返回版本信息。核心原则是:页面状态只能被当前有效请求更新。
详细版
竞态不是接口错误,而是异步顺序不可控。
| 场景 | 风险 | 解决 |
|---|---|---|
| 搜索输入 | 旧关键词结果覆盖新关键词 | 取消旧请求或序号判断 |
| 分页切换 | 第 1 页晚于第 2 页返回 | 只接收当前页 key |
| 路由切换 | 已离开页面仍 setState | 卸载清理 |
| 自动刷新 | 多批结果交错 | 版本号或时间戳 |
let latestRequestId = 0
async function search(keyword: string) {
const id = ++latestRequestId
const data = await fetch(`/api/search?q=${keyword}`).then(r => r.json())
if (id === latestRequestId) render(data)
}
竞态处理的关键不是让请求按顺序返回,而是只让有效结果更新界面。
完整版教学
一、请求竞态为什么常见
浏览器发出的请求会经过网络、网关、服务端和缓存,每个请求耗时都不固定。即使 A 比 B 先发出,也可能 B 先返回。
如果代码默认“后发请求一定后返回”,就会产生竞态问题。
二、搜索框案例
用户输入 a 后发请求,马上输入 ab 又发请求。如果 ab 先返回并渲染,随后 a 晚返回又渲染,页面就显示了旧结果。
这种问题在弱网和后端波动时特别明显。
三、方案一:取消旧请求
Fetch 可以使用 AbortController。
let controller: AbortController | null = null
async function load(url: string) {
controller?.abort()
controller = new AbortController()
return fetch(url, { signal: controller.signal })
}
取消可以减少无效网络和服务端压力,但不是所有请求都一定能在服务端停止处理。
四、方案二:只接收最后一次
有时旧请求无法取消,或者取消不是必须。可以用请求序号判断。
let seq = 0
async function loadUser(id: string) {
const current = ++seq
const user = await api.getUser(id)
if (current !== seq) return
state.user = user
}
这能保证旧响应不会覆盖新状态。
五、方案三:按 key 存储
如果数据本来就属于不同参数,不要共用同一个状态槽。
| 错误状态 | 更好状态 |
|---|---|
list | listByQuery[queryKey] |
detail | detailById[id] |
pageData | pageDataByPage[page] |
这样不同请求不会互相覆盖。
六、组件卸载和路由切换
组件卸载后请求返回,如果继续 setState,可能造成警告或状态污染。要在清理函数中取消请求,或用 mounted 标记忽略结果。
React、Vue、Svelte 都有各自的生命周期清理点,原则一致。
七、常见误区与追问
- 误区:只要 await 就不会竞态。 await 只让当前函数等待,不能保证多个异步任务全局有序。
- 误区:取消请求后后端一定不处理。 浏览器取消连接不代表服务端业务一定停止,写接口仍需幂等。
- 误区:所有旧响应都应该丢弃。 如果按 key 缓存,旧响应可以写回对应 key,但不能覆盖当前视图。
- 追问:AbortError 要怎么处理? 通常识别为主动取消,不作为错误提示给用户。
- 追问:分页竞态怎么处理? 响应回来时检查当前页码或查询 key 是否一致。
- 追问:竞态和防抖有什么关系? 防抖减少请求数量,但剩余请求仍可能乱序,需要竞态保护。
八、加强记忆
请求竞态记成“先出发不等于先到站”。解决靠取消旧请求、序号只认最后一次、按 key 存状态和卸载清理,核心是旧响应不能覆盖新界面。