第4章 4.3 网络请求:axios 封装

⚠️ 声明:本文是 Vue3 系列教程的第 18 章,主题是 Vue3 中的网络请求封装。很多同学误以为这是 Python 教程,实际上 axios 是 JavaScript 生态的 HTTP 客户端,所以本文使用 TypeScript + Vue3 Composition API 讲解。但核心思想(封装、拦截器、统一错误处理)是相通的,Python 开发者也能从中受益。


🎯 开场 3 分钟:为什么要学这个?

上一章我们搞定了 Pinia 状态管理,终于能让数据在组件之间自由流动了。但是——数据从哪来?

你肯定遇到过这些糟心事:

  • 「接口地址改了一个字,整个项目要改 50 个地方」
  • 「后端接口挂了,前端直接白屏,用户体验为零」
  • 「每个组件里都写一堆 fetch / axios.get(),代码长得像面条」

这些问题归根结底:你的网络请求没有封装好

学完这一章,你能:

  • 写出一个「axios 实例」,一处配置,全项目生效
  • 写出「请求\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\n\n/响应拦截器」,统一处理 loading、错误、token
  • 封装一个 useRequest composable,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 里,然后通过计算属性 filteredUsersfilteredByDept 做实时筛选,前端过滤比后端更流畅。


📦 项目 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.tsaxios.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 个核心点

  1. axios 实例:把公共配置(baseURL、timeout、headers)抽到一个实例里,一处配置,全项目生效
  2. 拦截器:请求拦截器统一加 token,响应拦截器统一处理错误和格式化数据
  3. useRequest composable:把「发请求 + 存状态 + 管 loading」这些重复操作封装起来,组件里 3 行代码搞定

延伸学习资源

  1. Axios 中文文档 — 官方文档的中文翻译,写得很详细
  2. VueUse useFetch — Vue 生态里更强大的请求 Hook,直接用就行
  3. 《Vue3 设计与实现》— 如果你想深入理解 Composition API 的设计思想,这本书值得看

你在项目里用过 axios 封装吗?有没有踩过什么奇葩的坑? 评论区聊聊,老粉优先回复!

📌 预告:下一章我们要解决一个很实际的问题——表单与校验。你辛辛苦苦查来的数据,怎么让用户安全地「送回来」?表单校验怎么做才能既优雅又不啰嗦?剧透一下:我们会用到这一章学的 axios 封装来「提交」表单数据。敬请期待!

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。