功能合集:
- 重复请求自动取消
- 接口防抖节流
- 超时 / 服务异常失败重试
- 超时重连 + 最大重试次数限制
- 全局 Loading + NProgress 进度条
- 弱网 / 断网精准提示
- Token 自动携带 + 401 登录过期拦截
- TS 类型完整约束
- 路由跳转清空所有请求
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