业务场景
订单列表页面通常会有状态导航:
text
全部 / 待付款 / 待发货 / 已完成
用户每切换一次状态,前端就会请求一次订单列表:
js
axios.get("/api/orders", {
params: { status: "pendingPay" }
});
如果用户快速切换状态,就可能同时存在多个还没完成的请求:
text
点击 全部 -> 请求 A
点击 待付款 -> 请求 B
点击 待发货 -> 请求 C
请求 B 先返回 -> 页面显示待付款订单
请求 C 后返回 -> 页面显示待发货订单
请求 A 最后返回 -> 页面又被覆盖成全部订单
最终页面就会出现错误:导航高亮在"待发货",列表内容却是"全部订单"。
这就是订单状态导航里的数据竞态问题。
竞态的本质
JS 是单线程的,但接口请求不是一个一个等着返回的。前端发起多个请求后,请求会交给浏览器或运行环境处理,哪个请求先完成,哪个请求的回调就先进入任务队列。
所以竞态的本质不是"多个 JS 线程同时修改数据",而是:
多个异步结果都会更新同一份状态,但它们的完成顺序不可控,旧结果可能后返回并覆盖新结果。
在订单列表里,这份被竞争更新的状态通常是:
js
const state = {
activeStatus: "pendingShip",
orders: [],
loading: false,
error: null
};
要解决这个问题,目标很明确:
只有当前选中的订单状态对应的最新请求,才允许更新
orders、loading和error。
错误写法
下面这种写法很常见,但它有竞态风险:
js
async function loadOrders(status) {
setActiveStatus(status);
setLoading(true);
setError(null);
try {
const response = await axios.get("/api/orders", {
params: { status }
});
setOrders(response.data);
} catch (error) {
setError(error);
} finally {
setLoading(false);
}
}
问题在于:只要请求返回,就直接更新列表,没有判断这次返回的数据是否还属于当前状态。
比如用户先点"全部",马上又点"待发货"。如果"全部"的请求最后返回,它仍然会执行:
js
setOrders(response.data);
这样就会把"待发货"的列表覆盖成"全部订单"。
Axios 推荐处理方式
Axios 现在推荐使用 AbortController 的 signal 取消请求。根据 Axios 官方文档,Axios 从 v0.22.0 开始支持 AbortController,旧的 CancelToken 已经不建议在新项目里使用。
完整写法如下:
js
let controller = null;
let requestSeq = 0;
let latestRequestSeq = 0;
async function loadOrders(status) {
const requestId = ++requestSeq;
latestRequestSeq = requestId;
controller?.abort();
controller = new AbortController();
setActiveStatus(status);
setLoading(true);
setError(null);
try {
const response = await axios.get("/api/orders", {
params: { status },
signal: controller.signal
});
if (requestId !== latestRequestSeq) {
return;
}
setOrders(response.data);
} catch (error) {
if (axios.isCancel(error) || error.name === "CanceledError") {
return;
}
if (requestId === latestRequestSeq) {
setError(error);
}
} finally {
if (requestId === latestRequestSeq) {
setLoading(false);
}
}
}
这段代码用了两层保护:
AbortController:切换状态时取消上一次 Axios 请求。requestId:即使旧请求没有被真正取消,返回后也不能更新页面。
代码逐段解释
1. 保存当前请求控制器
js
let controller = null;
controller 用来保存上一次请求的 AbortController。
每次切换订单状态时,先取消上一次请求:
js
controller?.abort();
然后为这一次新请求创建新的控制器:
js
controller = new AbortController();
再把它的 signal 传给 Axios:
js
axios.get("/api/orders", {
params: { status },
signal: controller.signal
});
这样当下一次状态切换发生时,就可以取消当前这次请求。
2. 给每次请求生成编号
js
let requestSeq = 0;
let latestRequestSeq = 0;
requestSeq 是全局递增的请求编号。
latestRequestSeq 记录当前最新请求的编号。
每次调用 loadOrders 时:
js
const requestId = ++requestSeq;
latestRequestSeq = requestId;
等价于:
js
requestSeq = requestSeq + 1;
const requestId = requestSeq;
latestRequestSeq = requestId;
假设用户快速点击三次:
text
第 1 次:全部 requestId = 1,latestRequestSeq = 1
第 2 次:待付款 requestId = 2,latestRequestSeq = 2
第 3 次:待发货 requestId = 3,latestRequestSeq = 3
如果第 1 次请求最后才返回,它自己的 requestId 还是 1,但最新请求已经是 3:
js
if (requestId !== latestRequestSeq) {
return;
}
判断不通过,说明这是旧请求,不能更新订单列表。
3. 只让最新请求更新列表
js
if (requestId !== latestRequestSeq) {
return;
}
setOrders(response.data);
这段是防止竞态的核心。
它保证了:
text
旧请求返回 -> 直接 return
最新请求返回 -> setOrders
所以即使网络请求乱序返回,页面最终也只会展示当前订单状态对应的数据。
4. catch 里也要判断
catch 不只是接口 500 才会进入。下面这些情况都会进入 catch:
- 请求被
controller.abort()取消。 - 用户断网或网络异常。
- 请求超时。
- HTTP 状态码不是 2xx,例如 401、403、404、500。
- Axios 拦截器主动
Promise.reject。
取消请求不是业务错误,所以直接忽略:
js
if (axios.isCancel(error) || error.name === "CanceledError") {
return;
}
真正的错误也要判断是不是最新请求:
js
if (requestId === latestRequestSeq) {
setError(error);
}
否则旧请求失败了,可能会把当前页面误改成错误状态。
5. finally 里也要判断
很多人只保护 setOrders,但忘了保护 setLoading(false)。
错误写法:
js
finally {
setLoading(false);
}
如果旧请求先失败或先结束,它会提前把 loading 关掉。此时最新请求可能还在加载中,页面状态就不准确。
所以应该写成:
js
finally {
if (requestId === latestRequestSeq) {
setLoading(false);
}
}
也就是说,orders、error、loading 都只能由最新请求更新。
为什么只用 AbortController 还不够
理论上,切换状态时取消上一个请求已经能解决大部分问题:
js
controller?.abort();
但实际项目里仍然建议保留 requestId 判断,原因有三点:
- 不是所有异步任务都能取消。
- 某些请求封装、缓存层、拦截器逻辑可能仍然会返回结果。
- 后续代码可能不只请求接口,还会包含异步格式化、延迟处理、数据合并等逻辑。
所以更稳的策略是:
text
能取消的,先取消。
取消不了的,返回后也不能更新 UI。
这就是 AbortController + requestId 组合使用的意义。
CancelToken 还要用吗
旧项目里可能会看到 Axios 的 CancelToken:
js
const source = axios.CancelToken.source();
axios.get("/api/orders", {
cancelToken: source.token
});
source.cancel();
这个写法现在不建议新项目优先使用。新项目更推荐:
js
const controller = new AbortController();
axios.get("/api/orders", {
signal: controller.signal
});
controller.abort();
如果是维护老项目,可以先看项目里的 Axios 版本和封装方式。如果已经支持 signal,可以逐步迁移到 AbortController。
小结
使用 Axios 时,推荐方案是:
text
AbortController 取消旧请求
+ requestId 判断最新请求
+ loading/error/orders 都做保护
这样才能保证:用户当前选中什么状态,页面最终就展示什么状态的订单数据。