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

背景

  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;
相关推荐
mldong4 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排5 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60616 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角7 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!7 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰7 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi9 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen9 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马10 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu10 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss