第4章 4.3 网络请求:axios 封装
⚠️ 声明:本文是 Vue3 系列教程的第 18 章,主题是 Vue3 中的网络请求封装。很多同学误以为这是 Python 教程,实际上 axios 是 JavaScript 生态的 HTTP 客户端,所以本文使用 TypeScript + Vue3 Composition API 讲解。但核心思想(封装、拦截器、统一错误处理)是相通的,Python 开发者也能从中受益。
🎯 开场 3 分钟:为什么要学这个?
上一章我们搞定了 Pinia 状态管理,终于能让数据在组件之间自由流动了。但是——数据从哪来?
你肯定遇到过这些糟心事:
- 「接口地址改了一个字,整个项目要改 50 个地方」
- 「后端接口挂了,前端直接白屏,用户体验为零」
- 「每个组件里都写一堆
fetch/axios.get(),代码长得像面条」
这些问题归根结底:你的网络请求没有封装好。
学完这一章,你能:
- 写出一个「axios 实例」,一处配置,全项目生效
- 写出「请求\n\n
\n\n
\n\n/响应拦截器」,统一处理 loading、错误、token - 封装一个
useRequestcomposable,3 行代码搞定接口调用
🧱 基础 25 分钟:核心概念
4.3.1 先搞清楚:为什么需要封装 axios?
想象你点外卖:
- 没封装 = 每次点餐都要自己打开外卖 App → 选店 → 选菜 → 填地址 → 付款 → 等快递
- 封装后 = 打个电话给助理:「给我订个外卖」,助理帮你搞定一切
axios 封装就是给你配一个「请求助理」,你只管说「我要什么」,它帮你处理重复的杂事。
4.3.2 创建 axios 实例
// src/utils/request.ts
import axios from 'axios'
// 创建一个「助理」实例
const request = axios.create({
baseURL: '/api', // 所有请求的「起步价」——会自动拼接
timeout: 10000, // 等 10 秒还没响应?就放弃吧
headers: { // 默认 header,以后每个请求都会带
'Content-Type': 'application/json'
}
})
export default request
这行在干嘛:创建一个 axios 实例,配置了基础 URL、超时时间、默认请求头。以后所有接口都基于这个实例来发请求。
4.3.3 请求拦截器——「发车前的检查」
// src/utils/request.ts(续)
import { useUserStore } from '@/stores/user'
// 请求拦截器:每次发请求之前自动执行
request.interceptors.request.use(
(config) => {
// 从 Pinia 获取 token
const userStore = useUserStore()
if (userStore.token) {
// 偷偷把 token 塞到 header 里
config.headers.Authorization = `Bearer ${userStore.token}`
}
// 打开全局 loading(后面讲)
console.log('🚀 发请求了:', config.method?.toUpperCase(), config.url)
return config // 检查通过,放行!
},
(error) => {
// 检查没通过,直接报错
return Promise.reject(error)
}
)
这行在干嘛:在请求发出去之前,自动把 token 塞到 header 里,这样每个接口都自动带着身份信息。
4.3.4 响应拦截器——「快递签收的统一话术」
// src/utils/request.ts(续)
// 响应拦截器:每次收到响应之后自动执行
request.interceptors.response.use(
(response) => {
// 状态码 2xx 的响应会进到这里
console.log('✅ 收到响应:', response.config.url)
return response.data // 只把 data 返回去,别的不要
},
(error) => {
// 状态码不是 2xx 的响应会进到这里
console.error('❌ 请求失败了:', error.response?.status, error.message)
// 统一处理错误
switch (error.response?.status) {
case 401:
// 没登录?跳去登录页
window.location.href = '/login'
break
case 403:
alert('没有权限访问')
break
case 500:
alert('服务器抽风了,请稍后再试')
break
}
return Promise.reject(error) // 把错误继续抛出去,让组件知道出错了
}
)
这行在干嘛:收到服务器响应后,先统一判断状态码。401 就跳转登录页,500 就弹窗报错,而不是让页面直接崩溃。
4.3.5 封装 useRequest——「一句话发请求」
把上面这些组合起来,封装成一个 useRequest composable:
// src/composables/useRequest.ts
import request from '@/utils/request'
import { ref } from 'vue'
export function useRequest<T = any>(url: string) {
const data = ref<T | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
const execute = async (params?: any) => {
loading.value = true
error.value = null
try {
// 发请求,结果存到 data 里
data.value = await request.get(url, { params })
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
return { data, loading, error, execute }
}
这行在干嘛:把「发请求、存结果、记状态」这些重复操作包进一个函数,以后组件里 3 行代码就能发请求了。
🔥 实战 35 分钟:3 个递进的小项目
📦 项目 1:用户列表查询(5 分钟)
需求:从 /api/users 获取用户列表,展示在页面上。
<!-- src/views/UserList.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRequest } from '@/composables/useRequest'
interface User {
id: number
name: string
email: string
}
// 用 3 行代码发请求
const { data: users, loading, error, execute } = useRequest<User[]>('/users')
onMounted(() => {
execute()
})
</script>
<template>
<div>
<h1>用户列表</h1>
<!-- 加载中 -->
<div v-if="loading">加载中...</div>
<!-- 报错 -->
<div v-else-if="error" style="color: red">
出错了: {{ error.message }}
</div>
<!-- 成功 -->
<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }} - {{ user.email }}
</li>
</ul>
</div>
</template>
预期输出:
加载中...
(1秒后)
张三 - zhangsan@example.com
李四 - lisi@example.com
王五 - wangwu@example.com
一句话解释:我们用 useRequest 发请求,它自动帮我们管理了 loading 和 error 状态,组件只需要关心怎么展示。
📦 项目 2:搜索过滤功能(15 分钟)
需求:在项目 1 基础上,加一个搜索框,能按名字筛选用户。
先看一下服务端返回的数据结构:
{
"users": [
{ "id": 1, "name": "张三", "email": "zhangsan@example.com", "department": "技术部" },
{ "id": 2, "name": "李四", "email": "lisi@example.com", "department": "产品部" },
{ "id": 3, "name": "王五", "email": "wangwu@example.com", "department": "技术部" }
]
}
改动后的组件:
<!-- src/views/UserSearch.vue -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRequest } from '@/composables/useRequest'
interface User {
id: number
name: string
email: string
department: string
}
const { data, loading, execute } = useRequest<{ users: User[] }>('/users')
const searchKeyword = ref('')
// 搜索框绑定的值
// 计算属性:过滤后的用户列表
const filteredUsers = computed(() => {
if (!data.value?.users) return []
const keyword = searchKeyword.value.trim().toLowerCase()
return data.value.users.filter(user =>
user.name.toLowerCase().includes(keyword)
)
})
// 按部门过滤
const selectedDept = ref('')
const filteredByDept = computed(() => {
if (!selectedDept.value) return filteredUsers.value
return filteredUsers.value.filter(u => u.department === selectedDept.value)
})
// 获取部门列表(用于下拉框)
const departments = computed(() => {
if (!data.value?.users) return []
return [...new Set(data.value.users.map(u => u.department))]
})
onMounted(() => execute())
</script>
<template>
<div>
<h1>用户搜索</h1>
<!-- 搜索框 -->
<input
v-model="searchKeyword"
placeholder="输入名字搜索..."
style="padding: 8px; width: 200px; margin-right: 10px"
/>
<!-- 部门筛选 -->
<select v-model="selectedDept" style="padding: 8px">
<option value="">全部部门</option>
<option v-for="dept in departments" :key="dept" :value="dept">
{{ dept }}
</option>
</select>
<p>共 {{ filteredByDept.length }} 个用户</p>
<div v-if="loading">加载中...</div>
<table v-else border="1" cellPadding="8" style="border-collapse: collapse; width: 100%">
<thead>
<tr>
<th>ID</th>
<th>姓名</th>
<th>邮箱</th>
<th>部门</th>
</tr>
</thead>
<tbody>
<tr v-for="user in filteredByDept" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.department }}</td>
</tr>
</tbody>
</table>
</div>
</template>
预期输出:
[输入: "张"] [部门: 全部部门]
共 1 个用户
| ID | 姓名 | 邮箱 | 部门 |
|----|------|------|------|
| 1 | 张三 | zhangsan@example.com | 技术部 |
一句话解释:请求只发一次(execute()),数据存在 data 里,然后通过计算属性 filteredUsers 和 filteredByDept 做实时筛选,前端过滤比后端更流畅。
📦 项目 3:待办事项管理小工具(15 分钟)
需求:做一个简单的待办事项管理,支持「添加」「完成」「删除」,数据来自 /api/todos 接口。
// src/types/todo.ts
export interface Todo {
id: number
text: string
done: boolean
createdAt: string
}
<!-- src/views/TodoApp.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import request from '@/utils/request'
import { Todo } from '@/types/todo'
const todos = ref<Todo[]>([])
const newTodoText = ref('')
const loading = ref(false)
// 加载待办列表
const loadTodos = async () => {
loading.value = true
try {
const res = await request.get<{ todos: Todo[] }>('/todos')
todos.value = res.todos
} finally {
loading.value = false
}
}
// 添加待办
const addTodo = async () => {
if (!newTodoText.value.trim()) return
await request.post('/todos', { text: newTodoText.value })
newTodoText.value = ''
await loadTodos() // 重新加载
}
// 切换完成状态
const toggleTodo = async (id: number) => {
await request.patch(`/todos/${id}/toggle`)
await loadTodos()
}
// 删除待办
const deleteTodo = async (id: number) => {
if (!confirm('确定删除?')) return
await request.delete(`/todos/${id}`)
await loadTodos()
}
onMounted(loadTodos)
</script>
<template>
<div style="max-width: 500px; margin: 0 auto; padding: 20px">
<h1>📝 我的待办</h1>
<!-- 输入框 -->
<div style="display: flex; gap: 10px; margin-bottom: 20px">
<input
v-model="newTodoText"
@keyup.enter="addTodo"
placeholder="输入新待办..."
style="flex: 1; padding: 10px; font-size: 16px"
/>
<button
@click="addTodo"
style="padding: 10px 20px; font-size: 16px; cursor: pointer"
>
添加
</button>
</div>
<!-- 加载中 -->
<div v-if="loading">加载中...</div>
<!-- 待办列表 -->
<ul style="list-style: none; padding: 0">
<li
v-for="todo in todos"
:key="todo.id"
style="display: flex; align-items: center; gap: 10px; padding: 10px; border-bottom: 1px solid #eee"
>
<input
type="checkbox"
:checked="todo.done"
@change="toggleTodo(todo.id)"
/>
<span
:style="{
flex: 1,
textDecoration: todo.done ? 'line-through' : 'none',
color: todo.done ? '#999' : '#333'
}"
>
{{ todo.text }}
</span>
<span style="color: #999; font-size: 12px">
{{ new Date(todo.createdAt).toLocaleDateString() }}
</span>
<button
@click="deleteTodo(todo.id)"
style="color: red; background: none; border: none; cursor: pointer"
>
🗑️
</button>
</li>
</ul>
<!-- 统计 -->
<p style="color: #666; margin-top: 20px">
完成 {{ todos.filter(t => t.done).length }} / {{ todos.length }}
</p>
</div>
</template>
预期输出:
📝 我的待办
[输入: "买牛奶" ] [添加]
☐ 整理衣柜 2024/1/15 🗑️
☑ 写周报 2024/1/14 🗑️
☐ 打电话给妈妈 2024/1/13 🗑️
完成 1 / 3
一句话解释:这个组件直接用封装好的 request 实例发请求,增删改查都只需要 1 行代码。axios 封装的好处在这里体现得淋漓尽致——业务逻辑和 HTTP 细节完全分离。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:请求拦截器里用了 Vue 的响应式 API,但没有在正确的时机获取
// ❌ 错误示例:在请求拦截器里直接获取 store
request.interceptors.request.use((config) => {
// 报错:getActivePinia() was called with no active Pinia
const userStore = useUserStore()
config.headers.Authorization = `Bearer ${userStore.token}`
return config
})
// ✅ 正确示例:用 callback 形式延迟获取
let getToken: () => string | null = () => null
export function setTokenGetter(fn: () => string | null) {
getToken = fn
}
request.interceptors.request.use((config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
原因:请求拦截器在 Vue 实例创建之前就可能执行了,这时候 Pinia 还没初始化。
坑 2:响应拦截器里直接 return response,导致组件拿不到正确的数据结构
// ❌ 错误示例:返回了整个 response
request.interceptors.response.use(
(response) => {
return response // 组件里要 response.data.users 才能拿到数据
}
)
// ✅ 正确示例:只返回 data
request.interceptors.response.use(
(response) => {
return response.data // 组件里直接 response.users 就行
}
)
坑 3:在拦截器里弹 alert 中断用户操作
// ❌ 错误示例:alert 会阻塞 JS 执行,用户体验差
request.interceptors.response.use(
(error) => {
if (error.response?.status === 500) {
alert('服务器错误') // 阻塞!
}
return Promise.reject(error)
}
)
// ✅ 正确示例:用 message 这个全局通知组件
import { message } from 'ant-design-vue'
request.interceptors.response.use(
(error) => {
if (error.response?.status === 500) {
message.error('服务器错误,请稍后再试')
}
return Promise.reject(error)
}
)
坑 4:忘记处理请求失败的情况,导致 loading 一直是 true
// ❌ 错误示例:没有 finally,请求失败时 loading 永远是 true
const fetchData = async () => {
loading.value = true
await request.get('/users')
loading.value = false // 如果请求失败,这行不会执行
}
// ✅ 正确示例:用 try...finally 确保 loading 一定能重置
const fetchData = async () => {
loading.value = true
try {
await request.get('/users')
} finally {
loading.value = false // 无论成功还是失败,都会执行
}
}
坑 5:并发请求时 token 互相覆盖
如果你的刷新 token 逻辑写得不对,并发请求可能导致 token 互相覆盖。
解决方案:用队列机制,保证同一时间只有一个刷新请求在执行。这部分较复杂,建议直接用 axios-auth-refresh 库。
💡 性能小贴士:对频繁请求做防抖
搜索场景下,用户每输入一个字就发请求,会把服务器打爆:
// 300ms 内如果还在输入,就取消之前的请求
let searchTimer: number | null = null
const onSearchInput = (e: Event) => {
const keyword = (e.target as HTMLInputElement).value
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
execute({ keyword }) // 真正发请求
}, 300)
}
🔍 调试技巧:用请求拦截器打印完整请求信息
request.interceptors.request.use((config) => {
console.group(`📤 请求: ${config.method?.toUpperCase()} ${config.url}`)
console.log('params:', config.params)
console.log('data:', config.data)
console.log('headers:', config.headers)
console.groupEnd()
return config
})
request.interceptors.response.use(
(response) => {
console.group(`📥 响应: ${response.config.url}`)
console.log('status:', response.status)
console.log('data:', response.data)
console.groupEnd()
return response
},
(error) => {
console.group(`❌ 错误: ${error.config?.url}`)
console.log('status:', error.response?.status)
console.log('message:', error.message)
console.groupEnd()
return Promise.reject(error)
}
)
打开控制台,所有请求一目了然。
✏️ 练习题 + 作业题
练习题(10 分钟)
练习 1(2 分钟):改 baseURL
- 输入:将 baseURL 从 /api 改成 /v2/api
- 预期输出:所有请求的 URL 前面都会自动加上 /v2/api 前缀
- 提示:在 request.ts 的 axios.create() 里改一行配置
练习 2(2 分钟):加一个请求失败时自动重试
- 输入:在响应拦截器的错误处理里,加一个 console.log('请求失败了,重试中...')
- 预期输出:接口报错时控制台会打印这条消息
- 提示:找 interceptors.response.use 的第二个参数
练习 3(3 分钟):给 useRequest 加一个 isEmpty 状态
- 输入:数据加载完成但数组为空时,显示「暂无数据」
- 预期输出:接口返回空数组时,显示「暂无数据」而不是空白
- 提示:在 useRequest 的返回值里加一个 isEmpty 计算属性
练习 4(3 分钟):给 TodoApp 加一个「标记全部完成」按钮
- 输入:点击按钮,所有待办都变成已完成
- 预期输出:批量调用 toggleTodo,所有 todo 的 done 变成 true
- 提示:可以用 Promise.all() 并行发送多个请求
练习 5(挑战题,5 分钟):分析这个报错
- 输入:控制台报错 Error: getActivePinia() was called with no active Pinia
- 预期输出:说出报错原因,并写出修复方案
- 提示:见进阶部分的「坑 1」
作业题(30 分钟 - 2 小时)
作业:做一个「天气查询小工具」
- 需求描述:做一个天气查询页面,用户输入城市名,查询该城市的天气
- 功能点:
1. 输入城市名,点击查询(或按回车)发送请求
2. 显示天气、温度、湿度等基本信息
3. 请求失败时显示友好错误提示(城市不存在/网络错误) - 加分项:
1. 加一个「最近搜索记录」功能(用 localStorage 保存)
2. 加一个 Loading 动画(不能用简单的「加载中...」文字) - 验收标准:
- 能跑起来
- 输入「北京」能显示北京的天气
- 输入不存在的城市(如「火星」)能显示「城市不存在」
- 代码有适量注释
- 提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本文学了 3 个核心点
- axios 实例:把公共配置(baseURL、timeout、headers)抽到一个实例里,一处配置,全项目生效
- 拦截器:请求拦截器统一加 token,响应拦截器统一处理错误和格式化数据
- useRequest composable:把「发请求 + 存状态 + 管 loading」这些重复操作封装起来,组件里 3 行代码搞定
延伸学习资源
- Axios 中文文档 — 官方文档的中文翻译,写得很详细
- VueUse useFetch — Vue 生态里更强大的请求 Hook,直接用就行
- 《Vue3 设计与实现》— 如果你想深入理解 Composition API 的设计思想,这本书值得看
你在项目里用过 axios 封装吗?有没有踩过什么奇葩的坑? 评论区聊聊,老粉优先回复!
📌 预告:下一章我们要解决一个很实际的问题——表单与校验。你辛辛苦苦查来的数据,怎么让用户安全地「送回来」?表单校验怎么做才能既优雅又不啰嗦?剧透一下:我们会用到这一章学的 axios 封装来「提交」表单数据。敬请期待!

评论(0)