前端请求竞态怎么处理?别让旧接口覆盖新数据

前端页面里有一类问题很隐蔽:接口没有报错,数据也正常返回,但页面显示却不对。

比如:

复制代码
用户快速切换筛选条件,页面显示了上一次的结果
搜索框连续输入,旧关键词的结果覆盖了新关键词
切换语言后,字幕列表又变回了旧语言
切换 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 校验

例如返回结果中带上 sessionIdversion

复制代码
{
  "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。