vueapi接口封装 - 完整解决方案与实战教程

在 Vue 项目从单体应用向中大型 SPA 演进的过程中,几乎所有团队都会遇到同一个问题:接口调用散落在各个组件中,导致重复代码泛滥、错误处理不统一、Token 刷新逻辑难以维护、接口类型定义缺失,最终形成“改一个接口要翻十个文件”的技术债。很多开发者以为封装一个 axios 实例就算完成了,但在真实业务里,真正的痛点往往出现在并发请求、取消重复请求、Loading 状态管理、以及响应拦截器里的异常兜底上。

问题现象

先看几个典型的“翻车现场”,如果你的项目命中了两条以上,说明接口层已经到了必须重构的阶段:

原因分析

这些现象背后其实只有三个根因:

  1. 缺少统一的服务层抽象。组件承担了本不该它负责的 HTTP 细节,视图逻辑和网络逻辑耦合在一起。
  2. 拦截器职责设计混乱。很多项目把 Token 注入、错误提示、Loading、重定向全部塞进一个响应拦截器,导致逻辑互相干扰,异常无法精准捕获。
  3. 没有请求取消与并发控制机制。axios 本身支持 AbortController,但大多数封装没有暴露取消能力,重复请求只能靠业务层“自觉”。

解决方案(附完整代码)

下面给出一套经过生产验证的 Vue3 + TypeScript + axios 分层封装方案,核心思路是:请求实例层 → 拦截器层 → API 模块层 → 组合式函数层,每一层只做一件事。

1. 请求实例与拦截器封装

// src/utils/request.ts
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
import { Message } from 'element-plus'

// 统一响应结构,后端约定必须返回该格式
export interface ApiResult {
  code: number
  message: string
  data: T
}

// 扩展请求配置,支持自定义是否显示错误提示、是否携带 Token
export interface RequestConfig extends AxiosRequestConfig {
  showError?: boolean
  withToken?: boolean
}

const service: AxiosInstance = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 15000,
  headers: { 'Content-Type': 'application/json' }
})

// 请求拦截器:只负责注入 Token 和取消重复请求
const pendingMap = new Map()

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

service.interceptors.request.use((config: RequestConfig) => {
  // 1. 取消重复请求:相同 key 的请求先 abort 再发新的
  const key = getRequestKey(config)
  if (pendingMap.has(key)) {
    pendingMap.get(key)!.abort()
    pendingMap.delete(key)
  }
  const controller = new AbortController()
  config.signal = controller.signal
  pendingMap.set(key, controller)

  // 2. 注入 Token
  if (config.withToken !== false) {
    const token = localStorage.getItem('token')
    if (token) config.headers!.Authorization = `Bearer ${token}`
  }
  return config
}, (error) => Promise.reject(error))

// 响应拦截器:只负责解构数据与统一错误处理
service.interceptors.response.use(
  (response: AxiosResponse<ApiResult>) => {
    const key = getRequestKey(response.config)
    pendingMap.delete(key)

    const res = response.data
    // 业务码非 0 视为业务异常,抛出以便组件层 catch
    if (res.code !== 0) {
      if (response.config.showError !== false) {
        Message.error(res.message || '请求失败')
      }
      // 401 单独处理:清理登录态并跳转
      if (res.code === 401) {
        localStorage.removeItem('token')
        window.location.href = '/login'
      }
      return Promise.reject(new Error(res.message))
    }
    // 成功时直接返回 data,组件无需再 .data.data
    return res.data
  },
  (error) => {
    // 主动取消的请求不提示错误
    if (axios.isCancel(error)) return Promise.reject(error)
    const msg = error.response?.status === 500 ? '服务器异常' : error.message
    Message.error(msg)
    return Promise.reject(error)
  }
)

// 对外暴露泛型请求方法,保证返回值类型可推导
export function request<T = any>(config: RequestConfig): Promise<T> {
  return service.request<any, T>(config)
}

export default service

2. API 模块层:按业务域组织

// src/api/user.ts
import { request } from '@/utils/request'

export interface UserInfo {
  id: number
  name: string
  avatar: string
}

// 每个接口只声明“入参”和“出参”,不掺杂任何 UI 逻辑
export const getUserInfo = (id: number) =>
  request<UserInfo>({ url: `/user/${id}`, method: 'get' })

export const updateUser = (data: Partial<UserInfo>) =>
  request<void>({ url: '/user', method: 'put', data })

// 搜索场景:显式关闭错误提示,避免输入过程中频繁弹窗
export const searchUser = (keyword: string) =>
  request<UserInfo[]>({
    url: '/user/search',
    method: 'get',
    params: { keyword },
    showError: false
  })

3. 组合式函数层:处理 Loading 与生命周期

// src/composables/useRequest.ts
import { ref, Ref } from 'vue'

// 通用请求 Hook,自动管理 loading 与错误
export function useRequest<T, P extends any[]>(
  api: (...args: P) => Promise<T>
): {
  data: Ref<T | undefined>
  loading: Ref<boolean>
  run: (...args: P) => Promise<T | undefined>
} {
  const data = ref<T>()
  const loading = ref(false)

  const run = async (...args: P) => {
    loading.value = true
    try {
      const res = await api(...args)
      data.value = res
      return res
    } catch (e) {
      // 错误已在拦截器统一提示,这里只做业务兜底
      return undefined
    } finally {
      loading.value = false
    }
  }

  return { data, loading, run }
}

4. 组件中使用

<script setup lang="ts">
import { onMounted } from 'vue'
import { getUserInfo } from '@/api/user'
import { useRequest } from '@/composables/useRequest'

const { data: user, loading, run } = useRequest(getUserInfo)

onMounted(() => run(1))
</script>

<template>
  <div v-loading="loading">
    <span>{{ user?.name }}</span>
  </div>
</template>

5. 关键排查与落地步骤

  1. 确认后端响应结构是否统一,若存在多种格式,先在响应拦截器里做归一化,不要污染业务层。
  2. 检查 AbortController 是否被正确传递,重复请求取消依赖 config.signal,不要手动覆盖。
  3. Token 刷新场景下,需在 401 处理里加锁,避免多个请求同时刷新 Token 造成竞态。
  4. 对于文件上传、下载等特殊接口,单独创建实例,不要复用 JSON 拦截器。
  5. 类型定义建议由 OpenAPI 自动生成,避免手写 interface 与后端脱节。

这套分层方案的核心价值在于:组件只关心数据和 UI,API 层只关心契约,拦截器只关心横切逻辑。当你下次再遇到“接口改不动”的问题时,先回头看看是不是这三层又被写混了。