前端页面里有一类问题很隐蔽:接口没有报错,数据也正常返回,但页面显示却不对。
比如:
用户快速切换筛选条件,页面显示了上一次的结果
搜索框连续输入,旧关键词的结果覆盖了新关键词
切换语言后,字幕列表又变回了旧语言
切换 Tab 后,已经离开的页面仍然更新了状态
这类问题通常不是后端返回错了,而是前端出现了"请求竞态"。
一、什么是请求竞态?
请求竞态指的是:多个异步请求同时存在,最终返回顺序和发起顺序不一致,导致旧请求覆盖新请求。
例如搜索框中,用户依次输入:
a
ap
app
apple
前端连续发起 4 个请求:
请求 1:keyword=a
请求 2:keyword=ap
请求 3:keyword=app
请求 4:keyword=apple
理想情况下,请求 4 最后返回,页面显示 apple 的搜索结果。
但真实网络中,返回顺序可能是:
请求 2 返回
请求 4 返回
请求 3 返回
请求 1 返回
如果每个请求返回后都直接更新页面,最终页面可能显示的是 a 的结果。
这就是典型的旧请求覆盖新数据。
二、常见触发场景
请求竞态经常出现在这些地方:
搜索框实时查询
筛选条件快速切换
分页快速点击
Tab 页面切换
路由跳转后接口仍然返回
语言、地区、模型等配置切换
自动刷新和手动刷新同时存在
如果页面只发起一次请求,通常不容易暴露。
只要用户操作速度变快、网络变慢、接口响应时间不稳定,问题就会出现。
三、方案一:使用请求序号,只接受最后一次结果
最简单的思路是给每次请求分配一个递增序号。
只有最新序号的请求,才允许更新页面。
let requestId = 0;
async function search(keyword) {
const currentId = ++requestId;
const result = await fetch(
`/api/search?keyword=${keyword}`
).then(res => res.json());
if (currentId !== requestId) {
return;
}
renderResult(result);
}
当用户连续搜索时,后发起的请求会更新全局 requestId。
旧请求即使后来返回,也会因为 ID 不匹配而被忽略。
这种方案适合:
搜索结果
筛选列表
详情页切换
前端状态覆盖风险较高的接口
优点是实现简单,不依赖浏览器取消请求能力。
缺点是旧请求仍然会继续执行,只是不再更新页面。
四、方案二:使用 AbortController 取消旧请求
如果希望新请求发起时直接取消旧请求,可以使用 AbortController。
let controller = null;
async function search(keyword) {
if (controller) {
controller.abort();
}
controller = new AbortController();
try {
const response = await fetch(
`/api/search?keyword=${keyword}`,
{
signal: controller.signal
}
);
const result = await response.json();
renderResult(result);
} catch (error) {
if (error.name === 'AbortError') {
return;
}
throw error;
}
}
当下一次搜索开始时,旧请求会被取消。
需要注意:
AbortController 取消的是前端等待过程
后端是否停止执行,取决于后端实现
也就是说,前端可以不再接收旧响应,但后端任务不一定真的被中止。
五、React 中如何处理?
React 里常见问题是组件已经卸载,但请求返回后仍然调用 setState。
可以在 useEffect 中清理请求。
import { useEffect, useState } from 'react';
function UserDetail({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
try {
const response = await fetch(
`/api/users/${userId}`,
{
signal: controller.signal
}
);
const data = await response.json();
setUser(data);
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
}
loadUser();
return () => {
controller.abort();
};
}, [userId]);
return <div>{user?.name}</div>;
}
当 userId 变化或组件卸载时,旧请求会被取消。
这样可以避免旧详情数据覆盖新详情数据。
六、Vue 中如何处理?
Vue 中也可以在参数变化时取消旧请求。
import { ref, watch } from 'vue';
const keyword = ref('');
const list = ref([]);
let controller = null;
watch(keyword, async value => {
if (controller) {
controller.abort();
}
controller = new AbortController();
try {
const response = await fetch(
`/api/search?keyword=${value}`,
{
signal: controller.signal
}
);
list.value = await response.json();
} catch (error) {
if (error.name !== 'AbortError') {
console.error(error);
}
}
});
如果是页面卸载,还可以在 onUnmounted 中取消请求:
onUnmounted(() => {
if (controller) {
controller.abort();
}
});
七、搜索框还要加防抖
取消请求可以解决旧数据覆盖问题,但如果用户每输入一个字符都请求一次,接口压力仍然很大。
搜索框更适合加防抖。
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
使用:
const handleSearch = debounce(keyword => {
search(keyword);
}, 300);
防抖解决的是"减少请求数量"。
请求取消和请求序号解决的是"避免旧结果覆盖"。
两者可以一起使用。
八、不要忽略 loading 状态
请求竞态还可能影响 loading。
例如旧请求返回后直接设置:
loading = false;
但此时新请求可能还在进行中。
更安全的方式是结合请求 ID:
let requestId = 0;
let loading = false;
async function loadData(params) {
const currentId = ++requestId;
loading = true;
try {
const data = await fetchData(params);
if (currentId === requestId) {
render(data);
}
} finally {
if (currentId === requestId) {
loading = false;
}
}
}
这样只有最后一次请求可以关闭 loading。
九、实时字幕场景中的请求竞态
在实时字幕、会议翻译这类产品中,请求竞态也很常见。
例如**同言翻译(Transync AI)**这类实时翻译工具,用户可能在会议中切换语言方向、字幕显示方式或语音播报设置。
如果旧配置请求比新配置请求更晚返回,就可能出现:
界面显示已切换为中文
实际字幕仍按英文方向刷新
旧字幕片段覆盖新字幕片段
旧语音播报设置重新生效
这类场景一般需要同时处理:
请求取消
请求序号
配置版本号
会话 ID 校验
例如返回结果中带上 sessionId 和 version:
{
"sessionId": "meeting_10001",
"version": 12,
"languagePair": "en-zh"
}
前端收到后先判断是否仍然属于当前会话,再决定是否更新页面。
十、检查清单
排查请求竞态时,可以检查:
1. 是否存在连续触发同一接口的场景
2. 旧请求返回后是否会更新新页面
3. 搜索框是否做了防抖
4. 参数变化时是否取消旧请求
5. 是否只允许最后一次请求更新状态
6. loading 是否可能被旧请求关闭
7. 组件卸载后是否还会 setState
8. 返回数据是否带 sessionId 或 version
总结
前端请求竞态的本质是:
用户操作顺序、请求发起顺序、接口返回顺序,并不总是一致。
常见解决方式包括:
请求序号
AbortController
搜索防抖
组件卸载清理
loading 状态校验
sessionId 或 version 判断
如果只是普通搜索列表,使用"防抖 + 请求序号"通常已经够用。
如果是切换页面、切换配置或实时数据场景,则建议同时使用请求取消和版本判断。
不要默认最后返回的接口就是最新数据。
前端真正要保证的是:只有当前页面、当前参数、当前会话对应的请求结果,才有资格更新 UI。

