前端如何优雅的解决竞态问题

154 阅读1分钟

竞态问题

在一些频繁请求的接口上,如列表快速翻页、查询表单快速点击查询时都可能出现。下面模拟了这种情况:

3eb90897-5cc3-4673-b48e-1f28c4dbd4cf.gif 延时请求会在1s后返回,ID区分立刻返回,可以看到由于延时请求ID区分到达更晚,导致数据与期望的不一致。

虽然可以通过一些控制查询按钮使能状态等方法规避,但是也只是治标不治本,下面分享两个针对这个问题的处理方法,欢迎交流~

方案1:通过ID区分

最常见的解法莫过于为每个请求设置id,在数据消费者拿到数据后对比请求前后id,区分是否为本次请求的数据。

下面是通过fetch封装的一个支持请求id的方法。

export interface RequestProps<T = any, R = any> {
  url: string;
  method?: "GET" | "POST" | "PUT" | "DELETE";
  data?: T;
  params?: R;

  requestId?: string;
}

const baseUrl = "http://localhost:7001";
const request = async ({
  url,
  method = "GET",
  data,
  params,
  requestId,
}: RequestProps) => {
  const searchParams = new URLSearchParams(params).toString();
  const urlWithParams = `${baseUrl}${url}?${searchParams}`;
  const res = await fetch(urlWithParams, {
    method,
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(data),
  });

  const result = await res.json();

  return { data: result, requestId: requestId };
};

export default request;

在使用时传入requestId参数:

  const currentRequestId = useRef<string>("");

  const handleData = async (type: string) => {
    currentRequestId.current = crypto.randomUUID();
    const { data, requestId } = await request({
      url: "/api/data",
      method: "GET",
      params: { type },
      requestId: currentRequestId.current,
    });
    if (requestId === currentRequestId.current) {
      setData(data.data);
    }
  };

效果如下:

3eb90897-5cc3-4673-b48e-1f28c4dbd4cf.gif

方案2:自动取消

在实际生产中,可以看到上述的方案存在诸多弊端:

  • 请求没有取消,依旧正常发送、返回,浪费带宽
  • 每次需要在业务逻辑中处理请求的问题,不够解耦
  • 使用不方便,在不同的模块需要写同样的处理逻辑(可以通过封装解决)

下面分享一种简单优雅的处理方法,代码如下:

  const requestMap = new Map<string, AbortController>();
  const key = url + method;
  if (autoCancel && requestMap.has(key)) {
    requestMap.get(key)?.abort();
    requestMap.delete(key);
  }

  const controller = new AbortController();
  if (autoCancel) {
    requestMap.set(key, controller);
  }
  
  const res = await fetch(urlWithParams, {
    method,
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(data),
    signal: controller.signal,
  });

通过一个autoCancel参数控制是否支持自动取消,解决了上述的几点痛点问题。

可能有疑惑的点在于:为什么key要这样组合呢?

结合业务场景,

  1. 一般的会出现竞态问题的地方都是同一个接口的快速重复调用,因此,直接通过url可以初步作为是否需要支持自动取消的key
  2. 考虑到RESTFUL风格的API形式,需要加上请求的method再做区分。

实现的效果如下:

dfae9f3a-026d-48e8-ac7e-c896ff6c6d52.gif 可以看到在后续触发相同的请求时,自动取消了之前的请求。

补充

采用第二种方案时有一个点需要注意:当使用try...catch处理异常时,请求取消也会被catch到,因此需要单独加一个判断处理:

......
  } catch (error: any) {
    if (error.name === "AbortError") {
      return null;
    }
    console.error(error);
  }
......

完整代码:

export interface RequestProps<T = any, R = any> {
  url: string;
  method?: "GET" | "POST" | "PUT" | "DELETE";
  data?: T;
  params?: R;

  requestId?: string;
  autoCancel?: boolean;
}

const baseUrl = "http://localhost:7001";
const requestMap = new Map<string, AbortController>();
const request = async ({
  url,
  method = "GET",
  data,
  params,
  requestId,
  autoCancel = false,
}: RequestProps) => {
  const key = url + method;
  if (autoCancel && requestMap.has(key)) {
    requestMap.get(key)?.abort();
    requestMap.delete(key);
  }

  const controller = new AbortController();
  if (autoCancel) {
    requestMap.set(key, controller);
  }

  const searchParams = new URLSearchParams(params).toString();
  const urlWithParams = `${baseUrl}${url}?${searchParams}`;

  let result = null;
  try {
    const res = await fetch(urlWithParams, {
      method,
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(data),
      signal: controller.signal,
    });
    result = await res.json();
  } catch (error: any) {
    if (error.name === "AbortError") {
      return null;
    }
    console.error(error);
  }

  return { data: result, requestId: requestId };
};

export default request;