在上一节《组件接上数据:Vue 3 + Axios 请求页面实战》中,我们已经把 axios 正式接进了 Vue 组件,学会了在 onMounted() 中请求接口、渲染列表、处理加载状态和提交表单。
但如果项目一旦变大,你很快就会发现:只会在组件里直接写 axios.get() 和 axios.post() 还远远不够。
在真实的 Vue / Vite 企业项目中,一个非常常见的选择就是第三方库 —— Axios。
为什么放着原生的 fetch 不用,非要引入一个额外的库?在前端工程化中,我们应该如何优雅地管理成百上千个接口?这篇文章将带你完成前端网络请求的“工业化改造”。
1. 为什么选择 Axios 而不是 Fetch?
fetch 足够简单,但它太底层了。在实际业务中,我们每次发请求都需要处理很多“琐事”:
- 自动转换 JSON:
fetch需要手动调用response.json(),而 Axios 会自动帮你转换。 - 错误处理机制:正如我们之前提到的,
fetch遇到404或500状态码时,不会进入catch块,必须手动判断response.ok。Axios 只要状态码不在 2xx 范围内,就会自动抛出错误进入catch。 - 全局拦截器 (Interceptors)(核心痛点):
- 痛点 1:用户登录后,后端要求后续的每一次请求都在 Header 里带上
Authorization: Bearer <Token>。如果用fetch,你必须在每个请求的地方手动拼接。 - 痛点 2:如果 Token 过期,后端返回
401,你需要把用户踢回登录页。如果用fetch,你要在每个页面写一遍判断逻辑。 - Axios 提供的拦截器完美解决了这些问题,它可以统一在请求发出去之前或收到响应之后做全局处理。
- 痛点 1:用户登录后,后端要求后续的每一次请求都在 Header 里带上
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 这类特殊响应做兼容处理。组件里的代码将变得极度纯粹。
补充说明: 企业项目里常见两种“登录失效”约定:
- HTTP 状态码
401:通常表示这次请求从协议层面就未通过认证。- 业务状态码(如
40101):表示 HTTP 请求本身成功,但业务上判定 Token 已失效。- 刷新接口要尽量绕开当前
service实例:否则刷新请求自己也可能再次进入这套拦截器,造成递归调用。- 失败请求只自动重试一次:通过
_retry标记避免无限循环。- 多个请求同时过期时要排队:这样只会发出一次刷新请求,后续请求等刷新成功后再继续。
这两种情况都很常见,所以拦截器里最好都覆盖到。
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-Type 为 multipart/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组件:只关心业务逻辑——“什么时候发请求”、“拿到数据后怎么渲染页面”,彻底摆脱了冗长的网络处理代码。
到此为止,你的前端项目架构已经完全达到了企业级开发的标准。接下来,万事俱备,我们可以真正开始搭建一个完整的后台管理系统了!