前言
在现代前端应用中,网络请求是不可或缺的一部分。Axios 作为最流行的 HTTP 客户端,以其简洁的 API 和强大的功能赢得了开发者的青睐。然而,直接在每个组件中使用 Axios 会导致大量的代码冗余、错误处理混乱、难以维护等问题。
因此,我们需要对 Axios 进行二次封装,其核心价值在于:统一处理、集中配置、复用逻辑,把复杂的事情变得简单,把重复的事情变得自动化。。
本文将从零开始,深入探讨如何构建一个健壮、易用、类型安全的请求层,涵盖拦截器、请求取消、重试机制、缓存策略等高级特性。
为什么要封装 Axios?
没有封装的代码长什么样?
在本文开篇之前,我们先来看一个没有封装的 Axios 请求是什么样的:
typescript
// 用户页面
async function getUser() {
try {
const res = await axios.get('http://localhost:3000/api/users', {
headers: { token: localStorage.getItem('token') },
timeout: 10000
})
user.value = res.data
} catch (err) {
if (err.response?.status === 401) {
router.push('/login')
}
console.error('获取用户失败', err)
}
}
// 商品页面
async function getProduct() {
try {
const res = await axios.get('http://localhost:3000/api/products', {
headers: { token: localStorage.getItem('token') },
timeout: 10000
})
product.value = res.data
} catch (err) {
if (err.response?.status === 401) {
router.push('/login')
}
console.error('获取商品失败', err)
}
}
这段代码有哪些问题呢?
- 每个请求都需要重复配置
headers、timeout等重复配置项 - 每个请求都要重复获取和处理
token:localStorage.getItem('token') - 每个请求都要写
try/catch等错误处理 - 当需要修改请求配置时,与之相关的所有文件都要修改
封装之后的代码长什么样?
二次封装后,所有的重复配置都只需要写一次:
javascript
// request.js
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000
})
// 使用
import request from './request'
request.get('/users')
request.get('/products')
封装的核心价值
- 统一配置:一次配置,到处使用
- 统一处理:
token自动添加、错误统一处理 - 复用逻辑:loading状态、重试机制等都可复用
- 易于维护 :修改一处,生效全局
从零开始构建我们的请求层
第一层:基础配置
创建一个 request.js 文件,这是所有请求的基础:
javascript
// request.js
import axios from 'axios'
// 1. 创建axios实例
const request = axios.create({
// 基础URL - 通过环境变量区分开发/生产
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000/api',
// 超时时间 - 10秒后自动断开
timeout: 10000,
// 请求头 - 默认配置
headers: {
'Content-Type': 'application/json'
}
})
export default request
第二层:拦截器
拦截器就像机场的安检通道,每个请求和其响应都要经过检查:请求拦截器/响应拦截器:
typescript
// request.js
import { useUserStore } from '@/stores/user'
// 请求拦截器 - 请求发出前的处理
request.interceptors.request.use(
(config) => {
// 1. 获取用户token
const userStore = useUserStore()
// 2. 如果用户已登录,自动添加token
if (userStore.token) {
config.headers.Authorization = `Bearer ${userStore.token}`
}
// 3. GET请求添加时间戳,防止浏览器缓存
if (config.method === 'get') {
config.params = {
...config.params,
_t: Date.now()
}
}
return config
},
(error) => {
// 请求配置出错时的处理
return Promise.reject(error)
}
)
// 响应拦截器 - 收到响应后的处理
request.interceptors.response.use(
(response) => {
// 直接返回数据部分,简化使用
return response.data
},
(error) => {
// 统一的错误处理
if (error.response) {
// 服务器返回了错误状态码
switch (error.response.status) {
case 401: // 未授权
const userStore = useUserStore()
userStore.logout() // 清除用户信息
router.push('/login') // 跳转到登录页
break
case 403: // 禁止访问
ElMessage.error('没有权限执行此操作')
break
case 404: // 资源不存在
ElMessage.error('请求的资源不存在')
break
case 500: // 服务器错误
ElMessage.error('服务器开小差了,请稍后再试')
break
default:
ElMessage.error(error.response.data?.message || '请求失败')
}
} else if (error.request) {
// 请求发出去了,但没有收到响应
ElMessage.error('网络连接失败,请检查网络设置')
} else {
// 请求配置出错
ElMessage.error('请求配置错误')
}
return Promise.reject(error)
}
)
第三层:Loading 状态自动化
当我们在发送请求时,手动控制 loading 状态会很麻烦,可以让拦截器帮我们自动处理:
typescript
// stores/loading.js
import { ref } from 'vue'
import { defineStore } from 'pinia'
export const useLoadingStore = defineStore('loading', () => {
const count = ref(0) // 当前正在进行的请求数
const isLoading = computed(() => count.value > 0) // 是否显示loading
function add() {
count.value++
}
function remove() {
if (count.value > 0) {
count.value--
}
}
return { isLoading, add, remove }
})
在 request.js中,我们就可以使用上述 lodaing :
typescript
// request.js - 修改拦截器
import { useLoadingStore } from '@/stores/loading'
request.interceptors.request.use((config) => {
// 如果不是手动禁用了loading
if (!config.headers?.disableLoading) {
const loadingStore = useLoadingStore()
loadingStore.add()
}
return config
})
request.interceptors.response.use(
(response) => {
if (!response.config.headers?.disableLoading) {
const loadingStore = useLoadingStore()
loadingStore.remove()
}
return response
},
(error) => {
if (!error.config?.headers?.disableLoading) {
const loadingStore = useLoadingStore()
loadingStore.remove()
}
return Promise.reject(error)
}
)
在组件中使用:
html
<template>
<div>
<!-- 自动显示/隐藏loading -->
<div v-if="loadingStore.isLoading" class="loading">加载中...</div>
<div v-else>
<!-- 页面内容 -->
</div>
</div>
</template>
<script setup>
import { useLoadingStore } from '@/stores/loading'
const loadingStore = useLoadingStore()
// 发起请求会自动显示loading
async function fetchData() {
await request.get('/users')
}
</script>
实战技巧 - 解决常见痛点
场景1:请求取消,告别重复请求
当用户在使用搜索功能时,首先在搜索框输入"手机"发送搜索请求,此时请求还没返回;又将输入变成了"手机号",重新发送一次请求。此时应该取消第一个请求,只保留最新的一次请求:
typescript
// utils/CancelRequest.js
class CancelRequest {
constructor() {
// 存储所有pending状态的请求
this.pendingMap = new Map()
}
// 生成请求的唯一标识
getRequestKey(config) {
const { method, url, params, data } = config
return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&')
}
// 添加请求到pending列表
addPending(config) {
const key = this.getRequestKey(config)
// 如果已有相同的请求,取消它
if (this.pendingMap.has(key)) {
const abort = this.pendingMap.get(key)
abort() // 取消请求
this.pendingMap.delete(key)
}
// 创建新的AbortController
const controller = new AbortController()
config.signal = controller.signal
// 保存取消函数
this.pendingMap.set(key, () => controller.abort())
}
// 请求完成后,从pending列表移除
removePending(config) {
const key = this.getRequestKey(config)
if (this.pendingMap.has(key)) {
this.pendingMap.delete(key)
}
}
// 取消所有pending请求(这在页面切换时很有用)
cancelAll() {
this.pendingMap.forEach(cancel => cancel())
this.pendingMap.clear()
}
}
export const cancelRequest = new CancelRequest()
在拦截器中使用:
typescript
// request.js
import { cancelRequest } from './utils/CancelRequest'
request.interceptors.request.use((config) => {
// 如果没有禁用取消功能
if (!config.headers?.disableCancel) {
cancelRequest.addPending(config)
}
return config
})
request.interceptors.response.use(
(response) => {
cancelRequest.removePending(response.config)
return response
},
(error) => {
// 如果是手动取消的请求,不抛出错误
if (axios.isCancel(error)) {
console.log('请求已取消')
return new Promise(() => {}) // 返回pending的Promise
}
if (error.config) {
cancelRequest.removePending(error.config)
}
return Promise.reject(error)
}
)
// 路由切换时,取消所有请求
router.beforeEach((to, from, next) => {
cancelRequest.cancelAll()
next()
})
场景2:自动重试,提升用户体验
当网络不稳定时,我们需要自动重试功能,让用户无感知地完成操作,而不是简单地返回一句"网络异常,请稍后重试":
typescript
// utils/retry.js
/**
* 带重试功能的请求
* @param {Function} requestFn 请求函数
* @param {Object} options 配置选项
*/
export async function retryRequest(requestFn, options = {}) {
const {
retries = 3, // 最大重试次数
delay = 1000, // 初始延迟(毫秒)
factor = 2, // 延迟增长倍数
maxDelay = 30000, // 最大延迟
retryCondition = (error) => {
// 默认重试条件:网络错误 或 5xx服务器错误
return !error.response || error.response.status >= 500
}
} = options
let attempt = 0
while (attempt <= retries) {
try {
return await requestFn()
} catch (error) {
attempt++
// 最后一次尝试失败,抛出错误
if (attempt > retries) {
throw error
}
// 检查是否应该重试
if (!retryCondition(error)) {
throw error
}
// 计算等待时间(指数退避)
const waitTime = Math.min(delay * Math.pow(factor, attempt - 1), maxDelay)
console.log(`请求失败,${waitTime}ms后第${attempt}次重试...`)
// 等待后继续循环
await new Promise(resolve => setTimeout(resolve, waitTime))
}
}
}
// 使用示例
async function fetchImportantData() {
return retryRequest(
() => request.get('/important-data'),
{
retries: 5,
delay: 2000,
onRetry: (attempt, error) => {
// 可以在这里记录日志或通知用户
console.log(`第${attempt}次重试`, error)
}
}
)
}
场景3:数据缓存,减少不必要的请求
当用户频繁查看某个商品详情时,每次都要发送一次请求,这样既浪费资源,又慢,因此我们可以将数据缓存起来:
typescript
// utils/cache.js
class RequestCache {
constructor() {
this.cache = new Map()
}
/**
* 设置缓存
* @param {string} key 缓存键
* @param {any} data 缓存数据
* @param {number} ttl 过期时间(毫秒)
*/
set(key, data, ttl = 60000) {
this.cache.set(key, {
data,
expire: Date.now() + ttl
})
}
/**
* 获取缓存
* @param {string} key 缓存键
*/
get(key) {
const item = this.cache.get(key)
// 没有缓存
if (!item) return null
// 检查是否过期
if (Date.now() > item.expire) {
this.cache.delete(key)
return null
}
return item.data
}
// 清除特定缓存
delete(key) {
this.cache.delete(key)
}
// 清除所有缓存
clear() {
this.cache.clear()
}
}
export const requestCache = new RequestCache()
// 封装带缓存的请求
async function requestWithCache(url, options = {}) {
const { cacheTTL = 60000, ...restOptions } = options
// 只有GET请求才使用缓存
if (restOptions.method && restOptions.method !== 'GET') {
return request(url, restOptions)
}
// 生成缓存键
const cacheKey = `${url}:${JSON.stringify(restOptions.params)}`
// 检查缓存
const cached = requestCache.get(cacheKey)
if (cached) {
console.log('使用缓存数据:', cacheKey)
return cached
}
// 发起真实请求
const data = await request(url, restOptions)
// 存入缓存
requestCache.set(cacheKey, data, cacheTTL)
return data
}
TypeScript 加持 - 让代码更可靠
自定义类型系统
typescript
// types/api.d.ts
// 通用响应格式
export interface ApiResponse<T = any> {
code: number // 业务状态码
message: string // 提示信息
data: T // 实际数据
timestamp?: number // 时间戳
}
// 分页参数
export interface PaginationParams {
page: number // 当前页码
pageSize: number // 每页条数
sort?: string // 排序字段
order?: 'asc' | 'desc' // 排序方式
}
// 分页结果
export interface PaginatedResult<T> {
list: T[] // 数据列表
total: number // 总条数
page: number // 当前页码
pageSize: number // 每页条数
totalPages: number // 总页数
}
// 扩展的请求配置
export interface RequestConfig {
url: string
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
data?: any
params?: any
headers?: Record<string, string>
// 自定义选项
disableLoading?: boolean // 是否禁用loading
disableCancel?: boolean // 是否禁用自动取消
cacheTTL?: number // 缓存时间(毫秒)
retries?: number // 重试次数
}
创建类型安全的API模块
typescript
// api/user.ts
import request from '@/request'
import type { PaginationParams, PaginatedResult } from '@/types/api'
// 用户类型定义
export interface User {
id: number
name: string
email: string
avatar: string
role: 'admin' | 'user'
status: 'active' | 'inactive'
createdAt: string
updatedAt: string
}
export interface CreateUserDto {
name: string
email: string
password: string
role?: 'admin' | 'user'
}
export interface UpdateUserDto extends Partial<CreateUserDto> {
status?: 'active' | 'inactive'
}
export interface UserListParams extends PaginationParams {
keyword?: string
role?: string
status?: string
}
// 用户API模块
export const userApi = {
// 获取用户列表
getList: (params: UserListParams) =>
request.get<PaginatedResult<User>>('/users', { params }),
// 获取单个用户
getDetail: (id: number) =>
request.get<User>(`/users/${id}`),
// 创建用户
create: (data: CreateUserDto) =>
request.post<User>('/users', data),
// 更新用户
update: (id: number, data: UpdateUserDto) =>
request.put<User>(`/users/${id}`, data),
// 删除用户
delete: (id: number) =>
request.delete(`/users/${id}`),
// 修改状态
updateStatus: (id: number, status: User['status']) =>
request.patch(`/users/${id}/status`, { status })
}
在组件中使用
typescript
<script setup lang="ts">
import { ref } from 'vue'
import { userApi } from '@/api/user'
import type { User, UserListParams } from '@/api/user'
const users = ref<User[]>([])
const loading = ref(false)
const params = ref<UserListParams>({
page: 1,
pageSize: 10,
keyword: ''
})
async function loadUsers() {
loading.value = true
try {
const result = await userApi.getList(params.value)
users.value = result.list
} finally {
loading.value = false
}
}
// 完全的类型提示和自动补全!
async function handleCreate() {
const newUser = await userApi.create({
name: '张三',
email: 'zhangsan@example.com',
password: '123456',
role: 'user'
})
users.value.push(newUser)
}
</script>
封装的度 - 如何把握封装分寸?
封装层次图
封装原则
原则一:够用即可
不要过度设计,根据项目规模选择合适的封装程度:
typescript
// ✅ 小型项目:简单封装就够了
const request = axios.create({ baseURL: '/api' })
// ✅ 中型项目:添加拦截器、类型定义
request.interceptors.response.use(/* 错误处理 */)
// ✅ 大型项目:完整的缓存、重试、监控机制
原则二:可配置性
提供出口,让特殊场景可以绕过封装:
typescript
// 通过配置项控制
await request.get('/important-data', {
headers: {
disableLoading: true, // 不显示loading
disableCancel: true, // 不自动取消
disableRetry: true // 不重试
}
})
原则三:渐进增强
从简单开始,逐步完善:
typescript
// 第一阶段:基础封装
export const api = {
getUser: () => request.get('/user')
}
// 第二阶段:添加类型
export const api = {
getUser: (): Promise<User> => request.get('/user')
}
// 第三阶段:添加高级特性
export const api = {
getUser: () => retryRequest(
() => requestWithCache('/user'),
{ retries: 3 }
)
}
封装的检查清单
| 检查项 | 是否必需 | 说明 |
|---|---|---|
| 基础配置 | 是 | baseURL、超时、请求头 |
| 错误处理 | 是 | 统一错误提示、状态码处理 |
| Token管理 | 是 | 自动附加、过期处理 |
| Loading状态 | 推荐 | 提升用户体验 |
| TypeScript | 推荐 | 类型安全、开发体验 |
| 请求取消 | 看场景 | 搜索、标签切换等 |
| 数据缓存 | 看场景 | 频繁访问的静态数据 |
| 自动重试 | 看场景 | 网络不稳定时 |
完整目录结构
text
src/
├── api/
│ ├── index.ts # API统一出口
│ ├── user.ts # 用户模块
│ ├── product.ts # 商品模块
│ └── order.ts # 订单模块
├── utils/
│ ├── request.ts # 请求核心
│ ├── cache.ts # 缓存工具
│ ├── retry.ts # 重试工具
│ └── cancel.ts # 取消工具
├── types/
│ └── api.d.ts # 类型定义
└── stores/
└── loading.ts # loading状态
最终建议
Axios 封装没有标准答案,关键在于根据项目规模和团队习惯找到平衡点:
- 小型项目:简单的拦截器 + 类型定义就够了
- 中型项目:需要请求取消、错误统一处理
- 大型项目:完整的缓存、重试、监控机制
结语
封装不是为了炫技,而是为了让代码更简单,让开发更高效。一个好的封装应该让 90% 的场景变得简单,同时给 10% 的特殊场景留出出口。希望这篇文章能帮助我们构建适合自己的请求层。记住,最好的封装是让使用它的人感受不到封装的存在。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!