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

- 用户点击
A获取数据a - 用户立刻继续点击
B获取数据b - 数据展示区域的变化: "内容为空" → "b" → "a"
你会发现点击 B 最终展示的数据居然还是 a ?
这就是 JS 中的竞态问题
接下来我会介绍什么是 JS 中的竞态,以及如何解决竞态问题。还会深入一些优秀开源项目的源码,来看看社区是怎么解决这个问题的
源代码在这里:github.com/wangkaiwd/r... 你可以自己尝试。建议结合代码一起阅读本文,能更容易理解相关内容>
什么是竞态
由于网络原因, 接口的响应顺序和用户请求的发起顺序不一致。如果第一次的请求响应晚于第二次的请求响应,那么界面最终依旧展示第一次的响应结果。
开篇中介绍到的示例,就是一个典型的"竞态"问题,用英文表示是 "race condition"。在 搜索、分页、 tab 切换 等场景经常会遇到这个问题
那么如何解决竞态问题呢?通常有以下几种思路:
- 在请求过程中禁止用户操作
- 抛弃之前的请求,只采用最后一次请求
- 在请求之前,先取消前一次请求
请求中禁止用户操作
这个解决方案是对界面的交互优化,比如说在点击搜索按钮后,我们在响应到来前给搜索按钮设置 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来保存promise的reject - 用户自己执行
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 })
axios 在 xhr 发起请求的 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);
}
}
在使用过程中需要注意:当一个请求被取消后,再次执行该请求,由于 signal.abort 是 true ,会立即取消。要想再次发起请求,需要创建一个新的 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 来解决竞态问题
最后希望本文能在你遇到竞态问题时,提供更多的解决思路