如何使用Axios缓存请求和限制频率

583 阅读4分钟

在很多情况下前端处理请求时需要使用防抖防止连续发出请求,对一些数据并不经常变更get接口使用缓存

现在大多数的前端应用都会首选Axios作为Ajax的工具类,我们可以使用Axios的拦截器来对get请求数据进行缓存,对连续请求直接abort。这样在一些未覆盖到使用debounce防抖和缓存的页面及功能,可以在请求时进行统一的优化处理,简单也高效

此处的缓存是指如Storage的浏览器存储,而不是cache-control需服务器指定的缓存

流程图

Tips:通过流程图你会发现在request拦截器中使用Promise.reject抛出的错误,会在 response拦截器中被捕获

使用url,method,params,data来生成唯一的key,用来判断是否是同一请求。需要注意的是,你务必在生成key时保证各个参数位置一致,否则会出现{a:'1',b:'2'}{b:'2',a:'1'}不是同一个请求

image.png

code it

示意代码是在vite + typescript,按需参考

注意:代码中我可能会使用自定义的storage方法,用法与传统的localStorage一致,因为懒所以懒得修改代码了

TypeScript中的使用

TypeScript使用Axios时,会不允许在config中增加不存在的配置项,所以我定义一个RequestConfig继承自AxiosRequestConfig ,增加了{cache,interval}的配置项。在export时不再直接export整个instance,而是重新定义get/post。如果你的RESTful的API中还会使用到其他的方法,如put,delete等,请自行重新定义

ps:其实有些参考使用data或者params中携带配置内容,这样会污染请求体,如果在request拦截器中删除掉后,会在response拦截器中找不到所对应的配置项。

// axios.ts

import axios from 'axios';
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import { getCSRF } from '@/utils/csrf';
import { getToken } from '@/utils/auth';
import { getStorage, removeStorage, setStorage } from '@/utils/storage';
import { md5 } from 'js-md5';

export interface HttpResponse<T = unknown> {
  status: number;
  msg: string;
  message?: string;
  code: number;
  data: T;
}

export interface RequestConfig<T = any> extends AxiosRequestConfig<T> {
  interval?: number | false;
  cache?: number | false;
}

const instance: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '',
  timeout: 3000,
  headers: {},
});

/**
 *  使用config中参数:url,method,params,data 生成唯一key
 * @param config AxiosRequestConfig
 * @returns string(MD5)
 */
function obj2str(obj: object): string {
  return (
    obj &&
    Object.keys(obj)
      .filter((v) => {
        return !!obj[v];
      })
      .sort((a, b) => a.localeCompare(b)) // 使用sort保证结果顺序一致
      .reduce((acc: string[], val) => {
        acc.push(`${val}:${obj[val]}`);
        return acc;
      }, [])
      .join('#')
  );
}
function generateFetchKey(config: AxiosRequestConfig): string {
  const keys = { url: config.url, method: config.method, params: config.params, data: config.data };
  keys.params = obj2str(keys.params);
  keys.data = obj2str(keys.data);
  return md5(obj2str(keys));
}
// #region 缓存设置
// ######################## 缓存设置 START ########################
const CACHE_KEY = 'fetch-cache';
const CACHE_MESSAGE = 'custom-cache';
const CACHE_DEFAULT_TIME = 500;
const buildCacheKey = (fetchKey: any) => {
  return `${CACHE_KEY}-${fetchKey}`;
};

const getCacheData = (fetchKey: string): null | any => {
  return getStorage<Record<string, any>>(buildCacheKey(fetchKey), sessionStorage);
};
// ⭐️⭐️⭐️ 设置缓存,并在expired时刻过后删除缓存 ⭐️⭐️⭐️ 
const setCacheData = (fetchKey: string, data: any, expired: number) => {
  fetchKey = buildCacheKey(fetchKey);
  setStorage(fetchKey, data, sessionStorage);
  setTimeout(() => {
    removeStorage(fetchKey, sessionStorage);
  }, expired);
};
// ######################## 缓存设置  END  #########################
// #endregion

// #region 连续请求设置
// ######################## 连续请求设置 START ########################
// 用于记录当前请求的取消函数

const INTERVAL_MESSAGE = 'custom-interval';
const INTERVAL_DEFAULT_TIME = 500;
const fetchQuery: Set<string> = new Set();
// ⭐️⭐️⭐️ 加入队列,并在interval时刻过后删除key ⭐️⭐️⭐️
function addFetchQuery(fetchKey: string, interval: number) {
  fetchQuery.add(fetchKey);
  setTimeout(() => {
    fetchQuery.delete(fetchKey);
  }, interval);
}
// ######################## 连续请求设置  END  ########################
// #endregion

// ######################## 拦截器 START ########################

instance.interceptors.request.use(
  (config: RequestConfig) => {
    const fetchKey = generateFetchKey(config);
    // 判断是否需限制请求频率
    if (config.interval !== false) {
      if (fetchQuery.has(fetchKey)) {
        const cancelRejectData = { message: INTERVAL_MESSAGE };
        // ⭐️ 发现连续请求,抛出错误给 response拦截器处理 ⭐️
        return Promise.reject(cancelRejectData);
      }
      addFetchQuery(fetchKey, config.interval || INTERVAL_DEFAULT_TIME);
    }

    // 判断是否使用缓存
    if (config.method === 'get' && config.cache !== false) {
      const cacheData = getCacheData(fetchKey);
      if (cacheData) {
        const cacheRejectData = { data: cacheData, message: CACHE_MESSAGE };
        // ⭐️ 存在缓存,抛出错误给response拦截器处理 ⭐️
        return Promise.reject(cacheRejectData);
      }
    }
    
    // 按需使用,这里将jwt-token及 csrf-token 追加到headers上,与本次内容无关可忽略
    const token = getToken();
    if (token) {
      config.headers = config.headers || {};
      config.headers.Authorization = `Bearer ${token}`;
      config.headers['x-csrf-token'] = getCSRF() as string;
    }
    config.withCredentials = true;
    return config;
  },
  (error) => {
    return Promise.reject(error);
  }
);
instance.interceptors.response.use(
  (response: AxiosResponse<HttpResponse>) => {
    const res = response.data;
    const config = response.config as RequestConfig;
    // ⭐️ 判断当前接口是否需要缓存结果 ⭐️
    if (config.method === 'get' && config.cache !== false) {
      setCacheData(generateFetchKey(config), res, config.cache || CACHE_DEFAULT_TIME);
    }
    return res;
  },
  (error) => {
      // 根据message判断错误处理方式
      
    // ⭐️ 如果是缓存reject,则使用 resolve返回缓存的response数据⭐️
    if (error.message === CACHE_MESSAGE) {
      return Promise.resolve(error.data);
    }
    if (error.message == INTERVAL_MESSAGE) {
     // todo sth
    }
    return Promise.reject(error);
  }
);
// ######################## 拦截器  END  ########################

// 为扩展Axios的Config,重新export get/post方法将参数由 AxiosRequestConfig 修改为 RequestConfig
export default {
  instance,
  get<T = any, R = AxiosResponse<T>, D = any>(url: string, config?: RequestConfig<D>): Promise<R> {
    return instance.get(url, config);
  },
  post<T = any, R = AxiosResponse<T>, D = any>(
    url: string,
    data?: D,
    config?: RequestConfig<D>
  ): Promise<R> {
    return instance.post(url, data, config);
  },
};