竞态需求

174 阅读5分钟

背景:

在项目开发过程中,我们经常会遇到重复请求的场景,如果不对重复的请求进行处理,则可能会导致系统出现各种问题。

例如假设用户在系统内的列表搜索条件连续查询两次,第一次查询条件较复杂,请求的返回结果比第二次慢,就会导致查询出来是第一次的结果,这是不符合预期的,便产生了竞态问题。

其实竞态不止发生在请求间,凡是异步操作同一数据的(包括 DOM 元素),都有可能出现竞态问题。而网络请求的频繁程度、响应时长的不确定性,使其成为最常见的竞态场景。

解决这个问题的核心思路是如何忽略前面几次无效请求,只处理最后一次请求的结果。\

方案一:增加请求ID

在每次请求时,都生成一个随机的字符串数据带入请求中去,后端在返回数据时,再将该requestID返回;本地也有一个全局变量,记录最新的requestID。

在数据返回后,拿数据中的requestID与本地最新的requestID做比对,如果相等,则展示数据,不相等,则舍掉数据。

const reqId = useRef(+new Date());

const run = (async (params) => {
  reqId.current = +new Date();
  const data = await Axios.get("/getSomething", { params: { ...params, reqId: reqId.current } });
  if (reqId.current.toString() === data.data.reqId) {
    setData(data.data);
  }
});

优点:逻辑较简单

缺点:需要依赖后端的配合,最好完全由前端处理。

方案二:利用useEffect特性

useRequest

ahooks的useRequest在取消请求中这样描述“竞态取消,当上一次请求还没返回时,又发起了下一次请求,则会取消上一次请求”,说明其默认处理了竞态问题。\

自行实现

先来看一下react hooks的执行流程:

注:Cleanup 会在下面两种情况下被执行:

  • 组件销毁的时候
  • 下一次将要调用此 effect 时

\

可以发现,在每次Update阶段,都会先执行一次cleanUp Effects,然后再执行Effects函数。借助此效果,我们可以定义一个Ref外部变量,每次Effect clean阶段改变该值。在effect阶段,将该值赋给局部变量。在请求结束之后,判断该次请求是否被取消。

  useEffect(() => {
    let didCancel = false;
    httpGet(time).then((res) => {
      if (!didCancel) {
        setData(res);
      }
    });
    return () => {
      didCancel = true;
    };
  }, [time]);

要理解上面的代码,需要知道httpGet 和 cleanup 方法形成了两个闭包,这两个闭包使用了同一个 didCancel 变量。每次请求产生的目标方法都有自己的 didCancel ,而每一个 didCancel 又只会影响本目标方法的执行。当后一次请求触发时会调用前一次请求方法的cleanup,将其didCancel置为true,当前一次请求返回时,他的结果就不会被处理。

优点:不依赖后端实现

缺点:依据useEffect的实现方案,没法做到全局统一处理

提取为单独hooks

// 使用函数返回值的方式保证原来的数据
export const useCurrentEffect = (effect, deps) => {
  useEffect(() => {
    let isCurrent = true;

    // 如果返回了清理函数,则放在cleanup阶段执行
    const cleanup = effect(() => isCurrent);

    return () => {
      isCurrent = false;
      cleanup && cleanup();
    };
  }, deps);
};

使用:

useCurrentEffect(
  (getCurrent) => {
    if(!time) return;
    const run = async () => {
      const data = await Axios.get("/getSth", { params: { time } });
      if (getCurrent()) {
        setData(data);
      }
    };
    run();
  },
  [time]
);

方案三:取消上一次请求

还有一种实现思路,我们可以在下一次请求的时候,直接将上一次未结束的网络请求取消掉。取消网络请求,对应的api是

XMLHttpRequest.abort()

let xhr = new XMLHttpRequest();
xhr.open("GET", "https://developer.mozilla.org/", true);
xhr.send();
setTimeout(() => xhr.abort(), 300);

Axios的 CancelToken

返回实例用法

const CancelToken = axios.CancelToken;
const source = CancelToken.source();

Axios.post('/user', { params }, {
  cancelToken: source.token
})

source.cancel(); 

构造函数用法

const CancelToken = axios.CancelToken;
let cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    cancel = c;
  })
});

cancel(); // 取消请求

Axios的cancelToken的实现方式是利用了一个cancel token的Promise,如果存在该Promise,并且该promise resolve了,则执行xhr.abort()方法来取消掉请求。

单应用场景实现

const cancelSource = useRef(null);

const run = async (time) => {
  if (cancelSource.current) {
    // 如果之前存在请求,则取消掉
    cancelSource.current.cancel();
    cancelSource.current = null;
  }
  const CancelToken = Axios.CancelToken;
  cancelSource.current = CancelToken.source();

  const data = await Axios.get("/user", {
    params,
    cancelToken: cancelSource.current.token,
  });
  // 清空cancelSource值
  cancelSource.current = null;
  setData(data);
}

\

全局统一处理实现

\

如何判断重复请求

当请求方式、请求 URL 地址都一样时,我们就认为请求是一样的。因此在每次发起请求时,根据当前请求的请求方式、请求 URL 地址生成一个唯一的 key,同时为每个请求创建一个专属的 CancelToken,然后把 key 和 cancel 函数以键值对的形式保存到 Map 对象中,使用 Map 的好处是可以快速的判断是否有重复的请求:

注:如果请求参数也要相同才认为两个请求一样,在key中加入参数即可

// 生成唯一key
function generateReqKey(config) {
  const { method, url} = config;
  return [method, url].join("&");
}

// 把当前请求信息添加到pendingRequest对象中
const pendingRequest = new Map();
function addPendingRequest(config) {
  const requestKey = generateReqKey(config);
  config.cancelToken = config.cancelToken || new axios.CancelToken((cancel) => {
    if (!pendingRequest.has(requestKey)) {
       pendingRequest.set(requestKey, cancel);
    }
  });
}

当出现重复请求的时候,就可以使用 cancel 函数来取消前面已经发出的请求,在取消请求之后,我们还需要把取消的请求从 pendingRequest 中移除。

// 从pendingRequest对象中移除请求
function removePendingRequest(config) {
  const requestKey = generateReqKey(config);
  if (pendingRequest.has(requestKey)) {
     const cancelToken = pendingRequest.get(requestKey);
     cancelToken(requestKey);
     pendingRequest.delete(requestKey);
  }
}

全局拦截器设置

请求拦截器

axios.interceptors.request.use(
  function (config) {
    removePendingRequest(config); // 检查是否存在重复请求,若存在则取消已发的请求
    addPendingRequest(config); // 把当前请求信息添加到pendingRequest对象中
    return config;
  },
  (error) => {
     return Promise.reject(error);
  }
);

响应拦截器

axios.interceptors.response.use(
  (response) => {
     removePendingRequest(response.config); // 从pendingRequest对象中移除请求
     return response;
   },
   (error) => {
      removePendingRequest(error.config || {}); // 从pendingRequest对象中移除请求
      if (axios.isCancel(error)) {
        console.log("已取消的重复请求:" + error.message);
      } else {
        // 添加异常处理
      }
      return Promise.reject(error);
   }
);

\

优点:纯JS解决方案,并且可以在拦截器中做统一处理。

缺点:有些场景下不需要竞态处理