竞态问题:AbortController 取消同类请求

481 阅读5分钟

什么是竞态

竞态问题比较常见于tab页的切换,用户可能快速的切换几个tab,这时候会发送多个请求:请求a 请求b 请求c ,但可能因网络波动,或服务器等因素导致响应结果的顺序与请求不一致。最终呈现的效果可能是在c页面显示的是b的数据。

解决方案

在发起请求之前,检查一下是否已有正在请求的相同接口,如果有就取消掉正在请求的接口,重新发送本次请求。 这样就可以保证发送多个请求,最终获取到的响应只有一个,就是用户最后发送的请求。

这个解决方案有几个问题需要考虑

如何取消请求

绝大多数应用使用的都是 axios 或者 fetch 来发送请求的,这里拿 axios 举例

需要注意 aixos 在不同版本下取消请求的方式也有所不同

如果项目中 axios 版本在 0.22.0 之前

import axios from 'axios';
​
const source = axios.CancelToken.source();
const response = await axios.get('/api/data', {
  cancelToken: source.token
});
// 取消请求
source.isCancel('取消原因')

如果项目中 axios 版本在 0.22.0 及以后

和 fetch 一样支持直接使用 AbortController 进行请求取消

import axios from 'axios';
​
const abortController = new AbortController();
const response = await axios.get('/api/data', {
  signal: abortController.signal
});
// 取消请求
abortController.abort('取消原因')

取消请求之后会被响应拦截的 error 拦截。

如何判断请求是否要被取消

我们再回头看 tab 切换的例子,每个 tab 切换时发送的接口名都是一致的,但是请求的参数不同。是不是根据请求接口名作为标识即可,例如已发送接口名字的 /getList?type=1,在接口1没返回相应之前,发送 /getList?type=2 直接取消接口1 这样看用接口名作为标识没问题。

但有一种比较极端的情况,一个页面有两个组件,组件 1 和组件 2 都同时调用了 /getList 接口。这时候如果我们取消了一个请求会导致一个组件获取不到请求接口从而导致错误。 当然这种情况本身设计可能存在问题,但我们不能排除整个项目都没有类似的请求或者特殊的场景,在全局里面加逻辑还是要谨慎的。

再考虑一个问题,是不是所有的接口都要加这个取消相同请求的逻辑。我认为只有部分业务场景存在问题(如tab切换,输入框搜索输入完自动查询)

像是加了 loading 的查询按钮本身就会等待上个接口完成之后,这种操作虽然用户也可能多次触发,但就不会有竞态问题。

最终思路

通过 axios 的全局拦截器对重复接口进行拦截,但是不是针对所有接口,这个取消逻辑只有接口在 options 里面传了接口唯一标识才生效,就是说如果你觉得这个有竞态的风险,你就在发送请求的时候多传一个唯一值(格式可以自定义唯一即可,但是不能是随机值,要保证连续几次调用值都是一样的)。

在全局请求拦截里面用一个对象 abortControllers 存储,key为唯一值,value为取消请求的方法(abortController.abort,每一个请求都有自己的 abortController)。第一次请求 abortControllers 为空,初始化一个 abortController 用于取消请求,唯一值作为key存入,第二次请求过来在 abortController 里面找看看有没有key相同的唯一值,如果有就说明上一个同样的请求还没有响应,调用存储在 value 的取消方法取消上一个请求,重新创建一个新的 abortController 覆盖掉 唯一值 key 对于的value 取消方法。 等到有相应之后就可以把这个 key 从 abortControllers 中删除了。

下面看一下具体的实现:

使用

 export async function getSelectEmpCustomStandardApi (params,options) {
  const { data } = await instance({
    url: '/api/hrsaas-emp/empList/selectEmpCustomStandard',
    method: 'post',
    data:params,
    ...options
  })
  return data;
}
 // 这个接口可能存在竞态问题 除了参数之外传入 uniqueCancelIdentifier
 const data = await getSelectEmpCustomStandardApi(params,{uniqueCancelIdentifier:'xxx唯一id'});

axios 版本在 0.22.0 之后 AbortController 取消

const instance = axios.create({
  timeout: 1000*60*3,
  headers: defaultHeader,
  withCredentials: true,
  baseURL: baseUrl,
});
​
const abortControllers = {};
//请求拦截
instance.interceptors.request.use(
  function (config) {
    // ... 
    // 正常请求不会进入这个逻辑,不受影响
    if(config.uniqueCancelIdentifier){
      
      if(abortControllers[config.uniqueCancelIdentifier]){
        console.log('取消请求', config.uniqueCancelIdentifier)
        abortControllers[config.uniqueCancelIdentifier].abort()
      }
      
      const abortController = new AbortController()
      config.signal = abortController.signal
      abortControllers[config.uniqueCancelIdentifier] = abortController
    }
​
    return config;
  },
  function (error) {
    return Promise.reject(error);
  }
);
//响应拦截
instance.interceptors.response.use(
  function (config) {
      // ...   请求成功删除唯一值
      delete abortControllers[config?.config?.uniqueCancelIdentifier]
    return config;
  },
  function (error) {
    // ...
    delete abortControllers[error?.config?.uniqueCancelIdentifier]
    return Promise.reject(error);
  }
);

axios 版本在0.22.0之前的 axios.CancelToken.source() 取消

全局拦截axios

const instance = axios.create({
  timeout: 1000*60*3,
  headers: defaultHeader,
  withCredentials: true,
  baseURL: baseUrl,
});
​
const abortControllers = {};
//请求拦截
instance.interceptors.request.use(
  function (config) {
    // ... 
    if(config.uniqueCancelIdentifier){
      
      if(abortControllers[config.uniqueCancelIdentifier]){
        console.log('取消请求', config.uniqueCancelIdentifier)
        abortControllers[config.uniqueCancelIdentifier]()
      }
      
      const cancelToken = axios.CancelToken.source()
      config.cancelToken = cancelToken.token
      abortControllers[config.uniqueCancelIdentifier] = cancelToken.cancel
    }
​
    return config;
  },
  function (error) {
    return Promise.reject(error);
  }
);
//响应拦截
instance.interceptors.response.use(
  function (config) {
      // ...
      delete abortControllers[config?.config?.uniqueCancelIdentifier]
    return config;
  },
  function (error) {
    // ...
    delete abortControllers[error?.config?.uniqueCancelIdentifier]
    return Promise.reject(error);
  }
);

结尾

最后说一点防抖并不能解决竞态问题,只能降低触发的概率,tab这种也可以用loading来避免,一个列表的数据请求回来之后再允许用户切换另一个tab,但是体验太差了 ,不如表单的查询按钮的loading容易接受。

仅仅是本人对竞态问题的一点点理解,如果有不成熟的地方请各位大佬在评论区指正。希望大家能够有所收获!