Axios 通用请求封装

特性

  • 统一 baseURL、超时、Token 注入
  • 统一业务错误/HTTP 错误处理
  • 支持自动重试(网络错误/超时)
  • 支持取消请求(AbortController
  • 支持文件上传/下载
  • 返回值类型清晰,不再出现“类型和运行时不一致”

代码

src/utils/request.ts
import axios, {
  AxiosError,
  AxiosInstance,
  AxiosRequestConfig,
  InternalAxiosRequestConfig,
} from 'axios'

/** 后端统一响应结构(可按你的项目调整) */
export interface ApiResponse<T = unknown> {
  code: number
  message: string
  data: T
  success?: boolean
}

/** 扩展请求配置 */
export interface RequestConfig extends AxiosRequestConfig {
  /** 是否跳过 Token 注入 */
  ignoreToken?: boolean
  /** 是否打印错误日志 */
  showError?: boolean
  /** 重试次数(仅网络错误/超时) */
  retry?: number
  /** 重试间隔(毫秒) */
  retryDelay?: number
}

/** createRequest 的可配置项 */
export interface RequestOptions {
  baseURL?: string
  timeout?: number
  tokenKey?: string
  tokenPrefix?: string
  successCodes?: number[]
}

type RetryConfig = RequestConfig & { _retryCount?: number }

const DEFAULT_OPTIONS: Required<RequestOptions> = {
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
  timeout: 15000,
  tokenKey: 'token',
  tokenPrefix: 'Bearer',
  successCodes: [0, 200],
}

const HTTP_ERROR_MAP: Record<number, string> = {
  400: '请求参数错误',
  401: '登录状态已失效',
  403: '无权限访问',
  404: '请求资源不存在',
  405: '请求方法不允许',
  408: '请求超时',
  409: '请求冲突',
  422: '参数校验失败',
  429: '请求过于频繁',
  500: '服务器内部错误',
  502: '网关错误',
  503: '服务不可用',
  504: '网关超时',
}

const isDev = import.meta.env.DEV

const sleep = (ms: number): Promise<void> =>
  new Promise((resolve) => setTimeout(resolve, ms))

const isApiResponse = (data: unknown): data is ApiResponse =>
  !!data &&
  typeof data === 'object' &&
  'code' in data &&
  'message' in data

const isTimeoutError = (error: AxiosError): boolean =>
  error.code === 'ECONNABORTED' || /timeout/i.test(error.message || '')

const isNetworkError = (error: AxiosError): boolean => !error.response

const canRetryMethod = (method?: string): boolean => {
  const m = (method || 'get').toLowerCase()
  return m === 'get' || m === 'head' || m === 'options'
}

export const getToken = (tokenKey = DEFAULT_OPTIONS.tokenKey): string | null =>
  localStorage.getItem(tokenKey) || sessionStorage.getItem(tokenKey)

export const setToken = (
  token: string,
  remember = false,
  tokenKey = DEFAULT_OPTIONS.tokenKey,
): void => {
  if (remember) {
    localStorage.setItem(tokenKey, token)
  } else {
    sessionStorage.setItem(tokenKey, token)
  }
}

export const removeToken = (tokenKey = DEFAULT_OPTIONS.tokenKey): void => {
  localStorage.removeItem(tokenKey)
  sessionStorage.removeItem(tokenKey)
}

/**
 * 创建 axios 实例
 */
export const createRequest = (customOptions?: RequestOptions): AxiosInstance => {
  const options = {
    ...DEFAULT_OPTIONS,
    ...customOptions,
  }
  const successCodeSet = new Set(options.successCodes)

  const instance = axios.create({
    baseURL: options.baseURL,
    timeout: options.timeout,
    withCredentials: false,
  })

  // 请求拦截
  instance.interceptors.request.use(
    (config: InternalAxiosRequestConfig & RequestConfig) => {
      if (!config.ignoreToken) {
        const token = getToken(options.tokenKey)
        if (token) {
          config.headers.Authorization = `${options.tokenPrefix} ${token}`
        }
      }

      if (isDev) {
        console.log('[request]', config.method?.toUpperCase(), config.url, {
          params: config.params,
          data: config.data,
        })
      }

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

  // 响应拦截
  instance.interceptors.response.use(
    (response) => {
      // 下载场景:直接返回 Blob / ArrayBuffer
      const responseType = response.config.responseType
      if (responseType === 'blob' || responseType === 'arraybuffer') {
        return response.data
      }

      const data = response.data
      const config = response.config as RequestConfig

      // 非统一业务结构,直接透传
      if (!isApiResponse(data)) {
        return data
      }

      const isSuccess = successCodeSet.has(data.code) || data.success === true
      if (isSuccess) {
        return data
      }

      // 业务失败
      if (data.code === 401) {
        removeToken(options.tokenKey)
        console.warn('[request] 登录状态失效,请重新登录')
      }

      if (config.showError !== false) {
        console.error('[request] 业务错误:', data.message || '请求失败', data)
      }

      const bizError = new Error(data.message || '请求失败') as Error & {
        code?: number
        response?: ApiResponse
      }
      bizError.code = data.code
      bizError.response = data
      return Promise.reject(bizError)
    },
    async (error: AxiosError) => {
      const config = error.config as RetryConfig | undefined

      // 重试:仅网络错误/超时 + 幂等请求
      if (
        config &&
        (isNetworkError(error) || isTimeoutError(error)) &&
        canRetryMethod(config.method)
      ) {
        const maxRetries = config.retry ?? 0
        const currentRetry = config._retryCount ?? 0

        if (currentRetry < maxRetries) {
          config._retryCount = currentRetry + 1
          const retryDelay = config.retryDelay ?? 1000
          if (isDev) {
            console.warn(
              `[request] 第 ${config._retryCount} 次重试,${retryDelay}ms 后继续`,
            )
          }
          await sleep(retryDelay)
          return instance(config)
        }
      }

      const status = error.response?.status
      const message = status
        ? HTTP_ERROR_MAP[status] || `请求失败(${status})`
        : isTimeoutError(error)
          ? '请求超时,请检查网络'
          : '网络异常,请稍后重试'

      const shouldLogError = (config as RequestConfig | undefined)?.showError !== false
      if (shouldLogError) {
        console.error('[request] HTTP错误:', message, error)
      }

      return Promise.reject(error)
    },
  )

  return instance
}

/** 单例实例 */
export const request = createRequest()

/** 通用请求 */
export const http = <T = unknown>(config: RequestConfig): Promise<ApiResponse<T>> =>
  request.request<any, ApiResponse<T>>(config)

/** GET */
export const get = <T = unknown>(
  url: string,
  params?: Record<string, unknown>,
  config?: RequestConfig,
): Promise<ApiResponse<T>> =>
  request.get<any, ApiResponse<T>>(url, { ...config, params })

/** POST */
export const post = <T = unknown>(
  url: string,
  data?: unknown,
  config?: RequestConfig,
): Promise<ApiResponse<T>> =>
  request.post<any, ApiResponse<T>>(url, data, config)

/** PUT */
export const put = <T = unknown>(
  url: string,
  data?: unknown,
  config?: RequestConfig,
): Promise<ApiResponse<T>> =>
  request.put<any, ApiResponse<T>>(url, data, config)

/** DELETE */
export const del = <T = unknown>(
  url: string,
  params?: Record<string, unknown>,
  config?: RequestConfig,
): Promise<ApiResponse<T>> =>
  request.delete<any, ApiResponse<T>>(url, { ...config, params })

/** PATCH */
export const patch = <T = unknown>(
  url: string,
  data?: unknown,
  config?: RequestConfig,
): Promise<ApiResponse<T>> =>
  request.patch<any, ApiResponse<T>>(url, data, config)

/** 上传文件 */
export const upload = <T = unknown>(
  url: string,
  formData: FormData,
  config?: RequestConfig,
): Promise<ApiResponse<T>> => {
  // 不手动写 multipart/form-data,浏览器会自动带 boundary
  return request.post<any, ApiResponse<T>>(url, formData, {
    ...config,
    headers: {
      ...config?.headers,
    },
  })
}

/** 下载文件 */
export const download = (
  url: string,
  params?: Record<string, unknown>,
  config?: RequestConfig,
): Promise<Blob> =>
  request.get<any, Blob>(url, {
    ...config,
    params,
    responseType: 'blob',
  })

/** 创建可取消请求 */
export const createCancellableRequest = <T = unknown>(config: RequestConfig) => {
  const controller = new AbortController()
  const promise = http<T>({
    ...config,
    signal: controller.signal,
  })
  return {
    request: promise,
    cancel: () => controller.abort(),
  }
}

export default request

使用示例

src/api/user.ts
import { get, post, download } from '@/utils/request'

export const fetchUserInfo = async () => {
  const res = await get<{ id: string; name: string }>('/user/info')
  return res.data
}

export const login = async (username: string, password: string) => {
  const res = await post<{ token: string }>('/auth/login', { username, password })
  return res.data
}

export const exportFile = async () => {
  const blob = await download('/report/export', { month: '2026-03' })
  const url = URL.createObjectURL(blob)
  const a = document.createElement('a')
  a.href = url
  a.download = 'report.xlsx'
  a.click()
  URL.revokeObjectURL(url)
}

请求重试示例

说明:

  • 仅网络错误/超时会触发重试
  • 默认只对幂等请求重试(GET/HEAD/OPTIONS
src/api/retry-demo.ts
import { get } from '@/utils/request'

export const fetchOrderList = async () => {
  const res = await get<{ list: Array<{ id: string; amount: number }> }>(
    '/order/list',
    { page: 1, pageSize: 20 },
    {
      retry: 2, // 失败后最多再重试 2 次
      retryDelay: 800, // 每次重试间隔 800ms
    },
  )
  return res.data
}

请求取消示例

src/api/cancel-demo.ts
import { createCancellableRequest } from '@/utils/request'

export const fetchSlowData = () => {
  const { request, cancel } = createCancellableRequest<{ value: string }>({
    url: '/slow-api/data',
    method: 'get',
    params: { q: 'keyword' },
  })

  return { request, cancel }
}
在vue组件里使用
import { onBeforeUnmount } from 'vue'
import { fetchSlowData } from '@/api/cancel-demo'

const { request, cancel } = fetchSlowData()

request
  .then((res) => {
    console.log('请求成功:', res.data)
  })
  .catch((err) => {
    console.log('请求失败或被取消:', err?.message || err)
  })

// 组件销毁时取消请求,避免无效回调
onBeforeUnmount(() => {
  cancel()
})

依赖

npm i axios