Axios HTTP 请求封装
大约 9 分钟约 2741 字
Axios HTTP 请求封装
简介
Axios 是最流行的 HTTP 客户端库,支持请求/响应拦截器、自动 JSON 转换、取消请求和错误处理。在 Vue/React 项目中,封装 Axios 实例统一管理 API 基础路径、Token 注入、错误处理和请求取消,是前端工程化的基础。
特点
基础封装
Axios 实例
// utils/request.ts
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios'
import { ElMessage } from 'element-plus'
// 创建实例
const service: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 15000,
headers: { 'Content-Type': 'application/json' }
})
// 请求拦截器
service.interceptors.request.use(
config => {
// 注入 Token
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => Promise.reject(error)
)
// 响应拦截器
service.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
const { code, data, message } = response.data
if (code === 200 || code === 0) {
return data as any
}
// 业务错误
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message))
},
error => {
// HTTP 错误
if (error.response) {
const { status } = error.response
switch (status) {
case 401:
ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('token')
window.location.href = '/login'
break
case 403:
ElMessage.error('没有权限访问')
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 500:
ElMessage.error('服务器错误')
break
default:
ElMessage.error(`请求失败 (${status})`)
}
} else if (error.code === 'ERR_CANCELED') {
// 请求被取消,不提示
} else {
ElMessage.error('网络连接失败')
}
return Promise.reject(error)
}
)
// API 响应类型
interface ApiResponse<T = any> {
code: number
data: T
message: string
}
export default serviceAPI 模块化
按模块组织 API
// api/user.ts
import request from '@/utils/request'
export interface LoginParams {
username: string
password: string
}
export interface UserInfo {
id: number
name: string
email: string
role: string
}
// 用户 API
export const userApi = {
login: (data: LoginParams) =>
request.post<string>('/auth/login', data),
logout: () =>
request.post('/auth/logout'),
getInfo: () =>
request.get<UserInfo>('/user/info'),
updateProfile: (data: Partial<UserInfo>) =>
request.put('/user/profile', data),
changePassword: (oldPassword: string, newPassword: string) =>
request.put('/user/password', { oldPassword, newPassword })
}
// api/product.ts
export interface Product {
id: number
name: string
price: number
category: string
}
export interface PageResult<T> {
items: T[]
total: number
}
export const productApi = {
getList: (params: { page: number; pageSize: number; keyword?: string }) =>
request.get<PageResult<Product>>('/products', { params }),
getById: (id: number) =>
request.get<Product>(`/products/${id}`),
create: (data: Omit<Product, 'id'>) =>
request.post<Product>('/products', data),
update: (id: number, data: Partial<Product>) =>
request.put(`/products/${id}`, data),
delete: (id: number) =>
request.delete(`/products/${id}`)
}取消请求
防重复提交
// utils/request.ts — 添加取消请求支持
const pendingRequests = new Map<string, AbortController>()
function generateRequestKey(config: AxiosRequestConfig): string {
return `${config.method}-${config.url}-${JSON.stringify(config.params)}`
}
function addPendingRequest(config: AxiosRequestConfig) {
const key = generateRequestKey(config)
if (pendingRequests.has(key)) {
pendingRequests.get(key)?.abort()
}
const controller = new AbortController()
config.signal = controller.signal
pendingRequests.set(key, controller)
}
function removePendingRequest(config: AxiosRequestConfig) {
const key = generateRequestKey(config)
pendingRequests.delete(key)
}
// 在请求拦截器中使用
service.interceptors.request.use(config => {
addPendingRequest(config)
// ...
return config
})
service.interceptors.response.use(
response => {
removePendingRequest(response.config)
// ...
},
error => {
removePendingRequest(error.config)
// ...
}
)
// 路由切换时取消所有请求
export function cancelAllRequests() {
pendingRequests.forEach(controller => controller.abort())
pendingRequests.clear()
}Token 刷新机制
无感刷新 Token
// utils/request.ts — Token 自动刷新
let isRefreshing = false
let failedQueue: Array<{
resolve: (token: string) => void
reject: (error: any) => void
}> = []
function processQueue(error: any, token: string | null = null) {
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error)
} else {
resolve(token!)
}
})
failedQueue = []
}
// 响应拦截器中处理 401
service.interceptors.response.use(
(response) => {
const { code, data, message } = response.data
if (code === 200 || code === 0) return data as any
ElMessage.error(message || '请求失败')
return Promise.reject(new Error(message))
},
async (error) => {
const originalRequest = error.config
// 401 且不是刷新 Token 的请求
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// 正在刷新,加入队列等待
return new Promise((resolve, reject) => {
failedQueue.push({
resolve: (token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`
resolve(service(originalRequest))
},
reject,
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
// 使用 refresh_token 获取新的 access_token
const refreshToken = localStorage.getItem('refreshToken')
const res = await axios.post('/api/auth/refresh', {
refreshToken
})
const newToken = res.data.data.accessToken
localStorage.setItem('token', newToken)
// 更新默认请求头
service.defaults.headers.common.Authorization = `Bearer ${newToken}`
// 重试所有等待中的请求
processQueue(null, newToken)
// 重试当前请求
originalRequest.headers.Authorization = `Bearer ${newToken}`
return service(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
localStorage.removeItem('token')
localStorage.removeItem('refreshToken')
window.location.href = '/login'
return Promise.reject(refreshError)
} finally {
isRefreshing = false
}
}
return Promise.reject(error)
}
)请求重试机制
// utils/retry.ts — 请求失败自动重试
import axios, { type AxiosRequestConfig } from 'axios'
interface RetryConfig {
retries: number // 最大重试次数
retryDelay: number // 重试间隔(毫秒)
retryCondition: (error: any) => boolean // 是否重试的条件
}
const defaultRetryConfig: RetryConfig = {
retries: 3,
retryDelay: 1000,
retryCondition: (error) => {
// 只对网络错误和 5xx 错误重试
return !error.response || error.response.status >= 500
},
}
export function createRetryAdapter(retryConfig: Partial<RetryConfig> = {}) {
const config = { ...defaultRetryConfig, ...retryConfig }
return async (error: any) => {
const { config } = error
config.__retryCount = config.__retryCount || 0
if (config.__retryCount >= config.retries) {
return Promise.reject(error)
}
config.__retryCount++
// 延迟后重试
await new Promise(resolve =>
setTimeout(resolve, config.retryDelay * config.__retryCount)
)
return axios(config)
}
}
// 使用
service.interceptors.response.use(
response => response,
createRetryAdapter({ retries: 2, retryDelay: 500 })
)请求缓存与防抖
接口缓存
// utils/requestCache.ts
interface CacheItem {
data: any
timestamp: number
ttl: number // 缓存过期时间(毫秒)
}
const cache = new Map<string, CacheItem>()
export function createCachedRequest(ttl: number = 60000) {
return async (config: AxiosRequestConfig) => {
const cacheKey = `${config.method}-${config.url}-${JSON.stringify(config.params)}`
const cached = cache.get(cacheKey)
if (cached && Date.now() - cached.timestamp < cached.ttl) {
console.log(`缓存命中: ${cacheKey}`)
return cached.data
}
// 清理过期缓存
if (cached) cache.delete(cacheKey)
const response = await service(config)
cache.set(cacheKey, { data: response, timestamp: Date.now(), ttl })
return response
}
}
// 使用
const cachedGet = createCachedRequest(30000) // 缓存 30 秒
// 调用时自动检查缓存
const data = await cachedGet({ method: 'get', url: '/api/config' })防抖请求
// utils/debounceRequest.ts
const debounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
export function debounceRequest(
key: string,
requestFn: () => Promise<any>,
delay: number = 300
): Promise<any> {
return new Promise((resolve, reject) => {
// 清除上一次定时器
if (debounceTimers.has(key)) {
clearTimeout(debounceTimers.get(key))
}
debounceTimers.set(key, setTimeout(async () => {
debounceTimers.delete(key)
try {
const result = await requestFn()
resolve(result)
} catch (error) {
reject(error)
}
}, delay))
})
}
// 使用 — 搜索框输入防抖
const handleSearch = (keyword: string) => {
debounceRequest('search', () =>
productApi.getList({ page: 1, pageSize: 20, keyword })
).then(data => {
searchResults.value = data.items
})
}文件上传下载
文件操作
// 文件上传
export const uploadFile = (file: File) => {
const formData = new FormData()
formData.append('file', file)
return request.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (event) => {
const percent = Math.round((event.loaded * 100) / (event.total || 1))
console.log(`上传进度:${percent}%`)
}
})
}
// 文件下载
export const downloadFile = async (url: string, filename: string) => {
const response = await request.get(url, {
responseType: 'blob'
})
const blob = new Blob([response as any])
const downloadUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = downloadUrl
link.download = filename
link.click()
URL.revokeObjectURL(downloadUrl)
}分片上传
// 大文件分片上传
export async function uploadLargeFile(
file: File,
chunkSize: number = 5 * 1024 * 1024, // 5MB 每片
onProgress?: (percent: number) => void
) {
const totalChunks = Math.ceil(file.size / chunkSize)
const fileId = `${Date.now()}-${file.name}`
// 1. 通知后端创建文件
await request.post('/upload/init', {
fileId,
fileName: file.name,
totalChunks,
fileSize: file.size,
})
let uploadedChunks = 0
// 2. 并发上传分片(控制并发数为 3)
const concurrency = 3
const queue: Promise<void>[] = []
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
const uploadTask = async () => {
const formData = new FormData()
formData.append('file', chunk)
formData.append('fileId', fileId)
formData.append('chunkIndex', String(i))
await request.post('/upload/chunk', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
uploadedChunks++
onProgress?.(Math.round((uploadedChunks / totalChunks) * 100))
}
queue.push(uploadTask)
if (queue.length >= concurrency) {
await Promise.race(queue)
// 移除已完成的
queue.splice(
queue.findIndex(p => p === Promise.race(queue)),
1
)
}
}
await Promise.all(queue)
// 3. 通知后端合并分片
const result = await request.post('/upload/merge', { fileId })
return result
}多文件拖拽上传
import { ref } from 'vue'
export function useFileUpload(options: {
maxFiles?: number
maxSize?: number // 字节
accept?: string
multiple?: boolean
}) {
const {
maxFiles = 10,
maxSize = 10 * 1024 * 1024, // 10MB
accept = '*',
multiple = true,
} = options
const files = ref<File[]>([])
const uploading = ref(false)
const progress = ref(0)
const errors = ref<string[]>([])
function validate(file: File): string | null {
if (file.size > maxSize) {
return `文件 ${file.name} 超过大小限制`
}
if (files.value.length >= maxFiles) {
return `最多上传 ${maxFiles} 个文件`
}
return null
}
function addFiles(newFiles: FileList | File[]) {
errors.value = []
const fileArray = Array.from(newFiles)
for (const file of fileArray) {
const error = validate(file)
if (error) {
errors.value.push(error)
continue
}
if (!multiple && files.value.length > 0) {
files.value = [file]
} else {
files.value.push(file)
}
}
}
function removeFile(index: number) {
files.value.splice(index, 1)
}
async function uploadAll() {
if (files.value.length === 0) return
uploading.value = true
progress.value = 0
try {
const total = files.value.length
let completed = 0
for (const file of files.value) {
const formData = new FormData()
formData.append('file', file)
await request.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
completed++
progress.value = Math.round((completed / total) * 100)
}
} finally {
uploading.value = false
}
}
// 拖拽处理
function onDragOver(e: DragEvent) {
e.preventDefault()
}
function onDrop(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.files) {
addFiles(e.dataTransfer.files)
}
}
return {
files,
uploading,
progress,
errors,
addFiles,
removeFile,
uploadAll,
onDragOver,
onDrop,
}
}Axios vs Fetch 对比
// Axios 优势:
// 1. 自动 JSON 转换(fetch 需要手动 res.json())
// 2. 请求/响应拦截器
// 3. 超时控制(fetch 需要 AbortController)
// 4. 请求取消(fetch 需要 AbortController)
// 5. 自动转换响应数据
// 6. XSRF 防护
// 7. 更好的错误处理(fetch 不会 reject HTTP 错误)
// 8. Node.js 环境支持
// Fetch 优势:
// 1. 原生支持,零依赖
// 2. 更小的包体积
// 3. Stream 支持
// 4. 现代 API 设计
// 什么时候用 Fetch?
// - 对包体积极度敏感的场景(如组件库、SSR)
// - 简单的 GET 请求
// - 需要 ReadableStream 的场景
// 什么时候用 Axios?
// - 需要拦截器统一处理 Token/错误
// - 需要请求取消和超时
// - 复杂的 API 管理和类型封装
// - 需要同时兼容浏览器和 Node.js
## 优点
- [x] 1.拦截器 — 统一处理 Token、错误、日志
- [x] 2.模块化 — API 按模块组织,类型安全
- [x] 3.取消请求 — 避免重复请求和内存泄漏
- [x] 4.文件支持 — 上传下载和进度监控
## 缺点
- [x] 1.包体积 — 相比 fetch 增加约 13KB
- [x] 2.过度封装 — 简单项目不一定需要封装
- [x] 3.TypeScript — 泛型类型推断需要额外配置
- [x] 4.替代方案 — 原生 fetch 已能满足大部分需求
## 总结
Axios 封装核心:创建实例 → 请求拦截器注入 Token → 响应拦截器统一错误处理。API 按模块组织(userApi/productApi),每个方法定义明确的 TypeScript 类型。防重复请求用 AbortController。文件上传用 FormData + 进度回调,下载用 Blob + URL.createObjectURL。
## 关键知识点
- 先判断主题更偏浏览器原理、框架机制、工程化还是性能优化。
- 前端问题很多看似是页面问题,实际源头在构建、缓存、状态流或接口协作。
- 真正成熟的前端方案一定同时考虑首屏、交互、可维护性和线上诊断。
## 项目落地视角
- 把组件边界、状态归属、网络层规范和错误处理先定下来。
- 上线前检查包体积、缓存命中、接口失败路径和关键交互降级策略。
- 如果主题和性能有关,最好用 DevTools、Lighthouse 或埋点验证。
## 常见误区
- 只盯框架 API,不理解浏览器和运行时成本。
- 把状态、请求和 UI 更新混成一层,后期难维护。
- 线上问题出现时没有日志、埋点和性能基线可对照。
## 进阶路线
- 继续补齐 SSR、边缘渲染、设计系统和监控告警能力。
- 把主题和后端接口约定、CI/CD、缓存策略一起思考。
- 沉淀组件规范、页面模板和性能基线,减少团队差异。
## 适用场景
- 当你准备把《Axios HTTP 请求封装》真正落到项目里时,最适合先在一个独立模块或最小样例里验证关键路径。
- 适合中后台应用、门户站点、组件库和实时交互页面。
- 当需求涉及状态流、路由、网络缓存、SSR/CSR 或性能治理时,这类主题很关键。
## 落地建议
- 先定义组件边界和状态归属,再落地 UI 细节。
- 对核心页面做首屏、体积、缓存和错误路径检查。
- 把安全、兼容性和可访问性纳入默认交付标准。
## 排错清单
- 先用浏览器 DevTools 看请求、性能面板和控制台错误。
- 检查依赖版本、构建配置、环境变量和静态资源路径。
- 如果是线上问题,优先确认缓存、CDN 和构建产物是否一致。
## 复盘问题
- 如果把《Axios HTTP 请求封装》放进你的当前项目,最先要验证的输入、输出和失败路径分别是什么?
- 《Axios HTTP 请求封装》最容易在什么规模、什么边界条件下暴露问题?你会用什么指标或日志去确认?
- 相比默认实现或替代方案,采用《Axios HTTP 请求封装》最大的收益和代价分别是什么?
## 延伸阅读
- [http 缓存](/dir/frontend/http_cache.md)
- [微前端架构](/dir/frontend/micro_frontend.md)
- [响应式设计实战](/dir/frontend/responsive_design.md)
- [前端性能优化](/dir/frontend/frontend_performance.md)