在真实项目中,直接使用 Axios 的 get、post 等方法会带来大量重复代码:每个请求都要拼接完整 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 层面,可以提供一个通用的“防止重复请求”方案作为辅助,而处理竞态则有两种策略:
- 使用请求标识取消前一个同类请求(即上述的重复请求拦截)。这是最彻底的方案,适合“搜索结果覆盖”这类场景:输入
ab时发起请求 A,立刻输入abc时取消 A 并发起 B。 - 在业务层利用
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 类型提示:封装时尽量保留泛型,让调用处能推断返回数据类型,减少手动标注。
这样一个封装,足以覆盖从中小型项目到大型应用的日常需求,并且每个模块都可以按需裁剪或增强。