API请求乱序?深入解析 JS 竞态问题

首先我们假设如下场景(见下图):

  • 用户点击 A 获取数据 a
  • 用户立刻继续点击 B 获取数据 b
  • 数据展示区域的变化: "内容为空" → "b" → "a"

你会发现点击 B 最终展示的数据居然还是 a

这就是 JS 中的竞态问题

接下来我会介绍什么是 JS 中的竞态,以及如何解决竞态问题。还会深入一些优秀开源项目的源码,来看看社区是怎么解决这个问题的

源代码在这里:github.com/wangkaiwd/r... 你可以自己尝试。建议结合代码一起阅读本文,能更容易理解相关内容>

什么是竞态

由于网络原因, 接口的响应顺序和用户请求的发起顺序不一致。如果第一次的请求响应晚于第二次的请求响应,那么界面最终依旧展示第一次的响应结果。

开篇中介绍到的示例,就是一个典型的"竞态"问题,用英文表示是 "race condition"。在 搜索、分页、 tab 切换 等场景经常会遇到这个问题

那么如何解决竞态问题呢?通常有以下几种思路:

  1. 在请求过程中禁止用户操作
  2. 抛弃之前的请求,只采用最后一次请求
  3. 在请求之前,先取消前一次请求

请求中禁止用户操作

这个解决方案是对界面的交互优化,比如说在点击搜索按钮后,我们在响应到来前给搜索按钮设置 loading 状态,这样用户只能在响应成功后再次点击搜索发起请求,从而避免竞态问题。

下图是一个在请求中给 搜索、分页 等操作添加 loading 的一个示例:procomponents.ant.design/en-US/compo...

但是如果想减少用户的等待时间,不允许显示 loading 状态,而是只展示最新的搜索结果,那么就要采用下边的两个方案来解决了

当然你也能用防抖函数来解决快速重复点击问题,但是这个思路不在本文的讨论范围内

抛弃之前的请求,只采用最后一次请求

实际上用户只需要最后一次请求的响应结果

我们可以定义一个变量 reqId 来存储请求的唯一 id

  • 每次请求时 reqId + 1
  • 在拿到响应时,判断当前请求的 id,是否是最后一次请求
  • 如果是最后一次请求,才会处理响应数据

代码如下:

tsx 复制代码
const [data, setData] = useState('')
// 👉 记录请求id
const reqIdRef = useRef(0)

const getData = async (type: OperationType) => {
  reqIdRef.current++
	// 👉 记录当前请求id
  const currentReqId = reqIdRef.current
  const newData = await service(type)
  // 👉 当前请求id是最后一次请求,才会处理响应
  if (currentReqId === reqIdRef.current) {
    setData(newData)
  }
}

完整代码在这里:github.com/wangkaiwd/r...

其实在 ant design 组件库文档中,就有介绍到处理竞态问题的 demo ,采用的也是类似的思路: ant.design/components/...

在请求之前,取消前一次请求

我们可以只取消 promise , 不处理请求结果。也可以取消 promise 并终止请求

取消前一次 promise

取消 promise 的逻辑其实很简单,只需要将 reject 函数保存到外部,在需要的时候手动调用即可:

  • 设置全局的 reject 来保存 promisereject
  • 用户自己执行 reject 来拒绝 promise
tsx 复制代码
let reject = null
return new Promise((resolve, _reject) => {
	reject = _reject
})
// 在合适的时机执行
reject('reason')

示例 demo : github.com/wangkaiwd/r...

取消前一次 promise 并且终止请求

大多数项目中都会使用 axios 来处理请求,以 axios 为例,取消请求的代码如下:

tsx 复制代码
const controller = new AbortController();

axios.get('/foo/bar', {
   signal: controller.signal
}).then(function(response) {
   // ...
});
// cancel the request
controller.abort()

文档在这里:axios-http.com/docs/cancel...

为了更好的理解 axios 如何取消请求,下面我们看一下它的工作原理

Axios 取消请求原理分析

首先看下 AbortController 是如何工作的

  • AbortController 实例中的 signal.aborted 用来表示请求是否被终止,初始值为 false
  • 执行 abort 方法后, signal.aborted 变为了 true
  • 通过 signal.addEventListener 监听 abort 事件,在执行 abort 方法时处理某些逻辑

在调用 axios 发起请求时,将 signal 传入了 axios 的配置中:

tsx 复制代码
axios.get('/foo/bar', { signal: controller.signal })

axiosxhr 发起请求的 Promise 中处理如下:

tsx 复制代码
// 👉 检查是否传入 signal
if (_config.cancelToken || _config.signal) {
    onCanceled = cancel => {
      if (!request) {
        return;
      }
      // 👉 取消 promise
      reject(!cancel || cancel.type ? new CanceledError(null, config, request) : cancel);
      // 👉 终止请求
      request.abort();
      request = null;
    };

    _config.cancelToken && _config.cancelToken.subscribe(onCanceled);
    if (_config.signal) {
     // 👉 如果aborted是true,执行 onCanceled 取消请求
     // 👉 如果aborted是false, 监听 abort 事件
      _config.signal.aborted ? onCanceled() : _config.signal.addEventListener('abort', onCanceled);
    }
}

源码:github.com/axios/axios...

在使用过程中需要注意:当一个请求被取消后,再次执行该请求,由于 signal.aborttrue ,会立即取消。要想再次发起请求,需要创建一个新的 AbortController 实例

tsx 复制代码
const controller = new AbortController()
axios.get('/foo/bar', { signal: controller.signal })
// 终止请求
controller.abort()

// ❌ 由于 signal.aborted 是 true,所以该请求会立即取消
axios.get('/foo/bar', { signal: controller.signal })

// ✅ 创建新的 AbortController 实例才能继续发起请求
const controller = new AbortController()
axios.get('/foo/bar', { signal: controller.signal })

在 React 中使用 axios 取消请求

在了解了 axios 取消请求的用法与大概原理后,我们将取消请求逻辑集成到 react

tsx 复制代码
// ✅ 全局存储 controller
const controllerRef = useRef<AbortController | null>(null)
const getData = async (tab: string) => {
	// ✅ 取消前一次请求
  const controller = controllerRef.current
  if (controller) {
    controller.abort('cancel previous request')
  }
  // ✅ 新请求要重新创建一个 AbortController 实例
  controllerRef.current = new AbortController()
  try {
    const res = await fetchTopics({
      params: { tab, limit: 2 },
      signal: controllerRef.current.signal,
    })
    setData(res.data)
  } catch (err: any) {
	  // ✅ 处理请求取消的业务逻辑
    if (axios.isCancel(err)) {
      console.log('cancel request', err)
      return
    }
    console.log('error', err)
  }
}

完整代码在这里:github.com/wangkaiwd/r...

在实际工作中,我们可以使用 ahooks 中的 useRequest 来处理请求:

tsx 复制代码
// ✅ useRequest 帮我们处理了竞态问题
const { runAsync } = useRequest(service)
const [content, setContent] = useState<null | string>(null)
const onClick = async (type: OperationType) => {
  const res = await runAsync(type)
  setContent(res)
}

useRequest 内部帮我们处理了竞态问题,它采用的思路是丢弃之前的请求,只处理最后一次请求的响应:github.com/alibaba/hoo...

结语

文章开头通过一个示例来引出 JS 的竞态问题,之后介绍了什么是竞态问题,以及解决竞态问题的三个方法:

  • 请求中禁止用户操作
  • 抛弃之前的请求,只采用最后一次请求
  • 在请求之前,取消前一次请求

然后我们演示了如何在 React 中结合 axios 以及 useRquest 自定义 hooks 来解决竞态问题

最后希望本文能在你遇到竞态问题时,提供更多的解决思路

参考

相关推荐
布兰妮甜9 分钟前
CSS Houdini 与 React 19 调度器:打造极致流畅的网页体验
前端·css·react.js·houdini
小小愿望21 分钟前
ECharts 实战技巧:揭秘 X 轴末项标签 “莫名加粗” 之谜及破解之道
前端·echarts
小小愿望29 分钟前
移动端浏览器中设置 100vh 却出现滚动条?
前端·javascript·css
fail_to_code30 分钟前
请不要再只会回答宏任务和微任务了
前端
摸着石头过河的石头30 分钟前
taro3.x-4.x路由拦截如何破?
前端·taro
lpfasd12339 分钟前
开发Chrome/Edge插件基本流程
前端·chrome·edge
练习前端两年半1 小时前
🚀 Vue3 源码深度解析:Diff算法的五步优化策略与最长递增子序列的巧妙应用
前端·vue.js
烛阴1 小时前
TypeScript 接口入门:定义代码的契约与形态
前端·javascript·typescript
掘金安东尼2 小时前
使用自定义高亮API增强用户‘/’体验
前端·javascript·github
参宿72 小时前
electron之win/mac通知免打扰
java·前端·electron