前端如何处理订单状态导航的数据竞态问题

业务场景

订单列表页面通常会有状态导航:

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
};

要解决这个问题,目标很明确:

只有当前选中的订单状态对应的最新请求,才允许更新 ordersloadingerror

错误写法

下面这种写法很常见,但它有竞态风险:

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 现在推荐使用 AbortControllersignal 取消请求。根据 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);
    }
  }
}

这段代码用了两层保护:

  1. AbortController:切换状态时取消上一次 Axios 请求。
  2. 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);
  }
}

也就是说,orderserrorloading 都只能由最新请求更新。

为什么只用 AbortController 还不够

理论上,切换状态时取消上一个请求已经能解决大部分问题:

js 复制代码
controller?.abort();

但实际项目里仍然建议保留 requestId 判断,原因有三点:

  1. 不是所有异步任务都能取消。
  2. 某些请求封装、缓存层、拦截器逻辑可能仍然会返回结果。
  3. 后续代码可能不只请求接口,还会包含异步格式化、延迟处理、数据合并等逻辑。

所以更稳的策略是:

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 都做保护

这样才能保证:用户当前选中什么状态,页面最终就展示什么状态的订单数据。

相关推荐
开发者每周简报1 小时前
网海三部曲·无名宗师传
javascript·人工智能
喵个咪2 小时前
GoWind Toolkit 前端代码生成|Vue3(ElementPlus/Vben)、React(AntDesign)全自动一键生成教程
前端·vue.js·react.js
摆烂大大王3 小时前
玩转 OpenClaw:用 TaskFlow + Heartbeat 打造自动化工作流
前端·人工智能·自动化
zhangxingchao3 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
梦想的颜色3 小时前
TypeScript 完全指南(上):从零开始掌握类型系统
前端·typescript
之歆3 小时前
Day01_ES6+ 专业指南:从基础到实战的现代JavaScript开发(下)
前端·javascript·es6
lichenyang4534 小时前
鸿蒙 MVVM 实战:从 Demo 到工程化,聊聊登录、状态管理与埋点系统设计
前端
IT_陈寒4 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端
kyriewen4 小时前
AI生成代码快如闪电,但我修了三个小时——它到底帮了谁?
前端·javascript·ai编程