工程化网络请求:Axios 封装与 API 接口管理

在上一节《组件接上数据:Vue 3 + Axios 请求页面实战》中,我们已经把 axios 正式接进了 Vue 组件,学会了在 onMounted() 中请求接口、渲染列表、处理加载状态和提交表单。

但如果项目一旦变大,你很快就会发现:只会在组件里直接写 axios.get()axios.post() 还远远不够。

在真实的 Vue / Vite 企业项目中,一个非常常见的选择就是第三方库 —— Axios

为什么放着原生的 fetch 不用,非要引入一个额外的库?在前端工程化中,我们应该如何优雅地管理成百上千个接口?这篇文章将带你完成前端网络请求的“工业化改造”。

1. 为什么选择 Axios 而不是 Fetch?

fetch 足够简单,但它太底层了。在实际业务中,我们每次发请求都需要处理很多“琐事”:

  1. 自动转换 JSONfetch 需要手动调用 response.json(),而 Axios 会自动帮你转换。
  2. 错误处理机制:正如我们之前提到的,fetch 遇到 404500 状态码时,不会进入 catch 块,必须手动判断 response.ok。Axios 只要状态码不在 2xx 范围内,就会自动抛出错误进入 catch
  3. 全局拦截器 (Interceptors)(核心痛点)
    • 痛点 1:用户登录后,后端要求后续的每一次请求都在 Header 里带上 Authorization: Bearer <Token>。如果用 fetch,你必须在每个请求的地方手动拼接。
    • 痛点 2:如果 Token 过期,后端返回 401,你需要把用户踢回登录页。如果用 fetch,你要在每个页面写一遍判断逻辑。
    • Axios 提供的拦截器完美解决了这些问题,它可以统一在请求发出去之前或收到响应之后做全局处理。

2. 安装与基础使用

在你的 Vue 项目中安装 Axios:

npm install axios

基础用法演示(这和 fetch 非常像,但更简洁):

import axios from 'axios'

async function getUser() {
  try {
    // GET 请求
    const res = await axios.get('https://api.example.com/users/1')
    // 数据直接在 res.data 中,不需要 response.json()
    console.log(res.data) 
  } catch (error) {
    // 404 或 500 会直接来到这里
    console.error('请求失败:', error)
  }
}

虽然直接用 axios.get 很方便,但这依然不够工程化。接下来,我们将对其进行二次封装。


3. 核心实战:Axios 二次封装

在真实项目中,我们通常会在 src 目录下新建一个 utils 文件夹,专门用来存放工具函数。

创建 src/utils/request.js

import axios from 'axios'
import { ElMessage } from 'element-plus'
// 假设你使用 Pinia 管理了用户信息,这里可以引入 store 获取 token
// import { useUserStore } from '@/stores/user'
// import router from '@/router'

// 1. 创建 Axios 实例
const service = axios.create({
  // 配置基础 URL。实际项目中通常会读取环境变量,例如 import.meta.env.VITE_API_BASE_URL
  baseURL: 'https://api.example.com',
  // 请求超时时间(5秒)
  timeout: 5000
})

// 控制 refresh token 刷新流程,避免多个 401 同时触发多次刷新
let isRefreshing = false
// 刷新期间,先把后续失败请求挂起,等新 token 拿到后再统一重放
let pendingRequests = []

// 单独封装 token 写入,便于后续替换为 Pinia / cookie 等存储方案
const setAccessToken = (token) => {
  localStorage.setItem('token', token)
  // const userStore = useUserStore()
  // userStore.setToken(token)
}

// 有些后端会在刷新时连同 refresh token 一起轮换,这里一并更新
const setRefreshToken = (refreshToken) => {
  localStorage.setItem('refreshToken', refreshToken)
}

// 如果项目把登录态同时存到了 localStorage 和 Pinia,失效时记得一起清理
const clearAuthState = () => {
  localStorage.removeItem('token')
  localStorage.removeItem('refreshToken')
  // const userStore = useUserStore()
  // userStore.logout()
}

// 给原请求补上新的 access token,然后原样重新发一次
const retryRequest = (config, accessToken) => {
  config.headers = config.headers || {}
  config.headers.Authorization = `Bearer ${accessToken}`
  return service(config)
}

// 刷新完成后,批量恢复排队中的请求;如果刷新失败,则一起拒绝
const processPendingRequests = (error, accessToken = null) => {
  pendingRequests.forEach(({ resolve, reject, config }) => {
    if (error) {
      reject(error)
    } else {
      resolve(retryRequest(config, accessToken))
    }
  })
  pendingRequests = []
}

// 用 refresh token 换新的 access token
const refreshAccessToken = async () => {
  const refreshToken = localStorage.getItem('refreshToken')

  if (!refreshToken) {
    throw new Error('Refresh Token 不存在')
  }

  // 刷新接口通常使用原始 axios,避免它再次进入当前 service 的拦截器
  const response = await axios.post(`${service.defaults.baseURL}/auth/refresh`, {
    refreshToken
  })

  const tokenData = response.data.data
  setAccessToken(tokenData.accessToken)

  if (tokenData.refreshToken) {
    setRefreshToken(tokenData.refreshToken)
  }

  return tokenData.accessToken
}

// access token 失效后的统一处理入口:
// 1. 防止同一个请求无限重试
// 2. 刷新中则排队等待
// 3. 刷新成功后重放原请求
// 4. 刷新失败后清空登录态
const handleTokenExpired = async (originalRequest) => {
  if (!originalRequest || originalRequest._retry) {
    clearAuthState()
    ElMessage.error('登录状态已失效,请重新登录')
    // router.push('/login')
    return Promise.reject(new Error('登录状态已失效,请重新登录'))
  }

  originalRequest._retry = true

  if (isRefreshing) {
    return new Promise((resolve, reject) => {
      // 这里不立即重试,而是把请求先存起来,等待首个刷新请求完成
      pendingRequests.push({ resolve, reject, config: originalRequest })
    })
  }

  isRefreshing = true

  try {
    const newAccessToken = await refreshAccessToken()
    // 先恢复排队请求,再补发当前这个触发刷新的请求
    processPendingRequests(null, newAccessToken)
    return retryRequest(originalRequest, newAccessToken)
  } catch (refreshError) {
    processPendingRequests(refreshError)
    clearAuthState()
    ElMessage.error('登录状态已失效,请重新登录')
    // router.push('/login')
    return Promise.reject(refreshError)
  } finally {
    isRefreshing = false
  }
}

// 2. 配置请求拦截器 (Request Interceptor)
// 作用:在请求真正发出去之前,做一些统一处理
service.interceptors.request.use(
  (config) => {
    // 场景:统一携带 Token
    const token = localStorage.getItem('token') // 或者从 Pinia 获取
    if (token) {
      // 让每个请求携带自定义 token,这里的 'Authorization' 字段名要和后端协商
      config.headers['Authorization'] = `Bearer ${token}`
    }
    return config
  },
  (error) => {
    // 请求错误时的处理
    return Promise.reject(error)
  }
)

// 3. 配置响应拦截器 (Response Interceptor)
// 作用:在拿到服务器响应之后,交给组件处理之前,做统一的错误拦截
service.interceptors.response.use(
  (response) => {
    // 文件下载、图片预览这类二进制响应不走统一 JSON 拆壳逻辑,直接放行
    if (response.config.responseType === 'blob') {
      return response.data
    }

    // Axios 默认把完整响应包在 response 对象里,业务数据通常在 response.data 中
    const res = response.data

    // 很多公司的后端会自定义一套状态码,比如 code: 200 代表成功,40101 代表 Token 失效
    if (res.code === 40101) {
      return handleTokenExpired(response.config)
    }

    if (res.code !== 200) {
      ElMessage.error(res.message || '系统未知错误')
      return Promise.reject(new Error(res.message || 'Error'))
    }

    // 一切正常,只返回核心数据给组件,剥离掉 axios 封装的外层
    return res.data
  },
  (error) => {
    // 主动取消的请求通常不需要给用户弹错
    if (axios.isCancel(error)) {
      console.log('请求被取消:', error.message)
      return Promise.reject(error)
    }

    // 处理 HTTP 状态码报错 (如 401, 404, 500)
    console.error('HTTP Error: ', error)
    if (error.response?.status === 401) {
      return handleTokenExpired(error.config)
    }

    const msg = error.response?.data?.message || error.message || '网络连接异常'
    ElMessage.error(msg)
    return Promise.reject(error)
  }
)

// 4. 导出封装好的实例
export default service

这份封装的含金量在于: 只要是使用这个 request.js 发出的请求,自动带 Token,自动静默刷新 access token,自动重试失效请求,自动拦截报错,自动剥离无用数据层,并对 blob 这类特殊响应做兼容处理。组件里的代码将变得极度纯粹。

补充说明: 企业项目里常见两种“登录失效”约定:

  1. HTTP 状态码 401:通常表示这次请求从协议层面就未通过认证。
  2. 业务状态码(如 40101:表示 HTTP 请求本身成功,但业务上判定 Token 已失效。
  3. 刷新接口要尽量绕开当前 service 实例:否则刷新请求自己也可能再次进入这套拦截器,造成递归调用。
  4. 失败请求只自动重试一次:通过 _retry 标记避免无限循环。
  5. 多个请求同时过期时要排队:这样只会发出一次刷新请求,后续请求等刷新成功后再继续。

这两种情况都很常见,所以拦截器里最好都覆盖到。


4. API 接口模块化管理

封装好 request.js 后,我们是不是直接在 Vue 组件里引入它来发请求呢? 不推荐。

如果把接口路径散落在几十个 .vue 文件里,一旦后端改了某个接口的 URL,你就要满世界去找。

专业的做法是:将接口按照业务模块统一管理。 在 src 目录下新建 api 文件夹。例如,有关用户的接口,我们放在 src/api/user.js 中:

// src/api/user.js
import request from '@/utils/request'

/**
 * 登录接口
 * @param {Object} data - 包含 username 和 password 的对象
 */
export function login(data) {
  return request({
    url: '/user/login',
    method: 'post',
    data // post 请求的数据用 data 传递
  })
}

/**
 * 获取用户详情
 * @param {Number} id - 用户 ID
 */
export function getUserInfo(id) {
  return request({
    url: `/user/info/${id}`,
    method: 'get'
    // get 请求如果带参数,用 params 传递:params: { id }
  })
}

这样,所有的 API 接口都成了一个个纯粹的 JavaScript 函数,拥有清晰的注释,高度复用。


5. 在 Vue 组件中优雅地调用

现在,让我们看看在 .vue 组件中,网络请求的代码变得多么干净:

<template>
  <div class="login-box">
    <el-form>
      <!-- 表单绑定省略... -->
      <el-button :loading="loading" @click="handleLogin">登录</el-button>
    </el-form>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { login } from '@/api/user' // 只引入需要的 API 函数
import { ElMessage } from 'element-plus'

const router = useRouter()
const loading = ref(false)
const formData = reactive({ username: 'admin', password: '123' })

const handleLogin = async () => {
  loading.value = true
  try {
    // 1. 调用 API 函数
    const res = await login(formData)

    // 2. 因为响应拦截器已经处理了报错并剥离了外层,这里拿到的直接是核心数据
    // 这里假设后端返回的是 { accessToken, refreshToken }
    console.log('登录成功,获取到 access token:', res.accessToken)

    // 3. 存储登录态(实际通常存入 Pinia + localStorage)
    localStorage.setItem('token', res.accessToken)
    localStorage.setItem('refreshToken', res.refreshToken)

    // 4. 提示成功并跳转
    ElMessage.success('登录成功,欢迎回来!')
    router.push('/')

  } catch (error) {
    // 如果走到这里,说明账号密码错误或网络异常
    // 但你完全不需要在这里写 ElMessage.error()!
    // 因为 request.js 里的响应拦截器已经替你弹出过红色错误提示了!
    console.log('登录失败已拦截')
  } finally {
    loading.value = false
  }
}
</script>

6. 进阶场景:文件上传与下载

在后台管理系统中,文件上传和导出 Excel 是极其高频的需求。

6.1 文件上传 (FormData)

上传文件时,我们需要将数据包装成 FormData 对象,并且通常需要设置 Content-Typemultipart/form-data。不过,现代浏览器和 Axios 会自动识别 FormData 并设置正确的请求头,所以我们只需要这样写:

// src/api/file.js
import request from '@/utils/request'

export const uploadFile = (file) => {
  const formData = new FormData()
  formData.append('file', file)
  // 如果有其他参数,也可以继续 append
  // formData.append('type', 'avatar')

  return request({
    url: '/api/upload',
    method: 'post',
    data: formData
    // 注意:不需要手动设置 headers: { 'Content-Type': 'multipart/form-data' }
    // Axios 会自动处理
  })
}

6.2 文件下载 (Blob)

当后端返回的是文件流(如 Excel、PDF)而不是 JSON 时,我们需要告诉 Axios 把响应数据当作 blob 处理,否则文件会损坏。

// src/api/file.js
export const downloadExcel = (params) => {
  return request({
    url: '/api/export',
    method: 'get',
    params,
    responseType: 'blob' // 关键:告诉 Axios 期望接收二进制数据
  })
}

在组件中处理下载:

import { downloadExcel } from '@/api/file'

const handleExport = async () => {
  try {
    const res = await downloadExcel({ status: 1 })
    
    // 这里之所以能直接拿到 Blob,
    // 是因为前面的响应拦截器已经对 responseType: 'blob' 做了直接放行
    
    // 创建一个临时的 a 标签用于下载
    const url = window.URL.createObjectURL(new Blob([res]))
    const link = document.createElement('a')
    link.href = url
    link.setAttribute('download', '导出数据.xlsx') // 设置文件名
    document.body.appendChild(link)
    link.click()
    
    // 清理 DOM 和释放内存
    document.body.removeChild(link)
    window.URL.revokeObjectURL(url)
  } catch (error) {
    console.error('导出失败', error)
  }
}

7. 进阶场景:取消重复请求 (AbortController)

在复杂的后台系统中,用户可能会频繁点击搜索按钮,或者在请求还没完成时就切换了路由。如果不取消上一次的请求,可能会导致旧数据覆盖新数据(竞态问题),或者浪费服务器资源。

Axios 从 v0.22.0 开始支持原生的 AbortController 来取消请求。

// src/api/search.js
import request from '@/utils/request'

// 1. 声明一个变量保存控制器
let abortController = null

export const searchData = (keyword) => {
  // 2. 如果上一次请求还没完成,先取消它
  if (abortController) {
    abortController.abort('取消了上一次的重复请求')
  }
  
  // 3. 创建一个新的控制器
  abortController = new AbortController()

  return request({
    url: '/api/search',
    method: 'get',
    params: { keyword },
    // 4. 将 signal 传给 Axios
    signal: abortController.signal
  })
}

在前面的主响应拦截器中,我们已经忽略了这种主动取消导致的报错,避免给用户弹出错误提示。对应逻辑如下:

// src/utils/request.js 响应拦截器 error 部分
error => {
  // 如果是主动取消的请求,不报错
  if (axios.isCancel(error)) {
    console.log('请求被取消:', error.message)
    return Promise.reject(error)
  }
  
  // 其他错误正常提示...
  ElMessage.error(error.message || '请求失败')
  return Promise.reject(error)
}

8. 总结

通过 Axios 封装和 API 接口管理,我们实现了前端工程化中极其重要的一步:关注点分离

  • utils/request.js:专门处理网络层的脏活累活(Header 配置、Token 处理、全局错误拦截)。
  • api/*.js:专门充当接口字典,管理 URL 和请求参数映射。
  • .vue 组件:只关心业务逻辑——“什么时候发请求”、“拿到数据后怎么渲染页面”,彻底摆脱了冗长的网络处理代码。

到此为止,你的前端项目架构已经完全达到了企业级开发的标准。接下来,万事俱备,我们可以真正开始搭建一个完整的后台管理系统了!