企业级 Axios 配置实战:从基础到完整封装

在前端项目中,Axios 几乎是 HTTP 请求的标配。但随着项目规模扩大,简单的 axios.get 已经无法满足需求。本文将从零开始,手把手带你封装一套企业级 Axios 配置,涵盖:实例化、请求/响应拦截、自动重试、取消重复请求、全局 Loading、错误处理细化、开发环境日志等,最后提供可直接使用的完整代码。


1. 创建 Axios 实例与环境变量

企业项目通常有多个环境(开发、测试、生产),使用环境变量管理接口基址是最佳实践。

typescript 复制代码
// request.ts
import axios from 'axios'

const instance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // 从环境变量读取
  timeout: 10000, // 请求超时时间
})

建议将 VITE_API_BASE_URL 配置在 .env 文件中,方便不同环境切换。


2. 请求拦截器:认证、Loading、取消重复请求

请求拦截器在请求发出前执行,我们在这里完成三件事:

  • 携带 Token:从 Store 中取出用户令牌,添加到请求头。
  • 全局 Loading:通过计数器控制全屏 Loading 的显示/隐藏。
  • 取消重复请求:如果短时间内发起相同请求(参数一致),取消之前的请求。

2.1 取消重复请求的实现

利用 axios.CancelTokenMap 存储每个请求的取消函数。请求的"唯一标识"由 methodurlparamsdata 共同生成。

typescript 复制代码
const pendingMap = new Map<string, AbortController>()

function getRequestKey(config: any): string {
  const { method, url, params, data } = config
  return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
}

// 请求拦截器
instance.interceptors.request.use((config) => {
  // 1. 显示 Loading
  showLoading()

  // 2. 生成请求键,取消相同请求
  const key = getRequestKey(config)
  if (pendingMap.has(key)) {
    const controller = pendingMap.get(key)
    controller?.abort() // 取消之前的请求
    pendingMap.delete(key)
  }

  // 3. 使用 AbortController 代替 CancelToken(现代浏览器)
  const controller = new AbortController()
  config.signal = controller.signal
  pendingMap.set(key, controller)

  // 4. 添加 Token
  const token = useUserStore().token
  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }

  return config
}, (error) => {
  return Promise.reject(error)
})

说明 :现代浏览器推荐使用 AbortController 替代已废弃的 CancelToken。这里为了兼容性,也可以继续使用 CancelToken,但 AbortController 是未来趋势。

2.2 全局 Loading 管理

Loading 需要支持多个并发请求,只有所有请求都完成时才关闭。我们使用计数器实现。

typescript 复制代码
let loadingInstance: any
let requestCount = 0

function showLoading() {
  if (requestCount === 0) {
    loadingInstance = ElLoading.service({
      fullscreen: true,
      text: '加载中...',
      background: 'rgba(0,0,0,0.7)',
    })
  }
  requestCount++
}

function hideLoading() {
  requestCount--
  if (requestCount === 0 && loadingInstance) {
    loadingInstance.close()
    loadingInstance = null
  }
}

3. 响应拦截器:业务状态码、HTTP 错误、401 跳转

响应拦截器负责:

  • 关闭 Loading
  • 从 pendingMap 中移除已完成请求
  • 处理业务状态码(如 code !== 0 视为业务失败)
  • 处理 HTTP 状态码(4xx、5xx)
  • 401 未授权时跳转登录页

3.1 成功响应处理

typescript 复制代码
instance.interceptors.response.use(
  (response) => {
    hideLoading()
    const key = getRequestKey(response.config)
    pendingMap.delete(key)

    // 假设后端统一返回格式:{ code: number, message: string, data: any }
    const { code, message, data } = response.data

    if (code === 0) {
      // 业务成功,直接返回 data(调用方无需再取 .data.data)
      return data
    }

    // 业务失败,统一提示
    ElMessage.error(message || '操作失败')
    return Promise.reject(new Error(message || '业务错误'))
  },
  (error) => {
    hideLoading()
    if (error.config) {
      const key = getRequestKey(error.config)
      pendingMap.delete(key)
    }

    // 处理取消请求(不弹错误)
    if (axios.isCancel(error)) {
      console.log('请求取消:', error.message)
      return Promise.reject(error)
    }

    // HTTP 错误细化处理
    if (error.response) {
      const status = error.response.status
      const msg = error.response.data?.message || error.message

      switch (status) {
        case 401:
          // 未授权,跳转登录
          router.push('/login')
          ElMessage.error('登录已过期,请重新登录')
          break
        case 403:
          ElMessage.error('没有权限访问')
          break
        case 404:
          ElMessage.error('请求资源不存在')
          break
        case 500:
        case 502:
        case 503:
        case 504:
          ElMessage.error('服务器异常,请稍后重试')
          break
        default:
          ElMessage.error(msg || '请求失败')
      }
    } else if (error.code === 'ECONNABORTED') {
      ElMessage.error('请求超时,请检查网络')
    } else if (error.message.includes('Network Error')) {
      ElMessage.error('网络异常,请检查网络连接')
    } else {
      ElMessage.error(error.message || '未知错误')
    }

    return Promise.reject(error)
  }
)

4. 自动重试机制

网络抖动或临时故障时,自动重试能极大提升用户体验。使用 axios-retry 插件。

bash 复制代码
npm install axios-retry

配置示例:

typescript 复制代码
import axiosRetry from 'axios-retry'

axiosRetry(instance, {
  retries: 3, // 最大重试次数
  retryDelay: (retryCount) => {
    console.log(`第 ${retryCount} 次重试...`)
    if (retryCount === 1) {
      ElMessage.warning('网络不稳定,正在尝试重连')
    }
    return retryCount * 1000 // 递增延迟
  },
  retryCondition: (error) => {
    // 只有网络错误或 5xx 错误才重试
    return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
           (error.response?.status ?? 0) >= 500
  }
})

5. 开发环境日志

在开发环境下打印请求和响应信息,便于调试。

typescript 复制代码
if (import.meta.env.DEV) {
  instance.interceptors.request.use((config) => {
    console.log(`[Request] ${config.method?.toUpperCase()} ${config.url}`, config)
    return config
  })
  instance.interceptors.response.use(
    (response) => {
      console.log(`[Response] ${response.config.method?.toUpperCase()} ${response.config.url}`, response)
      return response
    },
    (error) => {
      console.error('[Response Error]', error)
      return Promise.reject(error)
    }
  )
}

6. 文件上传进度支持

对于大文件上传,通常需要显示上传进度。Axios 原生支持 onUploadProgress 回调。

typescript 复制代码
function uploadFile(file: File, onProgress?: (percent: number) => void) {
  const formData = new FormData()
  formData.append('file', file)

  return instance.post('/upload', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
    onUploadProgress: (progressEvent) => {
      if (progressEvent.total) {
        const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total)
        onProgress?.(percent)
      }
    },
    // 如果需要取消上传,可以传入 signal 或 cancelToken
  })
}

7. 完整配置示例

下面是一个集成了上述所有功能的完整 request.ts 文件,可以直接复制到项目中使用(记得安装依赖:axiosaxios-retryelement-plus 并正确导入路由和 store)。

typescript 复制代码
// request.ts
import axios from 'axios'
import axiosRetry from 'axios-retry'
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios'
import router from '@/router'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElLoading } from 'element-plus'

// ---------- 类型扩展 ----------
declare module 'axios' {
  export interface InternalAxiosRequestConfig {
    _retry?: boolean // 用于重试标记
  }
}

// ---------- Loading 管理器 ----------
let loadingInstance: ReturnType<typeof ElLoading.service> | null = null
let requestCount = 0

function showLoading() {
  if (requestCount === 0) {
    loadingInstance = ElLoading.service({
      fullscreen: true,
      text: '加载中...',
      background: 'rgba(0, 0, 0, 0.7)'
    })
  }
  requestCount++
}

function hideLoading() {
  requestCount--
  if (requestCount === 0 && loadingInstance) {
    loadingInstance.close()
    loadingInstance = null
  }
}

// ---------- 取消重复请求 ----------
const pendingMap = new Map<string, AbortController>()

function getRequestKey(config: InternalAxiosRequestConfig): string {
  const { method, url, params, data } = config
  return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
}

// ---------- 创建实例 ----------
const instance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000
})

// ---------- 自动重试 ----------
axiosRetry(instance, {
  retries: 3,
  retryDelay: (retryCount) => {
    console.log(`网络错误,正在重试... 第${retryCount}次`)
    if (retryCount === 1) {
      ElMessage.warning('网络不稳定,正在尝试重连')
    }
    return retryCount * 1000
  },
  retryCondition: (error) => {
    return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
           (error.response?.status ?? 0) >= 500
  }
})

// ---------- 请求拦截器 ----------
instance.interceptors.request.use(
  (config) => {
    // 1. 显示 Loading
    showLoading()

    // 2. 取消重复请求
    const key = getRequestKey(config)
    if (pendingMap.has(key)) {
      const controller = pendingMap.get(key)
      controller?.abort()
      pendingMap.delete(key)
    }
    const controller = new AbortController()
    config.signal = controller.signal
    pendingMap.set(key, controller)

    // 3. 添加 Token
    const token = useUserStore().token
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }

    return config
  },
  (error) => {
    return Promise.reject(error)
  }
)

// ---------- 响应拦截器 ----------
instance.interceptors.response.use(
  (response: AxiosResponse) => {
    hideLoading()
    const key = getRequestKey(response.config)
    pendingMap.delete(key)

    // 假设后端返回格式:{ code: number, message: string, data: any }
    const { code, message, data } = response.data

    if (code === 0) {
      // 业务成功,直接返回 data
      return data
    }

    // 业务失败
    ElMessage.error(message || '操作失败')
    return Promise.reject(new Error(message || '业务错误'))
  },
  (error) => {
    hideLoading()
    if (error.config) {
      const key = getRequestKey(error.config)
      pendingMap.delete(key)
    }

    // 取消请求不报错
    if (axios.isCancel(error)) {
      console.log('请求取消:', error.message)
      return Promise.reject(error)
    }

    // HTTP 错误细化
    if (error.response) {
      const status = error.response.status
      const msg = error.response.data?.message || error.message

      switch (status) {
        case 401:
          router.push('/login')
          ElMessage.error('登录已过期,请重新登录')
          break
        case 403:
          ElMessage.error('没有权限访问')
          break
        case 404:
          ElMessage.error('请求资源不存在')
          break
        case 500:
        case 502:
        case 503:
        case 504:
          ElMessage.error('服务器异常,请稍后重试')
          break
        default:
          ElMessage.error(msg || '请求失败')
      }
    } else if (error.code === 'ECONNABORTED') {
      ElMessage.error('请求超时,请检查网络')
    } else if (error.message.includes('Network Error')) {
      ElMessage.error('网络异常,请检查网络连接')
    } else {
      ElMessage.error(error.message || '未知错误')
    }

    return Promise.reject(error)
  }
)

// ---------- 开发环境日志 ----------
if (import.meta.env.DEV) {
  instance.interceptors.request.use((config) => {
    console.log(`[Request] ${config.method?.toUpperCase()} ${config.url}`, config)
    return config
  })
  instance.interceptors.response.use(
    (response) => {
      console.log(`[Response] ${response.config.method?.toUpperCase()} ${response.config.url}`, response)
      return response
    },
    (error) => {
      console.error('[Response Error]', error)
      return Promise.reject(error)
    }
  )
}

export default instance

8. 使用示例

typescript 复制代码
import request from './request'

// GET 请求
const getUserInfo = (id: number) => {
  return request.get('/user/info', { params: { id } })
}

// POST 请求
const login = (data: { username: string; password: string }) => {
  return request.post('/auth/login', data)
}

// 文件上传(带进度)
const uploadAvatar = (file: File, onProgress: (p: number) => void) => {
  const formData = new FormData()
  formData.append('file', file)
  return request.post('/upload/avatar', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
    onUploadProgress: (e) => {
      if (e.total) {
        const percent = Math.round((e.loaded * 100) / e.total)
        onProgress(percent)
      }
    }
  })
}

总结

本文从零开始构建了一套企业级 Axios 配置,涵盖了:

  • 环境变量与实例化
  • 请求拦截器(Token、Loading、取消重复请求)
  • 响应拦截器(业务码处理、HTTP 错误、401 跳转)
  • 自动重试机制
  • 开发环境日志
  • 文件上传进度支持

这套配置能够满足绝大多数中大型项目的需求,具有良好的健壮性和可维护性。你可以根据项目具体情况调整细节,如业务状态码字段、Loading 样式等。希望本文能帮助你写出更专业的 Axios 封装!

相关推荐
低调小一2 小时前
OpenClaw 模型配置与火山 Coding Plan 支持清单(实践笔记)
java·前端·笔记·openclaw
毛骗导演2 小时前
万字解析 OpenClaw 源码架构-消息渠道集成简介
前端·架构
kyriewen2 小时前
别再直接 git push 了!这个"魔法"参数让你的代码质量翻倍
前端·git·命令行
1024小神2 小时前
uniapp中用vue3自己写一个验证码输入框,自动获取焦点和自动切到下一个焦点
前端
www_stdio2 小时前
手搓一个 Mini React:从 JSX 到虚拟 DOM 的完整实现
前端·react.js·面试
weixin_395448912 小时前
main.c_raw_0311_lyp
前端·网络·算法
毛骗导演2 小时前
万字解析 OpenClaw 源码架构-插件开发指南
前端·架构
毛骗导演2 小时前
万字解析 OpenClaw 源码架构-插件开发示例
前端