用在后台管理系统的一个公共请求组件,解决重复换取token,过滤重复请求,切换组件取消请求队列

后台管理系统的技术栈:vite、vue3、pinia、vue-router、naive-ui。
用在系统中的请求组件,用到了 axios,lodash,pinia 这几个库,解决了几个开发中的痛点:
1.token 过期需要更新 token,当token正在换取时,并发请求过多导致重复换取新 token 问题
2.重复请求过滤
3.切换组件时取消还未完成的请求

直接上代码

import axios from 'axios';
import _ from 'lodash';
// 使用pinia管理状态
import pinia from './pinia';
// 公共loading
import { useLoadingStore } from '../store/loading';
// 用户token
import { useUserStore } from '../store/user';
// 请求队列
import { useRequestStore } from '../store/request';

const userStore = useUserStore(pinia),
  requestStore = useRequestStore(pinia),
  loadingStore = useLoadingStore(pinia);

// 接口地址
const apiHost = 'https://xxx.com';
// 请求根目录,使用了环境变量配置文件,打生产包时会使用.env.production文件中的VITE_API_BASEURL地址
let baseURL = import.meta.env.MODE === 'production' ? import.meta.env.VITE_API_BASEURL : apiHost;

// 初始化axios实例
let http = axios.create({
  baseURL,
  timeout: 60000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8;'
  }
});

// 并发请求数量,处理多个并发请求时,多次调用loading问题
let requestCount = 0;
// loading操作
const ShowLoading = () => {
  if (requestCount === 0) {
    $uiMsg.loading('加载中');
  }
  requestCount++;
};
const HideLoading = () => {
  requestCount--;
  if (requestCount <= 0) {
    requestCount = 0;
    $uiMsg.removeMessage();
  }
};

// 根据url和参数生成标识key
const GenerateKey = config => {
  let { method, url, params, data } = config;
  return [method, url, params, typeof data === 'object' ? JSON.stringify(data) : data].join('&');
};
// 阻止重复请求
const StopRepeatRequest = (config, cancel, errorMessage) => {
  const errorMsg = errorMessage || '';
  for (let i = 0; i < requestStore.list.length; i++) {
    if (requestStore.list[i].key === GenerateKey(config)) {
      cancel(errorMsg);
      return;
    }
  }
  requestStore.Add({
    key: GenerateKey(config),
    cancel
  });
};
// 请求完成后删除重复队列
const DelRequest = config => {
  for (let i = 0; i < requestStore.list.length; i++) {
    if (requestStore.list[i].key === GenerateKey(config)) {
      requestStore.Del(i);
      break;
    }
  }
};

http.interceptors.request.use(config => {
  let cancel;
  config.cancelToken = new axios.CancelToken(c => {
    cancel = c;
  });
  StopRepeatRequest(config, cancel, `${config.url} 请求被中断`);
  if (userStore.token) {
    config.headers.Authorization = 'Bearer ' + userStore.token;
  }
  return config;
});

// 处理token过期时重复换取token问题
let isRefreshToken = false,
  retryRequest = [];
http.interceptors.response.use(
  response => {
    loadingStore.Hide();
    DelRequest(response.config);
    if (response.config.url.indexOf('/token') !== -1 && response.data.code !== 200) {
      // 换token时报错,退出登录
      $uiMsg.error('登录已过期,请重新登录', () => {
        userStore.LoginOut();
      });
    }
    return response.data;
  },
  error => {
    loadingStore.Hide();
    HideLoading();
    if (error.response && error.response.status === 401) {
      // token已过期,开始换取新token
      const config = error.response.config;
      // 删除队列
      DelRequest(config);
      if (!isRefreshToken) {
        // 处于未换取token状态
        isRefreshToken = true;
        return http
          .post('/token', userStore.token)
          .then(res => {
            if (!res.body) return;
            userStore.LoginIn(res.body);
            config.headers['Authorization'] = 'Bearer ' + res.body.token;
            // 已经刷新token,执行等候队列中的请求
            retryRequest.forEach(async cb => await cb('Bearer ' + res.body.token));
            // 执行完清空队列
            retryRequest = [];
            isRefreshToken = false;
            return http(config);
          })
          .catch(() => {
            isRefreshToken = false;
          })
          .finally(() => {
            isRefreshToken = false;
          });
      } else {
        // 正在刷新token,返回一个未执行resolve的promise
        return new Promise(resolve => {
          // 将resolve放进队列,用一个函数形式来保存,等token刷新后直接执行
          retryRequest.push(token => {
            config.headers['Authorization'] = token;
            return resolve(http(config));
          });
        });
      }
    } else {
      if (error.code !== 'ERR_CANCELED') {
        // 错误拦截,排除自行取消接口的错误类型
        $uiMsg.error(`网络错误,请稍后再试,错误代码:${error.response && error.response.status ? error.response.status : ''}`);
      }
      return Promise.reject(error);
    }
  }
);

/*  封装get请求
    url:请求地址
    params:请求参数
    config:请求配置(请求开始时是否展示loading,请求完成后是否隐藏loading,请求错误时是否显示错误信息)
*/
const get = (url, params, config = { showLoading: true, hideLoading: true, showError: true }) => {
  if (config.showLoading) {
    ShowLoading();
    loadingStore.Show();
  }
  return new Promise((resolve, reject) => {
    // 当请求参数为对象时,过滤掉值为null的参数
    let _params = Object.prototype.toString.call(params) === '[object Object]' ? _.pickBy(params, item => !_.isNil(item)) : params;
    http
      .get(url, _params)
      .then(res => {
        (config.hideLoading || config.hideLoading === undefined) && HideLoading();
        if (res?.code === 200 || url.indexOf('/api/oss/sign') !== -1) {
          resolve(res);
        } else {
          if ((config.showError || config.showError === undefined) && res.msg) {
            $uiMsg.error(res.msg, () => {
              reject(res);
            });
          } else {
            reject(res);
          }
        }
      })
      .catch(error => {
        if (error.code !== 'ERR_CANCELED') {
          reject(error);
        }
      });
  });
};

// 封装post请求,参数同get,filterNull:是否需要过滤掉值为null的参数
const post = (url, params, config = { showLoading: true, hideLoading: true, showError: true, filterNull: true }) => {
  if (config.showLoading) {
    ShowLoading();
    loadingStore.Show();
  }
  let _filterNull = config.filterNull === undefined ? true : config.filterNull;
  return new Promise((resolve, reject) => {
    let _params = params;
    if (_filterNull) {
      _params = Object.prototype.toString.call(params) === '[object Object]' ? _.pickBy(params, item => !_.isNil(item)) : params;
    }
    http
      .post(url, _params)
      .then(res => {
        (config.hideLoading || config.hideLoading === undefined) && HideLoading();
        if (res?.code === 200) {
          resolve(res);
        } else {
          if ((config.showError || config.showError === undefined) && res.msg) {
            $uiMsg.error(res.msg, () => {
              reject(res);
            });
          } else {
            reject(res);
          }
        }
      })
      .catch(error => {
        if (error.code !== 'ERR_CANCELED') {
          reject(error);
        }
      });
  });
};

export { get, post };

补上另外几个组件的代码

pinia.js

import { createPinia } from 'pinia';

const pinia = createPinia();

export default pinia;

loading.js 公共loading状态store

import { defineStore } from 'pinia';

export const useLoadingStore = defineStore('loadingStore', {
  state: () => ({
    isLoading: false
  }),
  actions: {
    Show() {
      this.isLoading = true;
    },
    Hide() {
      this.isLoading = false;
    }
  }
});

user.js 用户信息store

import { defineStore } from 'pinia';

export const useUserStore = defineStore('user', {
  state: () => ({
    userInfo: JSON.parse(localStorage.getItem('userInfo')),
    token: JSON.parse(localStorage.getItem('token'))
  }),
  actions: {
    LoginIn(data) {
      this.token = data.token;
      this.userInfo = data;
      localStorage.setItem('userInfo', JSON.stringify(data));
      localStorage.setItem('token', JSON.stringify(data.token));
      localStorage.setItem('expire', data.expire);
    },
    LoginOut() {
      localStorage.removeItem('userInfo');
      localStorage.removeItem('token');
      localStorage.removeItem('expire');
      location.href = '/';
    }
  }
});

request.js 请求队列store

import { defineStore } from 'pinia';

export const useRequestStore = defineStore('requestStore', {
  state: () => ({
    list: []
  }),
  actions: {
    Add(data) {
      this.list.push(data);
    },
    Del(index) {
      this.list.splice(index, 1);
    }
  }
});

router.js

import { useRequestStore } from '../store/request';
import pinia from './pinia';

const requestStore = useRequestStore(pinia);

router.beforeEach((to, from, next) => {
  // 切换router时,取消pending中的请求
  if (requestStore.list.length) {
    requestStore.list.forEach((item, index) => {
      item.cancel();
      requestStore.Del(index);
    });
  }
})
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容