我们做Web开发的时候,经常会遇到一种很诡异的现象:页面明明加载的是最新数据,显示的却是几秒钟之前的旧内容;或者明明先点了A按钮,后点了B按钮,最后界面呈现的结果却是A的返回。典型的场景,就是搜索框里输入关键词,快速删掉又输入新的,结果联想列表闪来闪去,最后停在错误的词条上;又比如分页表格,快速翻页到第10页,最后一屏显示的却是第3页的数据。这类问题背后的元凶,就是请求顺序覆盖问题。
我最早被这个问题坑,是一次后台管理系统的筛选功能。用户选了一个时间范围,接着马上切换状态筛选,然后疯狂点击“查询”,请求全发出去了。因为后端接口处理时间不稳定,先发出去的请求可能最后才返回,把后发请求的结果给覆盖了。当时的界面没有做任何防护,数据乱了,用户一脸懵,我还找不到原因。后来我把整个请求生命周期、交互层、浏览器机制、后端校验全部梳理了一遍,才彻底搞清楚这套玩法。
这篇文章就是要把这块彻底讲透。不管你是写Vue、React还是React Native,不管是传统的Ajax还是fetch,不管是做企业级管理系统还是Python Dash这种快速数据应用,只要涉及异步请求,早晚会碰到这个坑。我会把这套东西从前端交互、网络调度、请求取消、后端兜底几个层面完整拆开,配合实际可跑的代码,讲清楚为什么会出现、怎么解决、怎么验证。
1. 不是网速的锅:请求覆盖发生的真实链路
很多人第一反应是“是不是网速不行”。其实大部分请求顺序覆盖问题,跟网速没关系。就算所有请求都在同一台服务器的同一个接口上,只要它们不保证“先发先到”,就存在乱序覆盖的可能。要理解这个问题,得先看清楚数据链路。
1.1 从HTTP到浏览器的调度真相
HTTP协议本身,是没有“请求顺序”语义的。浏览器可以同时发起6个(HTTP/1.1)甚至更多的并发请求(HTTP/2多路复用后,并发数大幅提升),每个请求在网络上走的是独立连接或独立流。服务端处理每个请求的时候,也基本是各处理各的,响应到达浏览器的先后,完全由服务端处理耗时、网络中间节点、网关转发策略共同决定。
你点击查询一次,查询条件A的请求发出去了;紧接着点击第二次,查询条件B的请求也发出去了。如果A请求对应的SQL特别慢,B请求的SQL特别快,那么B的响应会先到,A的响应后到。前端代码如果只做“谁最后返回就显示谁”,那结果就是:界面上显示的是A的数据,但你自己知道你最后想要的其实是B。
这就好比两个人同时从不同地方给你寄快递,B寄的是顺丰次日达,A寄的是普通平邮。结果B先到了,你先签收了B;第二天A才到,你又签收了A。此时你手里拿着A,但你以为你拿的是最新的。问题的关键不在快递公司速度,而在签收逻辑:你没有一个“这个包裹是不是我要的最后一个”的判断机制。
1.2 乱序覆盖最常见的三种现场
结合我自己的项目和网友反馈,请求顺序覆盖问题最常出现在这几类交互上:
- 搜索/输入联想:输入框每次输入触发一次请求,快速输入“abc”再改成“ab”,如果第一个“abc”的响应比第二个“ab”晚到,输入框显示联想词可能还是“abc”相关的。
- 表格筛选/分页:连续切换筛选条件、翻页比较快时,后一页的响应先到,然后旧请求的响应又回来了,页面被拉回上一页的数据。
- Tab切换/组件复用:比如左侧菜单切换详情页,每个详情页都发起请求,切到B页面之后,A页面请求的响应才回来,把B页面的内容覆盖了。
这三个场景背后,本质上都是同一个问题:界面没有把“请求”作为一个有序的、可取消或可丢弃的资源来管理。大多数初学阶段的人,只会用Promise.then直接setData,根本没想过“如果这个Promise已经过期了,它的resolve应该被忽略”。
1.3 为什么“防抖+节流”只能算安慰剂
很多人的第一道防线是防抖和节流,确实能降低请求频率,减少出现覆盖的概率,但解决不了核心矛盾。防抖只是把“相邻两次触发”合并成一次,可一旦用户操作间隔大于你设定的防抖时间,照样会出现两个独立的请求在飞行中。节流也一样,它只是限定固定时间窗口里最多发一个请求,但上一个请求如果还在飞,下一个请求已经出去了,两个并行请求依然可能乱序返回。
所以防抖和节流是“减少交通事故”的手段,不是“保障道路顺序”的手段。要彻底解决,必须从请求生命周期上入手。
2. 第一道防线应该建立在交互层:能锁的就别等
在动手写复杂度较高的方案之前,先检查交互层面有没有可以优化的空间。对很多业务系统来说,交互层加锁,是最低成本的方案,效果也立竿见影。
2.1 请求期间的“按钮禁用”是最朴素的锁
如果是表单提交、登录、保存这类“一次操作,等待结果”的动作,最简单的做法是在请求期间把按钮禁用掉,请求完成后再恢复。这样用户想连续点击也点不动,自然不会出现两个并行请求。
具体实现很简单,但还是有几个隐藏细节。我见过很多人只disable了按钮,却没有处理回车键、快捷键触发的情况。还有一些人用了一个全局的isSubmitting标识,却忘了在finally里复位,导致请求没问题但按钮永远点不动。
正确做法是给按钮绑定一个loading状态,或者用一个统一的指令/组件封装。以Vue 3为例:
<template> <button :disabled="loading" @click="handleSubmit"> {{ loading ? '提交中...' : '提交' }} </button> </template> <script setup> import { ref } from 'vue' const loading = ref(false) async function handleSubmit() { if (loading.value) return loading.value = true try { await api.submit() } finally { loading.value = false } } </script>这种方案的本质,是把“并发”变成了“串行”:同一个按钮在一个时刻最多只能有一个请求。副作用是用户必须等上一个请求返回,但很多强一致性的业务场景(比如提交订单、支付确认)本来就该这么做。
2.2 像“文件锁”一样使用互斥标记
某些场景下按钮不能随便禁用,比如你希望用户在当前请求尚未完成时,可以继续浏览页面,只是不能发起同类型的第二次请求。这种时候可以用一个互斥标记。
let searchRequestInFlight = false async function search(keyword) { if (searchRequestInFlight) { console.warn('已有搜索请求进行中,忽略本次触发') return } searchRequestInFlight = true try { const res = await fetch(`/api/search?q=${keyword}`) // render(res) } finally { searchRequestInFlight = false } }这个方案的优点是简单,缺点是会丢弃用户最后一次触发的意图。比如用户已经点了两次,第二次因为“锁”没释放被丢掉,用户就会觉得“我明明点了没反应”。所以互斥标记适合高频、状态敏感度不高的场景,不适合对最终结果有严格预期的场景。
2.3 交互层加锁的取舍边界
交互层加锁,适用的场景核心特征就是“并发没有意义”。比如写入类操作、登录、支付、保存草稿,这些操作天然不允许并发,锁住就是最合理的。但对于阅读、查询、刷列表这种高频动作,并发本来就是常态,强行加锁会伤害体验。这时要往下看——真正的解决方案在请求层。
这里有个经验可以参考:如果请求是“幂等读操作”(比如查询),不要用这种lock方案,用请求序号方案更好;如果请求是“写操作”(比如提交、保存、删除),优先考虑disable或lock,实在要允许并发,一定要做后端幂等校验,后面会细说。
3. 给每个请求发一个“身份牌”:请求序号方案全解
这是应对“请求乱序覆盖”最经典、最实用、性价比最高的一套方案。核心思路非常简单:每次发起请求的时候,生成一个不断递增的序号,保存到局部变量里。响应返回后,先检查“当前最新序号”和“这个响应携带的序号”是否一致,一致才更新界面,不一致直接丢弃。
3.1 为什么它有效:用“最新意图”取代“最早返回”
这个方案的灵魂在于,不是“谁先到谁生效”,而是“谁最后发起,谁才有资格生效”。用户最后一次操作代表他的最新意图,序列号机制保证了只有最新意图对应的响应能渲染,其他旧响应一律作废。
用一个生活化的类比理解:超市排队取号,你都叫到100号了,60号还跑过来想结账,收银员当然不认。请求序号也一样,它是每个请求的“排队号”,后发请求序号更大,界面的“当前生效号”始终指向最新序号。
3.2 从零手写一个序号守卫函数
不依赖任何框架,用原生JavaScript写一个通用工具:
// 生成一个自增序号 let __requestSeq = 0 // 返回一个“判决器”,用来判断某个响应是否过期 function createLatestGuard() { const currentSeq = ++__requestSeq return { isLatest(respSeq) { return respSeq === currentSeq }, getSeq() { return currentSeq }, } } async function fetchSearch(keyword) { const guard = createLatestGuard() const mySeq = guard.getSeq() try { const res = await fetch(`/api/search?q=${keyword}`) const data = await res.json() if (guard.isLatest(mySeq)) { renderResult(data) } else { console.log('丢弃过期响应:', keyword, mySeq) } } catch (e) { if (guard.isLatest(mySeq)) { handleError(e) } } }这段代码里有两个关键点,我后来复盘时觉得特别重要:
createLatestGuard里的currentSeq是闭包变量,每次调用都会拿到“当时的全局序号”。两次请求并发时,后发请求的序号一定更大。- 错误处理也要走guard判断。不然可能出现更诡异的情况:旧请求报错了,弹了一个错误提示,但用户当时操作的是新请求,体验非常割裂。
3.3 React和Vue里的典型写法
React函数组件里用useRef保存最新序号,避免重新渲染时丢失状态:
function SearchComponent() { const latestSeqRef = useRef(0) async function handleSearch(keyword) { const seq = ++latestSeqRef.current const res = await fetch(`/api/search?q=${keyword}`) const data = await res.json() if (seq === latestSeqRef.current) { setResult(data) } } return <input onChange={(e) => handleSearch(e.target.value)} /> }Vue 3里的Composition API也类似,可以用一个普通的模块级变量,也可以放在组件的setup作用域里:
<script setup> import { ref } from 'vue' const result = ref(null) let seq = 0 async function search(keyword) { const current = ++seq const res = await fetch(`/api/search?q=${keyword}`) const data = await res.json() if (current === seq) { result.value = data } } </script>这里额外提醒一个陷阱:不要把seq放在reactive或ref里。如果把它变成响应式数据,每次++seq都会触发组件更新,增加无谓渲染,而且在高频操作时可能因为渲染调度导致序号判断不稳定。用普通变量或ref(React的useRef)就够了,它只是“膜拜用的令牌”,不需要驱动视图。
3.4 撤销与竞态如何配合:序号方案的进阶用法
序号方案还有一个好用的点,就是它可以同时覆盖“撤销请求”和“响应过期”两个场景。如果用户触发了新请求,旧请求理论上就可以被丢掉了,那么UI层面可以用AbortController主动把旧请求取消掉,节省带宽和服务器压力;如果取消不及时,也没关系,序号判断兜底了。
一个更完善的模式是:发新请求时,先取消上一个未完成的请求,再为当前请求生成新序号。
let currentController = null let reqId = 0 async function search(keyword) { // 取消上一次未完成的请求 if (currentController) { currentController.abort() } currentController = new AbortController() const currentId = ++reqId try { const res = await fetch(`/api/search?q=${keyword}`, { signal: currentController.signal, }) const data = await res.json() if (currentId === reqId) { render(data) } } catch (err) { if (err.name === 'AbortError') { // 被主动取消,不需要处理 } else if (currentId === reqId) { handleError(err) } } }这个方案就是“取消+序号”双重保障。取消了能一定程度上减少后端的无效计算;序号兜底保证了即使取消失败(比如请求已经到达服务端、响应已经在路上),渲染层也不会被旧数据污染。
4. 更彻底的方案:用AbortController真正取消请求
如果你觉得序号方案只是“丢弃结果”,资源还是浪费了,那可以更进一步,把“请求真的拉闸”,这就是AbortController干的事。现在浏览器和Node.js 15+都支持AbortController,Vue和React生态也普遍封装了cancel token,其实底层就是AbortController。
4.1 AbortController的工作原理和用法
AbortController非常简单,它有一个signal属性和一个abort()方法。signal传到fetch的option里,当abort()被调用时,浏览器会中止这个fetch请求,抛出一个名为AbortError的错误。
const controller = new AbortController() fetch('/api/data', { signal: controller.signal }) .then((res) => res.json()) .then((data) => render(data)) .catch((err) => { if (err.name === 'AbortError') { console.log('请求已取消') } else { console.error('其他错误', err) } }) // 某个时机下取消 controller.abort()对用户来说,取消请求最直观的效果,就是页面里不会再出现因为旧请求残留导致的闪现。而且网络面板里能看到请求状态变成(canceled),对排查问题很有帮助。
4.2 在axios里怎么取消(CancelToken与AbortController)
axios是老牌请求库。axios早期用的是CancelToken,后来axios 1.x也支持了AbortController风格。这里强烈建议统一用AbortController,因为它是标准API,cancelToken更像历史包袱,而且CancelToken官方文档里也标记了“deprecated”的倾向。
import axios from 'axios' let controller = null async function getProducts(params) { if (controller) { controller.abort() } controller = new AbortController() try { const res = await axios.get('/api/products', { params, signal: controller.signal, }) render(res.data) } catch (err) { if (axios.isCancel(err)) { console.log('请求被取消', err.message) } else { handleError(err) } } }注意,axios取消时抛的错误有几种形态:老版本CancelToken抛的是Cancel对象,新版本AbortController抛的是DOMException,命名不统一。最好通过axios.isCancel(err)来判断,同时对其他异常再做兜底处理。
4.3 AbortController的局限:取消不了已到达服务端的请求
AbortController本质上只是“浏览器不再等待响应”,并不代表“服务端已经停止处理”。如果请求已经到达了后端接口并且进入了处理逻辑,取消信号不会自动杀掉后端线程/进程。所以,AbortController解决的是“前端别再等这个响应了”的问题,服务端该算的、该写的可能一样都没少。
明白这个局限很重要。如果你在写一个支持并发的写接口,不能只依赖前端取消来防双写,后端必须有幂等校验或版本号兜底。前端取消更多是优化体验,真正的强一致性要后端保证。
5. Vue实战:组件卸载与Pinia全局竞态处理
前面讲的都是单组件场景。真实业务里,比这更棘手的是跨组件和全局状态下的竞态问题。比如一个清单页+一个详情页,你在详情页发起请求,然后快速返回列表页,再从列表页进入另一个详情页,这时候前一个详情页的请求如果返回了,Vue组件可能都已经卸载了,状态更新会直接报错,或者更新到store里导致数据串场。
5.1 组件卸载后的setState警告与清理
Vue 3中,在组件卸载后修改响应式数据,通常不会像React那么暴力地报warning,React 18之前的经典警告是“Can't perform a React state update on an unmounted component”。但不管有没有警告,数据污染是实际存在的。
解决思路有两条。一条是组件卸载时取消所有在途请求,另一条是组件卸载后把“是否已卸载”标记置位,响应回来发现标记为true直接丢弃。
Antd、Element Plus这些组件库的源码里,都大量用到了类似“cancel” token的机制,在beforeUnmount/unmount钩子里调用取消方法。
<script setup> import { onUnmounted, ref } from 'vue' const data = ref(null) let controller = null async function fetchDetail(id) { if (controller) { controller.abort() } controller = new AbortController() try { const res = await fetch(`/api/detail/${id}`, { signal: controller.signal, }) data.value = await res.json() } catch (err) { // AbortError 忽略,其他错误按需处理 } } onUnmounted(() => { if (controller) { controller.abort() } }) </script>5.2 Pinia里处理并发请求的全局竞态
如果多个页面共享同一个Store里的数据,而且每个页面都可能触发刷新,问题就更微妙了。比如一个全局的“公告列表”Store,详情页A刷新了它,列表页B也刷新了它,两个请求都写同一个state,后返回的覆盖先返回的。
这种场景我建议在Store里做一个全局的请求治理中心。可以用一个map记录每个action的“当前最新序号”,任何组件调用action时拿到自己的序号,action内部在写state前做序号比对。
// store/announce.js import { defineStore } from 'pinia' export const useAnnounceStore = defineStore('announce', { state: () => ({ list: [], listVersion: 0, }), actions: { async fetchList() { const currentVersion = ++this.listVersion const res = await fetch('/api/announce') const data = await res.json() if (currentVersion === this.listVersion) { this.list = data } }, }, })这样即使组件A和组件B几乎同时调用了fetchList,也只有最后一次调用的响应能真正写入list。当然,这要求你必须在每次触发新请求前确认“这是用户最新的意图”。如果你是后台定时轮询,则要单独设计,不能简单用版本号。
5.3 VueUse的useFetch和VueRequest的竞态处理
社区里其实已经有人把这类逻辑封装好了,直接用能省不少事。
VueUse的useFetch自带abort能力,而且可以配合isFetching状态来做UI反馈。VueUse还提供了useAsyncState这类API,能通过reset和reload来覆盖请求。
VueRequest(一个很有意思的Vue请求库)提供了ready、cancel、refresh等内置状态,底层同样处理了竞态问题。如果你在做一个中后台复杂系统,不想手写这么多细节,完全可以引入它。它会帮你管理请求缓存、去重、轮询和分页,竞态处理是默认能力。
不过我的经验是:对于基础薄弱或者刚接手项目的人来说,先手写一轮序号守卫,理解透彻了,再去用这些库会更顺畅。因为库给你的“开箱即用”,一旦出问题,你连排查方向都找不到。自己写过一遍,至少知道是哪个环节出了问题。
6. 数据密集型应用怎么办:以Dash和配电工艺图场景为例
热搜词里出现了“python+dash快速web应用开发”“web vue 开发 配电工艺图”这两类场景。这些领域的特点是:页面长连接、实时轮询、数据可视化,而且经常同时显示多路数据。请求顺序覆盖在这里更隐蔽,也更危险。
6.1 Python Dash里的回调竞态问题
Dash是Python写Web应用的一个好东西,它把Flask+React封装成了回调机制。但Dash的“多输入多输出”回调里,如果同一个输出被多个回调或同一回调的多个触发源更新,就非常容易出现竞态。比如一个下拉筛选框,用户快速切换选项,每次切换都会触发回调拿新数据。如果旧回调还没结束,新回调又开始了,Dash默认怎么处理?
Dash官方其实是知道这个问题的。它提供了allow_duplicate、prevent_initial_call等参数,但并没有把“取消过期请求”作为一个默认行为。在低版本的Dash里,你甚至可能看到回调的输出被旧数据覆盖的bug。
我建议的做法是,在Dash回调里用全局token做序号判断:
import dash from dash import Input, Output, State, dcc, html import threading app = dash.Dash(__name__) fetch_token = {"seq": 0} @app.callback( Output("my-output", "children"), Input("my-dropdown", "value"), prevent_initial_call=True, ) def update_output(value): current_seq = fetch_token["seq"] + 1 fetch_token["seq"] = current_seq # 模拟耗时请求 data = query_database(value) if current_seq == fetch_token["seq"]: return html.Div(data) return dash.no_update这里要注意,query_database是阻塞的,fetch_token修改后,如果旧回调还在跑,新回调会拿到更大的序号。旧回调返回后发现序号落后了,返回dash.no_update,Dash就不会更新界面。这个方案本质和前端JS里的序号守卫一模一样,只是搬到Python侧。
6.2 配电工艺图/配电柜场景的实时监测数据
配电工艺图、电路场控图这类业务,后台会持续采集电流、电压、开关状态等遥信遥测数据,前端要么是WebSocket长连接,要么是定时轮询。这类场景的请求顺序覆盖问题又不太一样:你面对的不是“用户点击触发的离散请求”,而是“数据源周期性推送的连续数据流”。
如果轮询请求发生乱序,先后返回的电压数据覆盖来覆盖去,配电柜上的状态图就会闪烁跳变,甚至会误报“开关状态异常”。这种场景,前端一定要做时间戳或版本号比对。我参与过的项目中,我们给每个遥测包打上设备侧时间戳,前端比较新旧包的时间戳,只接受比当前更新鲜的数据包。这个在实时监控里尤其重要,因为旧包后到会直接造成误判。
以一个Vue + 轮询为例:
let latestTimestamp = 0 async function pollTelemetry() { const res = await fetch('/api/telemetry') const data = await res.json() if (data.timestamp > latestTimestamp) { latestTimestamp = data.timestamp renderTelemetry(data) } }这个逻辑就是“单调递增时间戳校验”,通过严格只接受更新的数据包,从根源杜绝旧包覆盖新包的场景。相比请求序号,时间戳方案更符合数据本身的时间语义,而且还能顺带实现数据去重。
6.3 WebSocket消息乱序:也许你也遇到了
说到实时数据,还要提一种非常容易误判的场景:有时候你以为自己在处理“请求覆盖”,实际上遇到的是WebSocket消息乱序。
WebSocket协议本身保证消息在单条连接上的有序性,但在某些网关、代理、负载均衡服务器配置不当的情况下,或者前端做了多路订阅、断线重连、补偿拉取时,消息依然可能乱序。比如,断线重连后,后端把缓存里的旧消息和新消息一起推过来,旧消息排在前面,数据覆盖了。
对这种场景,处理方案也是统一的:给每条消息加一个msgId或timestamp,前端做一个“去重+保序”队列。我自己的习惯是,所有推送消息都带server_time和msg_id,前端永远只认可最新server_time或大于本地序号的msg_id,其余一律丢弃。这样不管上游怎么乱,展示层永远单调递增。
7. 后端兜底:版本号、条件更新与幂等键
前端做了再完美的措施,也架不住某些极端场景:请求实际上已经到后端了,而且后端产生了副作用(比如写库)。这个时候,序列号在前端只是把“显示”挡掉了,但后端的副作用还在。真要保证数据一致,后端也要参与进来。
7.1 乐观锁:用版本号挡住旧操作
后端写操作防乱序,最朴素也最可靠的方案就是乐观锁。给数据表加一个version字段,每次更新时带上当前的version,SQL里写死条件:
UPDATE products SET name = #{newName}, version = version + 1 WHERE id = #{id} AND version = #{oldVersion}如果影响行数是0,说明version已经变了,这个更新是旧操作,直接拒绝。这个机制可以保护“用户在不同页面并发修改同一条记录”的场景。
前端配合方式,是把version作为请求参数传给后端,后端比对。如果后端发现version过期,返回一个明确错误码,前端收到后提示用户“数据已被其他人修改,请刷新后重试”。
7.2 唯一请求ID:幂等键处理重复提交
还有另一个经典场景:用户快速点了两次“提交订单”。前端做了按钮禁用,但因为网络重试、网关重发,后端可能还是收到两次相同请求。这时候光靠version不够,因为两条请求都可能带着相同的新version(还没写库呢)。
对策是幂等键:客户端在发起写操作时,生成一个全局唯一的requestId,后端在第一次处理时记录redis缓存,第二次遇到相同requestId,直接返回第一次的处理结果,不重复执行。
// 前端生成 requestId const requestId = crypto.randomUUID() await fetch('/api/orders', { method: 'POST', headers: { 'X-Request-Id': requestId }, body: JSON.stringify(orderData), })后端伪代码如下:
def create_order(request): request_id = request.headers.get("X-Request-Id") if redis.exists(request_id): return cached_response(request_id) order = do_create_order(request) redis.set(request_id, order.id, ex=3600) return order幂等键的价值在于:即使前端怎么刷新、双击、重试,同一个业务动作只会被执行一次。它是写操作里的“终极大锁”。
7.3 前后端配合的完整请求协议
我参与的项目里,最终沉淀出一套相对完整的前后端配合协议。不需要全部照搬,但你可以在自己项目里按需裁剪:
- 每次前端请求带一个
clientReqId,作为唯一请求ID。 - 写操作额外带
version(或etag)字段,用于乐观锁。 - 前端维护一个单调递增的
requestSequence,用于渲染层防覆盖。 - 服务端对写操作强制校验requestId和version,对读操作不强制,由前端自己治理。
- 服务端响应头部返回
X-Relation-Id或X-Request-Id,方便全链路日志追踪。
这套协议的底层原则就一句话:能让前端兜底的,前端兜底;前端兜不住的,后端必须做幂等和版本校验。每一层都有守护,才不会因为某一层失误而出现数据错乱。
8. 验证你的修复是否有效:竞态测试方法论
修完bug,最怕的是“我觉得我修好了”。竞态问题有个特点:手动操作时很难稳定复现,因为受网络波动影响,时序差异是随机的。所以你需要一套系统的方法来验证。
8.1 模拟慢请求和乱序返回的调试技巧
最直接的手段,是人为延迟一些请求的响应,制造乱序条件。
- Chrome DevTools的Network面板,可以设置网络节流(Slow 3G),也可以右键一个请求选择“Block request URL”来模拟部分请求失败。
- 想要更精细控制,可以借助Charles或Fiddler这类代理工具,给特定接口添加断点或延时。
- 也可以用响应拦截的方式,在代码里人为给某个请求延迟1~2秒再resolve,先触发另一个请求,形成“后发先至”。
下面这个代码片段,可以在开发环境故意制造乱序:
// 开发环境:把第一个请求延迟2秒,模拟慢响应 const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) async function fakeFetchFirst() { await delay(2000) return { source: 'first' } } async function fakeFetchSecond() { await delay(500) return { source: 'second' } }如果你用这套逻辑替换真实接口,能非常稳定地复现“第二次请求先返回,第一次后返回”的乱序,验证你的序号判断或取消逻辑是否生效。
8.2 写一个自动化竞态测试用例
更正规一点,可以写自动化测试。核心思路是:控制接口返回顺序,断言最终渲染结果。
以Vitest + Vue Test Utils为例,可以mock请求返回顺序:
import { describe, it, expect, vi } from 'vitest' import { mount } from '@vue/test-utils' import SearchComponent from './SearchComponent.vue' vi.mock('./api', () => ({ search: vi.fn(), })) import { search } from './api' import SearchComponent from './SearchComponent.vue' describe('SearchComponent 竞态测试', () => { it('后发请求先返回时,界面应展示后发请求的数据', async () => { const wrapper = mount(SearchComponent) // 第一次调用,返回一个可控的 Promise let resolveFirst search.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve })) await wrapper.find('input').setValue('keyword1') // 第二次调用,立即返回 search.mockImplementationOnce(async () => ({ list: ['keyword2-result'] })) await wrapper.find('input').setValue('keyword2') await flushPromises() // 模拟第一次请求的慢响应终于回来了 resolveFirst({ list: ['keyword1-result'] }) await flushPromises() expect(wrapper.text()).toContain('keyword2-result') expect(wrapper.text()).not.toContain('keyword1-result') }) })这类测试跑通了,代码的防覆盖能力才算真正被验证过。手动测试只能凭感觉,自动化测试能保证以后每次回归都不会再踩这个坑。
8.3 常见的验证盲点:失败分支和边界情况
验证时很多人只测“成功返回”的竞态,忽略了失败分支。旧请求失败了,报错提示能不能被丢弃?新请求失败,错误提示会不会被旧请求的正常返回覆盖?
边界情况也要测:比如第一次请求被abort后,catch里还会不会setState?用户切走再切回来,id相同,序号是继续递增还是重置?这些分支如果不测,线上还是可能冒出奇怪的bug。
我自己踩过的一个坑是:处理AbortError时,axios的isCancel在浏览器环境没问题,但在某些低版本内核的浏览器或旧版axios里,判断的不是同一个对象,导致取消后代码还是走进了统一的错误处理函数,把错误提示弹出来了。后来我改成同时判断axios.isCancel(err)和err.code === 'ERR_CANCELED',才算稳定。
9. 从请求治理到系统设计的心得
写到这里,方案层面的东西基本都过了一遍。最后聊点实战心得。
我在实际项目里看到,很多团队解决这种问题的方式是“哪个页面出bug就改哪个页面”。这个思路看起来快,但往往按下葫芦浮起瓢。究其原因,是缺少一个统一的请求治理层。每个页面自己处理竞态,写出来的逻辑五花八门:有的用log做标志,有的用flag字段,有的干脆不处理。一旦团队扩大、业务复杂起来,这些重复的手写逻辑就是一颗颗定时炸弹。
我后来做重构时,会把“请求竞态处理”收敛到一个统一封装里。比如前端封装一个useRequest组合式函数,所有列表页都从它发起请求;后端定义一个统一响应协议,带上server_time、request_id、version这些字段。这种“治理下沉”带来的收益是长期的:新增页面只需要遵循规范,不需要重新理解竞态原理。
另外,竞态处理不是一次性的。我建议在代码评审清单里加一项:“这一步是否可能产生并行的、相互覆盖的请求?如果有,你打算怎么处理?”这是一个非常有效的检查点,能提前拦住很多隐蔽问题。
最后再说一个小技巧:给“覆盖”这一行为本身加日志。线上环境里,如果你在序号判断分支里加上console.warn或上报日志,说“丢弃了过期响应,请求序号xx小于当前序号xx”,后面排查问题时会有奇效。因为竞态问题最难的不是修复,而是确认它是否真的发生了。日志能帮你把这个过程变成可见的,而不是玄学。