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

55 阅读5分钟

业务场景

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

全部 / 待付款 / 待发货 / 已完成

用户每切换一次状态,前端就会请求一次订单列表:

axios.get("/api/orders", {
  params: { status: "pendingPay" }
});

如果用户快速切换状态,就可能同时存在多个还没完成的请求:

点击 全部       -> 请求 A
点击 待付款     -> 请求 B
点击 待发货     -> 请求 C

请求 B 先返回   -> 页面显示待付款订单
请求 C 后返回   -> 页面显示待发货订单
请求 A 最后返回 -> 页面又被覆盖成全部订单

最终页面就会出现错误:导航高亮在“待发货”,列表内容却是“全部订单”。

这就是订单状态导航里的数据竞态问题。

竞态的本质

JS 是单线程的,但接口请求不是一个一个等着返回的。前端发起多个请求后,请求会交给浏览器或运行环境处理,哪个请求先完成,哪个请求的回调就先进入任务队列。

所以竞态的本质不是“多个 JS 线程同时修改数据”,而是:

多个异步结果都会更新同一份状态,但它们的完成顺序不可控,旧结果可能后返回并覆盖新结果。

在订单列表里,这份被竞争更新的状态通常是:

const state = {
  activeStatus: "pendingShip",
  orders: [],
  loading: false,
  error: null
};

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

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

错误写法

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

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

问题在于:只要请求返回,就直接更新列表,没有判断这次返回的数据是否还属于当前状态。

比如用户先点“全部”,马上又点“待发货”。如果“全部”的请求最后返回,它仍然会执行:

setOrders(response.data);

这样就会把“待发货”的列表覆盖成“全部订单”。

Axios 推荐处理方式

Axios 现在推荐使用 AbortControllersignal 取消请求。根据 Axios 官方文档,Axios 从 v0.22.0 开始支持 AbortController,旧的 CancelToken 已经不建议在新项目里使用。

完整写法如下:

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. 保存当前请求控制器

let controller = null;

controller 用来保存上一次请求的 AbortController

每次切换订单状态时,先取消上一次请求:

controller?.abort();

然后为这一次新请求创建新的控制器:

controller = new AbortController();

再把它的 signal 传给 Axios:

axios.get("/api/orders", {
  params: { status },
  signal: controller.signal
});

这样当下一次状态切换发生时,就可以取消当前这次请求。

2. 给每次请求生成编号

let requestSeq = 0;
let latestRequestSeq = 0;

requestSeq 是全局递增的请求编号。

latestRequestSeq 记录当前最新请求的编号。

每次调用 loadOrders 时:

const requestId = ++requestSeq;
latestRequestSeq = requestId;

等价于:

requestSeq = requestSeq + 1;
const requestId = requestSeq;
latestRequestSeq = requestId;

假设用户快速点击三次:

第 1 次:全部   requestId = 1,latestRequestSeq = 1
第 2 次:待付款 requestId = 2,latestRequestSeq = 2
第 3 次:待发货 requestId = 3,latestRequestSeq = 3

如果第 1 次请求最后才返回,它自己的 requestId 还是 1,但最新请求已经是 3

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

判断不通过,说明这是旧请求,不能更新订单列表。

3. 只让最新请求更新列表

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

setOrders(response.data);

这段是防止竞态的核心。

它保证了:

旧请求返回 -> 直接 return
最新请求返回 -> setOrders

所以即使网络请求乱序返回,页面最终也只会展示当前订单状态对应的数据。

4. catch 里也要判断

catch 不只是接口 500 才会进入。下面这些情况都会进入 catch

  • 请求被 controller.abort() 取消。
  • 用户断网或网络异常。
  • 请求超时。
  • HTTP 状态码不是 2xx,例如 401、403、404、500。
  • Axios 拦截器主动 Promise.reject

取消请求不是业务错误,所以直接忽略:

if (axios.isCancel(error) || error.name === "CanceledError") {
  return;
}

真正的错误也要判断是不是最新请求:

if (requestId === latestRequestSeq) {
  setError(error);
}

否则旧请求失败了,可能会把当前页面误改成错误状态。

5. finally 里也要判断

很多人只保护 setOrders,但忘了保护 setLoading(false)

错误写法:

finally {
  setLoading(false);
}

如果旧请求先失败或先结束,它会提前把 loading 关掉。此时最新请求可能还在加载中,页面状态就不准确。

所以应该写成:

finally {
  if (requestId === latestRequestSeq) {
    setLoading(false);
  }
}

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

为什么只用 AbortController 还不够

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

controller?.abort();

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

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

所以更稳的策略是:

能取消的,先取消。
取消不了的,返回后也不能更新 UI。

这就是 AbortController + requestId 组合使用的意义。

CancelToken 还要用吗

旧项目里可能会看到 Axios 的 CancelToken

const source = axios.CancelToken.source();

axios.get("/api/orders", {
  cancelToken: source.token
});

source.cancel();

这个写法现在不建议新项目优先使用。新项目更推荐:

const controller = new AbortController();

axios.get("/api/orders", {
  signal: controller.signal
});

controller.abort();

如果是维护老项目,可以先看项目里的 Axios 版本和封装方式。如果已经支持 signal,可以逐步迁移到 AbortController

小结

使用 Axios 时,推荐方案是:

AbortController 取消旧请求
+ requestId 判断最新请求
+ loading/error/orders 都做保护

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