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

- 用户点击
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
来解决竞态问题
最后希望本文能在你遇到竞态问题时,提供更多的解决思路