vueapi接口封装 - 完整解决方案与实战教程
在 Vue 项目从单体应用向中大型 SPA 演进的过程中,几乎所有团队都会遇到同一个问题:接口调用散落在各个组件中,导致重复代码泛滥、错误处理不统一、Token 刷新逻辑难以维护、接口类型定义缺失,最终形成“改一个接口要翻十个文件”的技术债。很多开发者以为封装一个 axios 实例就算完成了,但在真实业务里,真正的痛点往往出现在并发请求、取消重复请求、Loading 状态管理、以及响应拦截器里的异常兜底上。
问题现象
先看几个典型的“翻车现场”,如果你的项目命中了两条以上,说明接口层已经到了必须重构的阶段:
- 组件里直接写
axios.get('/api/user'),URL 硬编码,后端一改路径就要全局搜索替换。 - 每个页面都在写
try/catch,但错误提示五花八门,有的用alert,有的用Message.error。 - 登录态失效时,多个并发请求同时触发 401,导致弹出十几个“登录已过期”提示框。
- 搜索框输入时频繁发请求,旧请求返回比新请求慢,页面显示的是过期数据。
- 接口返回结构不统一,有的返回
{ code, data },有的直接返回数组,组件里到处写兼容逻辑。
原因分析
这些现象背后其实只有三个根因:
- 缺少统一的服务层抽象。组件承担了本不该它负责的 HTTP 细节,视图逻辑和网络逻辑耦合在一起。
- 拦截器职责设计混乱。很多项目把 Token 注入、错误提示、Loading、重定向全部塞进一个响应拦截器,导致逻辑互相干扰,异常无法精准捕获。
- 没有请求取消与并发控制机制。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. 关键排查与落地步骤
- 确认后端响应结构是否统一,若存在多种格式,先在响应拦截器里做归一化,不要污染业务层。
- 检查
AbortController是否被正确传递,重复请求取消依赖config.signal,不要手动覆盖。 - Token 刷新场景下,需在 401 处理里加锁,避免多个请求同时刷新 Token 造成竞态。
- 对于文件上传、下载等特殊接口,单独创建实例,不要复用 JSON 拦截器。
- 类型定义建议由 OpenAPI 自动生成,避免手写 interface 与后端脱节。
这套分层方案的核心价值在于:组件只关心数据和 UI,API 层只关心契约,拦截器只关心横切逻辑。当你下次再遇到“接口改不动”的问题时,先回头看看是不是这三层又被写混了。