前端如何彻底解决重复请求问题

背景

  1. 保存按钮点击多次,造成新增多个单据
  2. 列表页疯狂刷新,导致服务器压力大

如何彻底解决

方案:我的思路从请求层面判断相同请求只发送一次,将结果派发给各个订阅者

实现思路

  1. 对请求进行数据进行hash
  2. 添加store 存储 hash => Array promise
  3. 相同请求,直接订阅对应的promise
  4. 请求取消,则将store中对应的promise置为null
  5. 请求返回后,调用所有未取消的订阅

核心代码

typescirpt 复制代码
  private handleFinish(key: string, index: number) {
    const promises = this.store.get(key);
    // 只有一个promise时则删除store
    if (promises?.filter((item) => item).length === 1) {
      this.store.delete(key);
    } else if (promises && promises[index]) {
    // 还有其他请求,则将当前取消的、或者完成的置为null
      promises[index] = null;
    }
  }

  private async handleRequest(config: any) {
    const hash = sha256.create();
    hash.update(
      JSON.stringify({
        params: config.params,
        data: config.data,
        url: config.url,
        method: config.method,
      }),
    );
    const fetchKey = hash.hex().slice(0, 40);
    const promises = this.store.get(fetchKey);
    const index = promises?.length || 0;
    let promise = promises?.find((item) => item);
    const controller = new AbortController();

    if (config.signal) {
      config.signal.onabort = (reason: any) => {
        const _promises = this.store.get(fetchKey)?.filter((item) => item);
        if (_promises?.length === 1) {
          controller.abort(reason);
          this.handleFinish(fetchKey, index);
        }
      };
    }

    if (!promise) {
      promise = this.instance({
        ...config,
        signal: controller.signal,
        headers: {
          ...config.headers,
          fetchKey,
        },
      }).catch((error) => {
        console.log(error, "请求错误");
        // 失败的话,立即删除,可以重试
        this.handleFinish(fetchKey, index);
        return { error };
      });
    }

    const newPromise = Promise.resolve(promise)
      .then((result: any) => {
        if (config.signal?.aborted) {
          this.handleFinish(fetchKey, index);
          return result;
        }
        return result;
      })
      .finally(() => {
        setTimeout(() => {
          this.handleFinish(fetchKey, index);
        }, 500);
      });
    this.store.set(fetchKey, [...(promises || []), newPromise]);
    return newPromise;
  }

以下为完整代码(仅供参考)

index.ts

typescript 复制代码
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
import { sha256 } from "js-sha256";
import transformResponseValue, { updateObjTimeToUtc } from "./utils";

type ErrorInfo = {
  message: string;
  status?: number;
  traceId?: string;
  version?: number;
};

type MyAxiosOptions = AxiosRequestConfig & {
  goLogin: (type?: string) => void;
  onerror: (info: ErrorInfo) => void;
  getHeader: () => any;
};

export type MyRequestConfigs = AxiosRequestConfig & {
  // 是否直接返回服务端返回的数据,默认false, 只返回 data
  useOriginData?: boolean;
  // 触发立即更新
  flushApiHook?: boolean;
  ifHandleError?: boolean;
};

type RequestResult<T, U> = U extends { useOriginData: true }
  ? T
  : T extends { data?: infer D }
    ? D
    : never;

class LmAxios {
  private instance: AxiosInstance;

  private store: Map<string, Array<Promise<any> | null>>;

  private options: MyAxiosOptions;

  constructor(options: MyAxiosOptions) {
    this.instance = axios.create(options);

    this.options = options;
    this.store = new Map();
    this.interceptorRequest();
    this.interceptorResponse();
  }

  // 统一处理为utcTime
  private interceptorRequest() {
    this.instance.interceptors.request.use(
      (config) => {
        if (config.params) {
          config.params = updateObjTimeToUtc(config.params);
        }
        if (config.data) {
          config.data = updateObjTimeToUtc(config.data);
        }
        return config;
      },
      (error) => {
        console.log("intercept request error", error);
        Promise.reject(error);
      },
    );
  }

  // 统一处理为utcTime
  private interceptorResponse() {
    this.instance.interceptors.response.use(
      (response): any => {
        // 对响应数据做处理,以下根据实际数据结构改动!!...
        const [checked, errorInfo] = this.checkStatus(response);

        if (!checked) {
          return Promise.reject(errorInfo);
        }

        const disposition =
          response.headers["content-disposition"] ||
          response.headers["Content-Disposition"];
        // 文件处理
        if (disposition && disposition.indexOf("attachment") !== -1) {
          const filenameReg =
            /filename\*?=['"]?(?:UTF-\d['"]*)?([^;\r\n"']*)['"]?;?/g;
          const filenames: string[] = [];
          disposition.replace(filenameReg, (r: any, r1: string) => {
            filenames.push(decodeURIComponent(r1));
          });
          return Promise.resolve({
            filename: filenames[filenames.length - 1],
            data: response.data,
          });
        }
        if (response) {
          return Promise.resolve(response.data);
        }
      },
      (error) => {
        console.log("request error", error);
        if (error.message.indexOf("timeout") !== -1) {
          return Promise.reject({
            message: "请求超时",
          });
        }
        const [checked, errorInfo] = this.checkStatus(error.response);
        return Promise.reject(errorInfo);
      },
    );
  }

  private checkStatus(
    response: AxiosResponse<any>,
  ): [boolean] | [boolean, ErrorInfo] {
    const { code, message = "" } = response?.data || {};
    const { headers, status } = response || {};

    if (!status) {
      return [false];
    }

    // 单地登录判断,弹出不同提示
    if (status === 401) {
      this.options?.goLogin();
      return [false];
    }

    if (code === "ECONNABORTED" && message?.indexOf("timeout") !== -1) {
      return [
        false,
        {
          message: "请求超时",
        },
      ];
    }

    if ([108, 109, 401].includes(code)) {
      this.options.goLogin();
      return [false];
    }
    if ((code >= 200 && code < 300) || code === 304) {
      // 如果http状态码正常,则直接返回数据
      return [true];
    }

    if (!code && ((status >= 200 && status < 300) || status === 304)) {
      return [true];
    }

    let errorInfo = "";
    const _code = code || status;
    switch (_code) {
      case -1:
        errorInfo = "远程服务响应失败,请稍后重试";
        break;
      case 400:
        errorInfo = "400: 错误请求";
        break;
      case 401:
        errorInfo = "401: 访问令牌无效或已过期";
        break;
      case 403:
        errorInfo = message || "403: 拒绝访问";
        break;
      case 404:
        errorInfo = "404: 资源不存在";
        break;
      case 405:
        errorInfo = "405: 请求方法未允许";
        break;
      case 408:
        errorInfo = "408: 请求超时";
        break;
      case 500:
        errorInfo = message || "500: 访问服务失败";
        break;
      case 501:
        errorInfo = "501: 未实现";
        break;
      case 502:
        errorInfo = "502: 无效网关";
        break;
      case 503:
        errorInfo = "503: 服务不可用";
        break;
      default:
        errorInfo = "连接错误";
    }

    return [
      false,
      {
        message: errorInfo,
        status: _code,
        traceId: response?.data?.requestId,
        version: response.data.ver,
      },
    ];
  }

  private handleFinish(key: string, index: number) {
    const promises = this.store.get(key);
    if (promises?.filter((item) => item).length === 1) {
      this.store.delete(key);
    } else if (promises && promises[index]) {
      promises[index] = null;
    }
  }

  private async handleRequest(config: any) {
    const hash = sha256.create();
    hash.update(
      JSON.stringify({
        params: config.params,
        data: config.data,
        url: config.url,
        method: config.method,
      }),
    );
    const fetchKey = hash.hex().slice(0, 40);
    const promises = this.store.get(fetchKey);
    const index = promises?.length || 0;
    let promise = promises?.find((item) => item);
    const controller = new AbortController();

    if (config.signal) {
      config.signal.onabort = (reason: any) => {
        const _promises = this.store.get(fetchKey)?.filter((item) => item);
        if (_promises?.length === 1) {
          controller.abort(reason);
          this.handleFinish(fetchKey, index);
        }
      };
    }

    if (!promise) {
      promise = this.instance({
        ...config,
        signal: controller.signal,
        headers: {
          ...config.headers,
          fetchKey,
        },
      }).catch((error) => {
        console.log(error, "请求错误");
        // 失败的话,立即删除,可以重试
        this.handleFinish(fetchKey, index);
        return { error };
      });
    }

    const newPromise = Promise.resolve(promise)
      .then((result: any) => {
        if (config.signal?.aborted) {
          this.handleFinish(fetchKey, index);
          return result;
        }
        return result;
      })
      .finally(() => {
        setTimeout(() => {
          this.handleFinish(fetchKey, index);
        }, 500);
      });
    this.store.set(fetchKey, [...(promises || []), newPromise]);
    return newPromise;
  }

  // add override type
  public async request<T = unknown, U extends MyRequestConfigs = {}>(
    url: string,
    config: U,
  ): Promise<RequestResult<T, U> | null> {
    // todo
    const options = {
      url,
      // 是否统一处理接口失败(提示)
      ifHandleError: true,
      ...config,
      headers: {
        ...this.options.getHeader(),
        ...config?.headers,
      },
    };

    const res = await this.handleRequest(options);

    if (!res) {
      return null;
    }

    if (res.error) {
      if (res.error.message && options.ifHandleError) {
        this.options.onerror(res.error);
      }
      throw new Error(res.error);
    }

    if (config.useOriginData) {
      return res;
    }

    if (config.headers?.feTraceId) {
      window.dispatchEvent(
        new CustomEvent<{ flush?: boolean }>(config.headers.feTraceId, {
          detail: {
            flush: config?.flushApiHook,
          },
        }),
      );
    }

    // 默认返回res.data
    return transformResponseValue(res.data)
  }
}

export type MyRequest = <T = unknown, U extends MyRequestConfigs = {}>(
  url: string,
  config: U,
) => Promise<RequestResult<T, U> | null>;

export default LmAxios;

utils.ts(这里主要用来处理utc时间,你可能用不到删除相关代码就好)

typescript 复制代码
import moment from 'moment';

const timeReg =
  /^\d{4}([/:-])(1[0-2]|0?[1-9])\1(0?[1-9]|[1-2]\d|30|31)($|( |T)(?:[01]\d|2[0-3])(:[0-5]\d)?(:[0-5]\d)?(\..*\d)?Z?$)/;

export function formatTimeValue(time: string, format = 'YYYY-MM-DD HH:mm:ss') {
  if (typeof time === 'string' || typeof time === 'number') {
    if (timeReg.test(time)) {
      return moment(time).format(format);
    }
  }
  return time;
}

// 统一转化如参
export const updateObjTimeToUtc = (obj: any) => {
  if (typeof obj === 'string') {
    if (timeReg.test(obj)) {
      return moment(obj).utc().format();
    }
    return obj;
  }
  if (toString.call(obj) === '[object Object]') {
    const newObj: Record<string, any> = {};
    Object.keys(obj).forEach((key) => {
      newObj[key] = updateObjTimeToUtc(obj[key]);
    });
    return newObj;
  }
  if (toString.call(obj) === '[object Array]') {
    obj = obj.map((item: any) => updateObjTimeToUtc(item));
  }
  return obj;
};

const utcReg = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.*Z$/;

const transformResponseValue = (res: any) => {
  if (!res) {
    return res;
  }
  if (typeof res === 'string') {
    if (utcReg.test(res)) {
      return moment(res).format('YYYY-MM-DD HH:mm:ss');
    }
    return res;
  }
  if (toString.call(res) === '[object Object]') {
    const result: any = {};
    Object.keys(res).forEach((key) => {
      result[key] = transformResponseValue(res[key]);
    });
    return result;
  }
  if (toString.call(res) === '[object Array]') {
    return res.map((item: any) => transformResponseValue(item));
  }
  return res;
};
export default transformResponseValue;
相关推荐
GISer_Jing1 小时前
前端面试通关:Cesium+Three+React优化+TypeScript实战+ECharts性能方案
前端·react.js·面试
落霞的思绪2 小时前
CSS复习
前端·css
咖啡の猫4 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲6 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5817 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路7 小时前
GeoTools 读取影像元数据
前端
ssshooter8 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
你的人类朋友8 小时前
【Node.js】什么是Node.js
javascript·后端·node.js
Jerry8 小时前
Jetpack Compose 中的状态
前端
dae bal9 小时前
关于RSA和AES加密
前端·vue.js