人人都会AI编程

14.1 Axios 深度封装

更新时间:2026-07-11

在真实项目中,直接使用 Axios 的 getpost 等方法会带来大量重复代码:每个请求都要拼接完整 URL、手动加 token、判断响应状态码、处理错误提示等。深度封装的核心目标是把通用逻辑收敛到一个地方,让业务代码只关心“调哪个接口、传什么参数、拿到什么数据”

以下封装基于 axios@1.x,TypeScript 环境,涵盖了日常开发中 90% 以上的需求场景。

基础底座:创建实例与全局配置

任何封装都从创建一个独立实例开始,这样不会污染全局默认配置,也方便多服务(如后端 API、文件上传服务)共存。

// request.ts
import axios, { AxiosRequestConfig, InternalAxiosRequestConfig, AxiosResponse } from 'axios'

const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASEURL, // 环境变量注入
  timeout: 15000,
  // 允许携带 cookie(跨域需要服务端配合 Access-Control-Allow-Credentials)
  withCredentials: true,
})

从这里开始,后续所有增强功能都通过拦截器、适配器或包装函数挂载到这个 http 实例上。

请求拦截器:统一注入 token 与参数处理

请求拦截器可以在请求发出前做三件事:添加认证信息、转换参数格式、记录请求开始时间(用于统计耗时)。

http.interceptors.request.use(
  (config: InternalAxiosRequestConfig) => {
    // 添加 token
    const token = getCookie('token') // 或从 pinia store 获取
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }

    // GET 请求时间戳防缓存(按需)
    if (config.method === 'get') {
      config.params = {
        ...config.params,
        _t: Date.now(),
      }
    }

    // 记录开始时间,用于响应拦截器计算耗时
    config.metadata = { startTime: Date.now() }

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

响应拦截器:统一错误处理与业务状态码判断

响应拦截器是封装的心脏,负责:剥离响应的外层包裹、判断业务状态码、统一弹出错误提示、处理 HTTP 异常(网络断开、超时、500 等)。

// 约定服务端响应结构
interface ApiResponse<T = any> {
  code: number       // 0 表示成功
  data: T
  message: string
}

http.interceptors.response.use(
  (response: AxiosResponse<ApiResponse>) => {
    const { data, config } = response
    // 计算请求耗时
    if (config.metadata?.startTime) {
      const duration = Date.now() - config.metadata.startTime
      console.log(`${config.url} 耗时 ${duration}ms`)
    }

    // 业务状态码非成功,视为业务失败
    if (data.code !== 0) {
      // 统一错误提示(按需,某些场景可能需静默失败)
      ElMessage.error(data.message || '请求失败')
      // 上报错误日志
      errorLog.report({ url: config.url, message: data.message })
      return Promise.reject(new Error(data.message))
    }

    // 成功时直接返回 data.data,业务层不用再解包
    return data.data as any
  },
  (error) => {
    // 处理 HTTP 错误
    if (error.response) {
      const status = error.response.status
      const strategies: Record<number, () => void> = {
        401: () => {
          // token 过期或未登录,跳转登录页
          ElMessage.error('登录已过期,请重新登录')
          router.push('/login')
        },
        403: () => ElMessage.error('没有权限访问'),
        404: () => ElMessage.error('请求的资源不存在'),
        500: () => ElMessage.error('服务器错误,请稍后重试'),
      }
      strategies[status]?.()
      // 也可弹出后台返回的 message
      const msg = error.response.data?.message
      if (msg) ElMessage.error(msg)
    } else if (error.code === 'ECONNABORTED') {
      ElMessage.error('请求超时,请检查网络')
    } else {
      ElMessage.error('网络异常,请检查连接')
    }
    return Promise.reject(error)
  }
)

细节要点

  • 在成功回调中直接 return data.data,让接口调用方拿到的是纯业务数据,而不是 Axios 的响应对象。
  • HTTP 错误一般不要阻止 Promise.reject,让业务层仍可通过 try/catch 捕获。
  • 401 处理常结合 refresh token 刷新逻辑,此处示例为简单跳转。

请求取消与重复请求拦截

场景:用户快速点击保存按钮,短时间内发出多次相同请求,或切换页面时终止未完成请求。封装一种“自动取消前一次相同请求”的机制非常实用。

// 存储每个请求的标识和取消函数
const pendingRequests = new Map<string, AbortController>()

function addPending(config: InternalAxiosRequestConfig) {
  // 以 url + method + 参数生成唯一标识
  const key = `${config.method}_${config.url}_${JSON.stringify(config.params || config.data)}`
  if (pendingRequests.has(key)) {
    // 存在相同请求,取消之前的
    pendingRequests.get(key)!.abort()
  }
  const controller = new AbortController()
  config.signal = controller.signal
  pendingRequests.set(key, controller)
}

function removePending(config: InternalAxiosRequestConfig) {
  const key = `${config.method}_${config.url}_${JSON.stringify(config.params || config.data)}`
  pendingRequests.delete(key)
}

// 在请求拦截器中调用 addPending
http.interceptors.request.use((config) => {
  addPending(config)
  return config
})

// 在响应拦截器中调用 removePending(无论成功失败都要删除)
http.interceptors.response.use(
  (response) => {
    removePending(response.config)
    return response
  },
  (error) => {
    // 如果是取消请求导致的错误,不做提示
    if (axios.isCancel(error)) {
      console.log('请求已取消:', error.message)
      return Promise.reject(new Error('请求已取消'))
    }
    removePending(error.config)
    return Promise.reject(error)
  }
)

关键点

  • 使用 Axios 0.22+ 内置的 AbortController(也可用旧版 CancelToken)。
  • 生成 key 时注意区分请求方式、URL 以及参数,避免误伤不同请求。
  • 取消后的错误需单独处理,避免弹出“网络错误”提示。

失败自动重试

网络抖动引起的偶发失败,可通过重试提高成功率。在响应拦截器的 onRejected 中增加重试逻辑。

// 在错误拦截器处理之前,增加重试计数器
http.interceptors.response.use(
  (response) => response,
  async (error) => {
    const config = error.config
    // 已配置重试次数,且未超过最大次数
    if (config && config.retryCount < config.retryLimit) {
      config.retryCount = (config.retryCount || 0) + 1
      // 延迟重试(指数退避)
      await new Promise((resolve) => setTimeout(resolve, 1000 * config.retryCount))
      return http(config)
    }
    return Promise.reject(error)
  }
)

// 类型扩展
declare module 'axios' {
  interface AxiosRequestConfig {
    retryLimit?: number
    retryCount?: number
  }
}

调用时指定 retryLimit

http.get('/api/data', { retryLimit: 2 })

注意事项

  • 仅对网络超时或 5xx 错误重试,业务错误(code !== 0)通常不重试。
  • 重试可能带来幂等问题,需根据接口特性选择使用。

接口防抖、节流与竞态问题

这三个问题虽然相似,但解决场景不同:

  • 防抖:避免短时间多次触发同一请求(如搜索框输入),只发最后一次。
  • 节流:限制请求频率(如下拉加载更多),保证一定间隔只发一次。
  • 竞态:多个请求并发,结果返回顺序不一致导致页面状态错误(如先发后至覆盖了后发先至的结果)。

通常,防抖和节流更适合在业务组件中通过自定义 Hook 处理,而非全局封装,因为它们与具体交互上下文相关。但在 Axios 层面,可以提供一个通用的“防止重复请求”方案作为辅助,而处理竞态则有两种策略:

  1. 使用请求标识取消前一个同类请求(即上述的重复请求拦截)。这是最彻底的方案,适合“搜索结果覆盖”这类场景:输入 ab 时发起请求 A,立刻输入 abc 时取消 A 并发起 B。
  2. 在业务层利用 watchEffect 或请求标记。比如给每个请求带上自增序号,响应时判断序号是否是最新的,非最新则丢弃。这需业务层配合,Axios 封装可提供一种“带版本号”的请求包装函数。

下面提供一个轻量的防抖请求包装函数,供业务侧使用:

export function debounceRequest<T>(
  fn: (...args: any[]) => Promise<T>,
  delay = 300
) {
  let timer: ReturnType<typeof setTimeout>
  let controller: AbortController

  return function (...args: any[]) {
    // 取消上次挂起的请求
    controller?.abort()
    controller = new AbortController()

    clearTimeout(timer)
    return new Promise<T>((resolve, reject) => {
      timer = setTimeout(() => {
        fn.apply(this, [...args, { signal: controller.signal }])
          .then(resolve)
          .catch(reject)
      }, delay)
    })
  }
}

使用:

const searchUser = debounceRequest((keyword: string, config?: AxiosRequestConfig) => {
  return http.get('/api/users', { params: { q: keyword }, ...config })
})

完整封装导出示例

最后,将常用 HTTP 方法封装成简洁的 API 对象,业务代码引入时只看到几个函数。

export const api = {
  get<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
    return http.get(url, { params, ...config })
  },
  post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    return http.post(url, data, config)
  },
  put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    return http.put(url, data, config)
  },
  delete<T = any>(url: string, params?: any, config?: AxiosRequestConfig): Promise<T> {
    return http.delete(url, { params, ...config })
  },
  upload<T = any>(url: string, formData: FormData, config?: AxiosRequestConfig): Promise<T> {
    return http.post(url, formData, {
      headers: { 'Content-Type': 'multipart/form-data' },
      timeout: 60000,
      ...config,
    })
  }
}

最后几点实践建议

  • 不要过度封装:不是所有接口都要走同一个实例,比如上报日志的请求往往需要极低优先级、不触发全局 loading。
  • 结合业务约定:响应结构(code/message/data)不同项目不一样,封装时要与后端约定对齐。
  • 可测试性:封装后的实例可被 mock,单元测试时替换适配器即可,避免发真实请求。
  • TypeScript 类型提示:封装时尽量保留泛型,让调用处能推断返回数据类型,减少手动标注。

这样一个封装,足以覆盖从中小型项目到大型应用的日常需求,并且每个模块都可以按需裁剪或增强。