使用 Codex 修改前端项目时,有一类 Bug 很难在第一次测试中发现:

页面功能看起来正常,接口也没有报错,但用户连续操作几次后,界面却显示了错误的数据。

常见表现包括:

  • 用户快速切换两个账号,最后显示的却是第一个账号资料;

  • 搜索框连续输入,旧关键词结果覆盖新关键词;

  • 切换页面后,之前的请求返回并修改当前页面状态;

  • 点击两次保存,较早的请求最后返回,把最新状态覆盖掉;

  • Loading 状态提前结束;

  • 网络越慢,问题越容易出现。

这类问题通常不是接口错误,而是典型的 Race Condition(竞态条件)


一、为什么异步请求会发生状态覆盖?

看一个很常见的代码:

async function loadUser(userId: string) {
  setLoading(true);

  const user = await api.getUser(userId);

  setUser(user);
  setLoading(false);
}

用户先点击用户A:

loadUser("A")

随后马上点击用户B:

loadUser("B")

理论上界面应该显示B。

但网络请求返回顺序不一定与发送顺序相同。

可能发生:

请求A发送
↓
请求B发送
↓
请求B返回
↓
界面显示B
↓
请求A较晚返回
↓
界面又被覆盖成A

代码每一行都没有语法错误,但最终状态已经错了。

这就是典型的竞态问题。


二、为什么Codex容易生成这种代码?

因为从单次调用来看:

const result = await request();
setState(result);

完全合理。

Codex 如果只看到:

根据userId获取用户信息并更新页面。

它通常会优先完成正常流程。

但真实用户可能:

  • 快速切换;

  • 重复点击;

  • 返回上一页;

  • 网络卡顿;

  • 同时触发多个请求。

所以对于异步逻辑,不能只问:

请求成功以后做什么?

还要问:

请求回来时,这个结果还有效吗?


三、最简单的方法:使用请求序号

可以给每一次请求增加编号:

let latestRequestId = 0;

async function loadUser(userId: string) {
  const requestId = ++latestRequestId;

  setLoading(true);

  const user = await api.getUser(userId);

  if (requestId !== latestRequestId) {
    return;
  }

  setUser(user);
  setLoading(false);
}

执行过程变成:

请求A:requestId = 1

请求B:requestId = 2

如果A最后才返回:

1 !== 2

结果直接丢弃。

这样可以确保:

只有最后一次请求可以修改当前页面状态。

这种方式特别适合:

  • 搜索建议;

  • 用户详情切换;

  • Tab切换;

  • 筛选条件变化;

  • 分页查询。


四、能取消就直接取消旧请求

如果请求支持取消,更推荐在新任务开始时终止旧请求。

浏览器中可以使用 AbortController

let controller: AbortController | null = null;

async function loadUser(userId: string) {
  controller?.abort();

  controller = new AbortController();

  try {
    const user = await fetch(
      `/api/users/${userId}`,
      {
        signal: controller.signal
      }
    ).then(res => res.json());

    setUser(user);
  } catch (error) {
    if (
      error instanceof DOMException &&
      error.name === "AbortError"
    ) {
      return;
    }

    throw error;
  }
}

每次新请求开始:

取消旧请求
→ 创建新请求
→ 只等待最新结果

好处不仅是避免状态覆盖,还能减少无意义的网络请求。


五、组件销毁后不要继续更新状态

另一个常见问题是:

打开页面
→ 请求开始
→ 用户离开页面
→ 请求返回
→ 继续修改已经失效的页面状态

React 中可以配合清理函数:

useEffect(() => {
  const controller = new AbortController();

  loadData(controller.signal);

  return () => {
    controller.abort();
  };
}, []);

Vue 中也可以在组件卸载时终止未完成请求。

原则是:

页面生命周期结束,对应异步任务也应该失效。

不要让已经离开的页面继续影响当前状态。


六、Loading状态也会发生竞态

很多代码会写:

setLoading(true);

await request();

setLoading(false);

如果同时存在两个请求:

请求A开始
请求B开始
请求A先结束

A执行:

setLoading(false);

但此时B还没完成。

用户就会看到 Loading 消失,但后台仍在加载。

更稳妥的方法可以记录请求数量:

let pendingCount = 0;

async function runRequest() {
  pendingCount++;
  setLoading(true);

  try {
    await request();
  } finally {
    pendingCount--;

    if (pendingCount === 0) {
      setLoading(false);
    }
  }
}

或者直接让每个独立模块管理自己的请求状态。

不要让多个不相关请求共享一个简单的:

loading: boolean

七、搜索框是最容易出现竞态的地方

例如用户输入:

c
co
cod
code
codex

如果每次输入都请求:

watch(keyword, async value => {
  results.value =
    await search(value);
});

网络返回顺序可能是:

codex
code
cod

最后页面显示的反而是 cod 的结果。

这种场景通常应该同时使用:

防抖

减少请求数量:

300ms内连续输入
→ 不请求

停止输入300ms
→ 再请求

请求隔离

即使旧请求已经发出,也不能覆盖新结果。

防抖解决“请求太多”,请求隔离解决“结果顺序错误”。

两者不是一回事。


八、不要使用全局变量保存所有异步状态

Codex 有时为了快速解决问题,会把请求状态放到全局:

let currentUserRequest;
let loading;
let currentResult;

小项目可能暂时可用,但随着页面增加,很容易互相干扰。

更合理的方式是让状态与任务作用域绑定:

用户模块
→ 用户请求状态

订单模块
→ 订单请求状态

搜索模块
→ 搜索请求状态

如果使用 React Query、TanStack Query、SWR 等数据请求工具,也应该利用其请求键和缓存机制,而不是继续手动维护一套全局异步状态。


九、保存操作不能简单使用“最后返回覆盖”

读取数据可以丢弃旧结果,但写操作需要更加谨慎。

例如用户快速点击两次保存:

保存A:name = Tom
保存B:name = Jerry

如果服务器最终处理顺序变成:

B
→ A

数据库最后可能保存成Tom。

这时前端取消请求并不能保证服务器停止执行。

写操作通常需要结合:

  • 防止重复提交;

  • 请求幂等;

  • 版本号;

  • 乐观锁;

  • 服务端状态校验。

例如:

version = 5

更新时要求:

UPDATE users
SET name = ?, version = 6
WHERE id = ?
AND version = 5;

如果另一请求已经修改版本号,当前更新就会失败,而不是静默覆盖新数据。


十、用状态机管理复杂异步流程

如果流程已经包含:

idle
loading
success
error
retrying
cancelled

继续堆叠:

isLoading
isError
isRetrying
isCancelled

很容易出现互相矛盾的状态。

例如:

isLoading = true
isError = true

此时页面到底应该显示加载中还是错误?

可以明确使用状态:

type RequestStatus =
  | "idle"
  | "loading"
  | "success"
  | "error"
  | "cancelled";

然后:

state.status = "loading";

失败后:

state.status = "error";

取消后:

state.status = "cancelled";

复杂异步业务中,明确状态转换比多个布尔值更加稳定。


十一、让Codex先分析“谁可以更新状态”

开始处理异步Bug时,可以先这样问:

请先不要修改代码。

分析当前异步流程:

1. 哪些函数会发起请求;
2. 哪些请求可能同时执行;
3. 哪些请求会更新同一个状态;
4. 请求返回顺序是否可能变化;
5. 旧结果是否可能覆盖新状态;
6. 组件卸载后是否仍会更新状态。

先把状态写入路径找出来,再决定使用:

AbortController
请求序号
防抖
状态机
版本号

不要一上来就大范围重构。


十二、测试竞态不能只测试单次请求

普通测试可能只是:

请求成功
→ 页面显示数据

还应该加入:

请求A开始
请求B开始
请求B先返回
请求A后返回

最终必须验证:

页面仍然显示B

还可以测试:

  • 请求过程中切换页面;

  • 连续点击两次;

  • 旧请求被取消;

  • 新请求失败;

  • 组件卸载;

  • 请求超时;

  • Loading状态是否正确。

竞态问题如果没有模拟异常顺序,很难真正覆盖。


十三、把异步规则写进AGENTS.md

可以加入:

# 异步请求规则

- 新请求必须评估旧请求是否仍然有效
- 搜索和切换类请求必须防止旧结果覆盖
- 支持取消的请求优先使用AbortController
- 组件卸载后禁止继续更新页面状态
- 多请求不得共用不准确的loading布尔值
- 写操作必须评估重复提交与版本冲突
- 复杂异步流程优先使用明确状态机
- 修复竞态问题后必须增加乱序返回测试

这样 Codex 后续生成异步代码时,会更容易主动考虑状态生命周期。


十四、一个推荐的异步任务检查流程

可以固定为:

找到状态
↓
找到所有写入点
↓
找到所有异步请求
↓
判断是否可能并行
↓
模拟乱序返回
↓
选择取消 / 序号 / 版本控制
↓
增加测试
↓
再提交代码

真正需要关注的不是:

Promise 有没有 await。

而是:

await 结束以后,这个结果还应该不应该生效?


十五、Plus还是Pro?

如果主要处理:

  • 单页面请求;

  • 搜索框;

  • 用户详情切换;

  • 少量异步Bug;

  • 小型前端项目;

Plus通常可以覆盖多数Codex任务。

如果长期维护大型前端、复杂状态管理、多请求并发、跨模块异步流程,并且需要持续分析大量代码和测试,则可以根据实际开发强度评估Pro。

不过版本并不能自动消除竞态条件。

异步状态是否正确,最终仍取决于任务边界和状态设计。

总结

Codex写出的异步请求出现状态覆盖,通常不是 async/await 写错,而是代码默认认为:

请求返回顺序 = 请求发送顺序。

真实网络并不保证这一点。

通过请求序号、AbortController、防抖、状态机和服务端版本控制,可以让旧任务失效,让最新操作拥有明确的状态所有权。

真正稳定的异步代码,需要回答一个关键问题:

当这个请求终于返回时,它还有资格修改当前状态吗?

CSDN文章描述

本文介绍Codex编写前端异步请求时常见的竞态条件,通过AbortController、请求序号、防抖、状态机和版本控制,解决旧请求覆盖新状态、Loading异常和重复保存等问题。

更多推荐