VUE3 中的 Axios 二次封装与请求策略

前言

在现代前端应用中,网络请求是不可或缺的一部分。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)
  }
}

这段代码有哪些问题呢?

  • 每个请求都需要重复配置 headerstimeout 等重复配置项
  • 每个请求都要重复获取和处理 tokenlocalStorage.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>

封装的度 - 如何把握封装分寸?

封装层次图

graph TB subgraph "业务层" A[业务组件] end subgraph "API层" B[API模块] end subgraph "基础层" C[请求实例] D[拦截器] E[工具函数] end A --> B B --> C C --> D D --> E

封装原则

原则一:够用即可

不要过度设计,根据项目规模选择合适的封装程度:

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% 的特殊场景留出出口。希望这篇文章能帮助我们构建适合自己的请求层。记住,最好的封装是让使用它的人感受不到封装的存在。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
饼干哥哥1 小时前
2026,OpenClaw驱动跨境电商10倍增长
前端·aigc
战族狼魂1 小时前
基于SpringBoot+Vue的基因调控网络推断系统
网络·vue.js·spring boot
陈随易1 小时前
Vite 8正式发布,内置devtool,Wasm SSR 支持
前端·后端·程序员
CodeSheep1 小时前
首个OpenClaw龙虾大模型排行榜来了,国产AI霸榜了!
前端·后端·程序员
Moment1 小时前
想转 AI 全栈?这些 Agent 开发面试题你能答出来吗
前端·后端·面试
Irene19911 小时前
Vue Router 中:route.params.id 和 route.query.id 的区别
vue.js·route·useroute
Joy T2 小时前
【Electron架构解析】打破浏览器沙盒:从 Web 前端到桌面客户端的技术跨越
前端·架构·electron
Rsun0455110 小时前
React相关面试题
前端·react.js·前端框架