Codex写异步请求为什么容易出现状态覆盖?用请求隔离解决竞态条件
使用 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异常和重复保存等问题。
更多推荐



所有评论(0)