网络数据竞态问题:由于网络不稳定性,后发起的ajax请求有可能会比先发起的ajax拿到响应结果,带来的问题就是数据处理的顺序异常。
场景:一般出现在频繁发起请求的展示性视图中,如远程搜索输入框、分页快速切换等,有可能会出现展示结果与搜索不符

解决方案: 只对最后一次请求数据进行处理
- 取消上一次的 axios 请求
- 取消上一次的 promise
- 自增 id 只执行最新 id 的处理
至于 rxjs
的 switchMap
这里就不过多介绍了,感兴趣的可以自行查看
目前提供这些解决方法的文章已经很多,但是少有用ts写的,也少有封装取消上一次axios请求的,本文用于个人学习记录,需要的时候可以直接cv用
1. 取消 axios 请求
代码比较简单,相信大家都知道可以通过 AbortController
来 取消请求,关键在于把 signal 暴露并接收其余传参给回调以及自动取消上一次的请求,被取消的请求都会处于rejected
状态
ts
type CancelPrevious = AbortController['abort'] | null
export default function cancelAxios<T extends any[], R>(
fn: (signal: AbortSignal, ...args: T) => R
): (...args: T) => R {
let controller: AbortController | null = null
let cancelPrevious: CancelPrevious = null
const wrappedFn: (...args: T) => R = (...args) => {
// 第一次调用 cancelPrevious 是 null,后面都会调用上一次记录的 controller.abort() 方法
cancelPrevious && cancelPrevious()
controller = new AbortController()
const signal = controller.signal
const result = fn(signal, ...args)
// 记录当前cancel的方法用于下一次调用执行
cancelPrevious = () => controller && controller.abort()
return result
}
return wrappedFn
}
- 简单使用
- 在 api 文件直接包一层 cancelAxios 配置一下 signal 就行
- 业务组件 调用 api,需要注意的是要区分Cancel异常或者其他异常,可以使用
axios.isCancel
或者error.code === 'ERR_CANCELED'
进行判断
ts
// 1.api文件
export const findAll = cancelAxios((signal: AbortSignal, data) =>
axios.post('api/xxx', data, { signal })
)
// 2.业务组件 调用 api
import axios from 'axios'
const querySearch = async (queryString: string) => {
try {
const { data } = await findAll({ query: queryString })
} catch (error) {
if (axios.isCancel(error)) {
// ...
} else {
// ...
}
}
}
2. 取消 Promise
和取消axios请求的处理类似,这里的关键点是让 promise 一直处于 pending
状态来取消链式调用。引用一个 npm 库 awesome-imperative-promise
来实现该功能,该库原理也很简单感兴趣的可以看一下 源码。至于大佬们讨论的内存泄漏问题,个人在最新版 chrome 123 中测试未发现,介意慎用。
ts
import { createImperativePromise } from 'awesome-imperative-promise'
import type { CancelCallback } from 'awesome-imperative-promise'
type CancelPrevious = CancelCallback | null
type Fn<T extends any[], R> = (...args: T) => Promise<R>
export default function cancelPromise<T extends any[], R>(fn: Fn<T, R>): Fn<T, R> {
let cancelPrevious: CancelPrevious = null
const wrappedFn: Fn<T, R> = (...args: T) => {
// 调用cancelPrevious()来取消上一次的promise
cancelPrevious && cancelPrevious()
const result = fn(...args) as Promise<R>
const { promise, cancel } = createImperativePromise(result)
// 记录当前cancel的方法用于下一次调用执行
cancelPrevious = cancel
return promise
}
return wrappedFn
}
3. 只处理最新 id
封装思路都一样,这里用 自增 id 作为标识,只处理最新的 id 响应,这里和方法2不同的是会等待请求完成再根据id判断返回data或者undefined,所以promise会处于 fulfilled
状态。需要注意的是处理接口数据时需要判断一下是否为undefined,否则有可能会报错。
ts
export default function useResolveLastReq<T extends any[], R>(
fn: (...args: T) => Promise<R>
): (...args: T) => Promise<R | undefined> {
let id = 0 // 标识每次请求
const wrappedFn = async (...args: T): Promise<R | undefined> => {
const curId = id + 1 // 每次请求的ID
id = curId // 最新的请求ID
// 执行请求
const data = await fn(...args)
try {
if (curId === id) {
return data
}
} catch (e) {
if (curId === id) {
throw e
}
}
}
return wrappedFn
}
总结
以上三种方法都可以实现只处理最后一次的请求结果,解决网络数据竞态问题,但是所有方法都会将请求会发到后端(包括abort网络请求),所以只适合用于查询类的请求,不适合用于创建或者更新类的请求,各有优劣,可自行选择
1.取消 axios 请求:
- 优点:被取消的promise会处于
rejected
状态,不会有内存泄漏的顾虑,且可以减少网络资源的浪费。 - 缺点:需要特殊处理 Cancel 异常不影响用户体验。
2.取消 promise:
- 优点:不用等待接口响应可以直接取消promise,只会执行最后一次的promise数据处理,不需要做undefined或Cancel rejected的特殊处理
- 缺点:依赖第三方库(其实很小,就43行代码可以直接cv到本地使用),被取消的promise都会处于
pending
状态,有可能会内存泄漏,经测试个人觉得没有一直处于引用状态就可以放心用
3.只处理最新id
- 优点:简单通用
- 缺点:速度可能是最慢的,需要等待每一个promise处于
fulfilled
状态才进行有效数据处理,对返回结果undefined需要做非空校验
参考及特别感谢:字节跳动技术团队