Axios的【接口防抖 + 请求失败重试 + 弱网提示】三合一高阶版封装

功能合集:

  1. 重复请求自动取消
  2. 接口防抖节流
  3. 超时 / 服务异常失败重试
  4. 超时重连 + 最大重试次数限制
  5. 全局 Loading + NProgress 进度条
  6. 弱网 / 断网精准提示
  7. Token 自动携带 + 401 登录过期拦截
  8. TS 类型完整约束
  9. 路由跳转清空所有请求
TypeScript 复制代码
import axios, {
  AxiosInstance,
  AxiosRequestConfig,
  AxiosResponse,
  CancelTokenSource
} from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import router from '@/router'

// 进度条关闭转圈
NProgress.configure({ showSpinner: false })

// 全局Loading
let loadingInstance: any = null

// 1. 重复请求队列
const pendingMap = new Map<string, CancelTokenSource>()

// 2. 防抖缓存队列 {url+method: timer}
const debounceMap = new Map<string, NodeJS.Timeout>()
// 防抖间隔 300ms
const DEBOUNCE_DELAY = 300

// 3. 失败重试全局配置
const RETRY_MAX_COUNT = 3       // 最大重试次数
const RETRY_INTERVAL = 1200     // 重试间隔 ms
// 指定需要重试的错误码/状态
const NEED_RETRY_CODE = ['ECONNABORTED', 'timeout']
const NEED_RETRY_STATUS = [500, 502, 503, 504]

// 统一返回泛型
export interface Result<T = any> {
  code: number
  data: T
  msg: string
}

// 创建axios实例
const service: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_URL,
  timeout: 12000,
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  }
})

// 生成请求唯一标识
function getReqKey(config: AxiosRequestConfig) {
  return `${config.method}-${config.url}`
}

// 取消重复请求
function addPending(config: AxiosRequestConfig) {
  removePending(config)
  const source = axios.CancelToken.source()
  config.cancelToken = source.token
  pendingMap.set(getReqKey(config), source)
}
function removePending(config: AxiosRequestConfig) {
  const key = getReqKey(config)
  if (pendingMap.has(key)) {
    pendingMap.get(key)?.cancel('取消重复请求')
    pendingMap.delete(key)
  }
}
// 清空所有请求
export function clearAllPending() {
  pendingMap.forEach(s => s.cancel('路由跳转终止请求'))
  pendingMap.clear()
  debounceMap.clear()
}

// ===================== 请求拦截器 =====================
service.interceptors.request.use(
  (config) => {
    NProgress.start()
    // 局部关闭loading
    if (!(config as any).hideLoading) {
      loadingInstance = ElLoading.service({
        text: '加载中...',
        background: 'rgba(0,0,0,0.05)'
      })
    }
    // 重复请求取消
    addPending(config)
    // 携带Token
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    // 初始化重试次数
    if (!(config as any).retryNum) (config as any).retryNum = 0
    return config
  },
  err => Promise.reject(err)
)

// ===================== 响应拦截器 =====================
service.interceptors.response.use(
  (res: AxiosResponse<Result>) => {
    NProgress.done()
    loadingInstance?.close()
    removePending(res.config)

    const { code, msg } = res.data
    switch (code) {
      case 200:
        return res.data
      case 401:
        ElMessage.warning('登录身份已失效,请重新登录')
        localStorage.clear()
        router.replace('/login')
        return Promise.reject(msg)
      case 403:
        ElMessage.error('暂无权限访问')
        return Promise.reject(msg)
      default:
        ElMessage.warning(msg || '业务请求异常')
        return Promise.reject(msg)
    }
  },

  // 错误统一处理 + 失败重试 + 弱网提示
  async (error) => {
    NProgress.done()
    loadingInstance?.close()
    error.config && removePending(error.config)
    const config = error.config

    // ========== 弱网 / 断网提示 ==========
    if (!navigator.onLine) {
      ElMessage.error('当前无网络,请检查网络连接')
      return Promise.reject(error)
    }

    const status = error.response?.status
    const errCode = error.code

    // ========== 判断是否需要重试 ==========
    const isNeedRetry =
      NEED_RETRY_CODE.includes(errCode) ||
      NEED_RETRY_STATUS.includes(status)

    // 不需要重试 直接抛出错误
    if (!isNeedRetry) {
      if (error.message === '取消重复请求') {
        console.log('重复请求已取消')
      } else {
        ElMessage.error('服务器繁忙,请稍后再试')
      }
      return Promise.reject(error)
    }

    // 超过最大重试次数
    if ((config as any).retryNum >= RETRY_MAX_COUNT) {
      ElMessage.error('请求多次失败,请稍后重试')
      return Promise.reject(error)
    }

    // 执行重试
    (config as any).retryNum++
    ElMessage.info(`请求异常,正在第${(config as any).retryNum}次重试...`)
    // 延迟重试
    await new Promise(resolve => setTimeout(resolve, RETRY_INTERVAL))
    return service(config)
  }
)

// ===================== 封装请求方法 + 接口防抖 =====================
const http = {
  /**
   * GET 防抖请求
   */
  get<T>(url: string, params?: object, config?: AxiosRequestConfig): Promise<Result<T>> {
    const key = getReqKey({ method: 'get', url })
    return new Promise((resolve, reject) => {
      // 清除上一次定时器
      if (debounceMap.has(key)) {
        clearTimeout(debounceMap.get(key)!)
      }
      // 延迟发起请求
      const timer = setTimeout(() => {
        debounceMap.delete(key)
        service({ method: 'GET', url, params, ...config })
          .then(resolve)
          .catch(reject)
      }, DEBOUNCE_DELAY)
      debounceMap.set(key, timer)
    })
  },

  /**
   * POST JSON
   */
  post<T>(url: string, data?: object, config?: AxiosRequestConfig): Promise<Result<T>> {
    const key = getReqKey({ method: 'post', url })
    return new Promise((resolve, reject) => {
      if (debounceMap.has(key)) clearTimeout(debounceMap.get(key)!)
      const timer = setTimeout(() => {
        debounceMap.delete(key)
        service({ method: 'POST', url, data, ...config })
          .then(resolve)
          .catch(reject)
      }, DEBOUNCE_DELAY)
      debounceMap.set(key, timer)
    })
  },

  /**
   * 表单提交 application/x-www-form-urlencoded
   */
  form<T>(url: string, data?: object): Promise<Result<T>> {
    return service({
      method: 'POST',
      url,
      data,
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
    })
  },

  /**
   * 文件上传(图片/视频)关闭防抖
   */
  upload<T>(url: string, data: FormData, config?: AxiosRequestConfig): Promise<Result<T>> {
    return service({
      method: 'POST',
      url,
      data,
      headers: { 'Content-Type': 'multipart/form-data' },
      ...config
    })
  },

  put<T>(url: string, data?: object, config?: AxiosRequestConfig): Promise<Result<T>> {
    return service({ method: 'PUT', url, data, ...config })
  },

  delete<T>(url: string, params?: object, config?: AxiosRequestConfig): Promise<Result<T>> {
    return service({ method: 'DELETE', url, params, ...config })
  },

  // 原生不防抖请求(紧急接口使用)
  rawGet<T>(url: string, params?: object, config?: AxiosRequestConfig) {
    return service({ method: 'GET', url, params, ...config })
  },
  rawPost<T>(url: string, data?: object, config?: AxiosRequestConfig) {
    return service({ method: 'POST', url, data, ...config })
  }
}

export default http

路由全局清空请求

TypeScript 复制代码
// src/router/index.ts
import { clearAllPending } from '@/utils/request'

router.beforeEach((to, from, next) => {
  clearAllPending()
  next()
})

使用方式

TypeScript 复制代码
<script setup lang="ts">
import http from '@/utils/request'

// 自带防抖 + 失败重试
const getData = async () => {
  const res = await http.get('/api/list', { page: 1 })
  console.log(res.data)
}

// 不需要防抖用原生请求
const quickQuery = async () => {
  const res = await http.rawGet('/api/info')
}

// 上传文件自动关闭防抖
const upload = async (file: FormData) => {
  await http.upload('/api/upload', file)
}
</script>

可自定义修改常量

TypeScript 复制代码
const DEBOUNCE_DELAY = 300     // 防抖毫秒
const RETRY_MAX_COUNT = 3      // 最大重试次数
const RETRY_INTERVAL = 1200    // 重试间隔
const NEED_RETRY_STATUS = [500,502,503,504] // 需要重试状态码

局部关闭 Loading

TypeScript 复制代码
http.get('/api/xxx',{},{ hideLoading:true })

JavaScript版本

javascript 复制代码
import axios from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import router from '@/router'

// 进度条关闭转圈
NProgress.configure({ showSpinner: false })

// 全局Loading
let loadingInstance = null

// 1. 重复请求队列
const pendingMap = new Map()

// 2. 防抖缓存队列 {url+method: timer}
const debounceMap = new Map()
// 防抖间隔 300ms
const DEBOUNCE_DELAY = 300

// 3. 失败重试全局配置
const RETRY_MAX_COUNT = 3       // 最大重试次数
const RETRY_INTERVAL = 1200     // 重试间隔 ms
// 指定需要重试的错误码/状态
const NEED_RETRY_CODE = ['ECONNABORTED', 'timeout']
const NEED_RETRY_STATUS = [500, 502, 503, 504]

// 创建axios实例
const service = axios.create({
  baseURL: import.meta.env.VITE_API_URL || '/api',
  timeout: 12000,
  withCredentials: true,
  headers: {
    'Content-Type': 'application/json;charset=utf-8'
  }
})

// 生成请求唯一标识
function getReqKey(config) {
  const method = (config.method || '').toUpperCase()
  const url = config.url || ''
  return `${method}-${url}`
}

// 取消重复请求
function addPending(config) {
  removePending(config)
  // 使用 axios CancelToken (注意:已被废弃,若要兼容未来建议改用 AbortController)
  const source = axios.CancelToken.source()
  config.cancelToken = source.token
  pendingMap.set(getReqKey(config), source)
}
function removePending(config) {
  const key = getReqKey(config)
  if (pendingMap.has(key)) {
    const source = pendingMap.get(key)
    source && source.cancel && source.cancel('取消重复请求')
    pendingMap.delete(key)
  }
}
// 清空所有请求
export function clearAllPending() {
  pendingMap.forEach(s => s.cancel && s.cancel('路由跳转终止请求'))
  pendingMap.clear()
  debounceMap.clear()
}

// ===================== 请求拦截器 =====================
service.interceptors.request.use(
  (config) => {
    NProgress.start()
    // 局部关闭loading
    if (!config.hideLoading) {
      loadingInstance = ElLoading.service({
        text: '加载中...',
        background: 'rgba(0,0,0,0.05)'
      })
    }
    // 重复请求取消
    addPending(config)
    // 携带Token
    const token = localStorage.getItem('token')
    if (token) {
      config.headers = config.headers || {}
      config.headers.Authorization = `Bearer ${token}`
    }
    // 初始化重试次数
    if (typeof config.__retryNum === 'undefined') config.__retryNum = 0
    return config
  },
  err => Promise.reject(err)
)

// ===================== 响应拦截器 =====================
service.interceptors.response.use(
  (res) => {
    NProgress.done()
    loadingInstance && loadingInstance.close && loadingInstance.close()
    removePending(res.config)

    const data = res.data || {}
    const code = data.code
    const msg = data.msg || data.message

    switch (code) {
      case 200:
        return data
      case 401:
        ElMessage.warning('登录身份已失效,请重新登录')
        localStorage.clear()
        router.replace('/login')
        return Promise.reject(msg)
      case 403:
        ElMessage.error('暂无权限访问')
        return Promise.reject(msg)
      default:
        ElMessage.warning(msg || '业务请求异常')
        return Promise.reject(msg)
    }
  },

  // 错误统一处理 + 失败重试 + 弱网提示
  async (error) => {
    NProgress.done()
    loadingInstance && loadingInstance.close && loadingInstance.close()
    error.config && removePending(error.config)
    const config = error.config || {}

    // ========== 弱网 / 断网提示 ==========
    if (typeof navigator !== 'undefined' && !navigator.onLine) {
      ElMessage.error('当前无网络,请检查网络连接')
      return Promise.reject(error)
    }

    const status = error.response && error.response.status
    const errCode = error.code

    // ========== 判断是否需要重试 ==========
    const isNeedRetry =
      NEED_RETRY_CODE.includes(errCode) ||
      NEED_RETRY_STATUS.includes(status)

    // 不需要重试 直接抛出错误
    if (!isNeedRetry) {
      if (error.message === '取消重复请求') {
        console.log('重复请求已取消')
      } else {
        ElMessage.error('服务器繁忙,请稍后再试')
      }
      return Promise.reject(error)
    }

    // 超过最大重试次数
    if (config.__retryNum >= RETRY_MAX_COUNT) {
      ElMessage.error('请求多次失败,请稍后重试')
      return Promise.reject(error)
    }

    // 执行重试
    config.__retryNum = (config.__retryNum || 0) + 1
    ElMessage.info(`请求异常,正在第${config.__retryNum}次重试...`)
    // 延迟重试
    await new Promise(resolve => setTimeout(resolve, RETRY_INTERVAL))
    return service(config)
  }
)

// ===================== 封装请求方法 + 接口防抖 =====================
const http = {
  /**
   * GET 防抖请求
   */
  get(url, params, config) {
    const key = getReqKey({ method: 'GET', url })
    return new Promise((resolve, reject) => {
      // 清除上一次定时器
      if (debounceMap.has(key)) {
        clearTimeout(debounceMap.get(key))
      }
      // 延迟发起请求
      const timer = setTimeout(() => {
        debounceMap.delete(key)
        service({ method: 'GET', url, params, ...config })
          .then(resolve)
          .catch(reject)
      }, DEBOUNCE_DELAY)
      debounceMap.set(key, timer)
    })
  },

  /**
   * POST JSON
   */
  post(url, data, config) {
    const key = getReqKey({ method: 'POST', url })
    return new Promise((resolve, reject) => {
      if (debounceMap.has(key)) clearTimeout(debounceMap.get(key))
      const timer = setTimeout(() => {
        debounceMap.delete(key)
        service({ method: 'POST', url, data, ...config })
          .then(resolve)
          .catch(reject)
      }, DEBOUNCE_DELAY)
      debounceMap.set(key, timer)
    })
  },

  /**
   * 表单提交 application/x-www-form-urlencoded
   */
  form(url, data) {
    return service({
      method: 'POST',
      url,
      data,
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
    })
  },

  /**
   * 文件上传(图片/视频)关闭防抖
   */
  upload(url, data, config) {
    return service({
      method: 'POST',
      url,
      data,
      headers: { 'Content-Type': 'multipart/form-data' },
      ...config
    })
  },

  put(url, data, config) {
    return service({ method: 'PUT', url, data, ...config })
  },

  delete(url, params, config) {
    return service({ method: 'DELETE', url, params, ...config })
  },

  rawGet(url, params, config) {
    return service({ method: 'GET', url, params, ...config })
  },
  rawPost(url, data, config) {
    return service({ method: 'POST', url, data, ...config })
  }
}

export default http
相关推荐
星栈4 小时前
被Leptos弹窗逼疯后,我搞了一套零Props方案
前端·前端框架·全栈
超绝大帅哥4 小时前
babel降级|>, Object.groupBy
前端·javascript
23朵毒蘑菇4 小时前
前端自定义滚动条新星库出现了,看它亮还是不亮
前端·javascript
子兮曰4 小时前
GEO 生成式引擎优化完全指南:让你的内容成为 AI 的默认答案
前端·后端·seo
吃着火锅x唱着歌4 小时前
深度探索C++对象模型 学习笔记 第五章 构造、解构、拷贝语意学(1)
c++·笔记·学习
Cache技术分享4 小时前
412. Java 文件操作基础 - 用装饰者模式定制 BufferedReader 实现结构化文本读取
前端·后端
w_t_y_y4 小时前
VUE3(一)VUE3语法
前端·javascript·vue.js
清音工具箱4 小时前
《DeployHub 故障排查全记录:从 404 到完整修复的 3 小时》
javascript
builderwfy4 小时前
VUE子页面调用父页面实现方式
前端·javascript·vue.js