Vue 3 深入学习笔记 16:API 请求封装与统一错误处理
一、为什么需要统一封装 API 请求?
在小型项目中,我们可能直接在组件里调用 Axios:
ts
import axios from 'axios'
const res = await axios.get('/api/users')
console.log(res.data)
这种写法本身没有问题,但随着项目规模增大,会逐渐出现一些问题。
1. 请求代码重复
不同组件都需要配置:
- API 基础地址
- 请求超时时间
- Token
- 请求头
- 错误处理
- Loading 状态
如果每个组件都自己处理,代码就会越来越难以维护。
2. 错误处理不统一
例如,多个接口都可能返回 HTTP 401:
ts
if (error.response?.status === 401) {
// 跳转登录页
}
如果每个 API 都写一遍,不仅重复,而且容易出现处理逻辑不一致的问题。
3. 组件与网络请求耦合
不推荐的写法:
vue
<script setup lang="ts">
import axios from 'axios'
async function loadUsers() {
try {
const res = await axios.get('/api/users')
// 直接处理接口响应
console.log(res.data)
} catch (error) {
console.error(error)
}
}
</script>
更好的分层方式是:
text
Vue 组件
↓
业务 API 模块
↓
统一请求封装
↓
Axios
↓
后端服务器
每一层只负责自己的事情。
| 层级 | 主要职责 |
|---|---|
| Vue 组件 | 展示数据、响应用户操作 |
| 业务 API 模块 | 定义用户、订单等业务接口 |
| 请求封装层 | Token、拦截器、统一错误处理 |
| Axios | 执行 HTTP 请求 |
| 后端 | 业务逻辑、权限校验、数据处理 |
核心原则:组件不应该关心请求是如何发送的,而应该关心业务数据是什么。
二、Axios 的核心机制
Axios 是一个基于 Promise 的 HTTP 客户端,可以在浏览器和 Node.js 环境中使用。
官方文档: https://axios-http.com/
1. 为什么要创建 Axios 实例?
不推荐所有业务模块都直接使用全局 Axios:
ts
axios.get('/api/users')
axios.post('/api/login', data)
更推荐创建一个独立实例:
ts
import axios from 'axios'
const request = axios.create({
baseURL: '/api',
timeout: 10000,
})
export default request
这样做有几个好处:
- 统一管理
baseURL。 - 统一管理超时时间。
- 可以单独配置请求和响应拦截器。
- 不影响项目中其他 Axios 实例。
- 方便针对不同后端服务创建不同实例。
例如:
ts
const userRequest = axios.create({
baseURL: '/user-api',
})
const orderRequest = axios.create({
baseURL: '/order-api',
})
两个实例可以分别请求不同的服务。
2. Axios 请求的执行流程
理解拦截器之前,需要先理解请求的整体流程。
text
业务 API 调用
↓
请求拦截器
↓
发送 HTTP 请求
↓
收到服务器响应
↓
响应拦截器
↓
返回业务 API
↓
Vue 组件处理结果
请求拦截器通常负责:
- 注入 Token。
- 添加公共请求头。
- 设置请求追踪标识。
- 处理请求发送前的公共逻辑。
响应拦截器通常负责:
- 提取响应数据。
- 处理 HTTP 错误。
- 处理登录失效。
- 统一转换错误对象。
注意:响应拦截器既可以处理成功响应,也可以处理失败响应。
三、HTTP 错误与业务错误有什么区别?
这是请求封装中非常重要的知识点。
1. HTTP 错误
HTTP 状态码反映服务器对请求的处理结果。
常见状态码包括:
| 状态码 | 含义 | 常见处理方式 |
|---|---|---|
| 400 | 请求错误 | 检查请求参数 |
| 401 | 未认证或身份凭证失效 | 根据认证机制处理登录状态 |
| 403 | 禁止访问 | 提示权限不足 |
| 404 | 资源不存在 | 检查接口地址或资源 |
| 500 | 服务器内部错误 | 提示服务异常 |
| 502 | 网关错误 | 检查上游服务 |
| 503 | 服务暂时不可用 | 提示稍后重试 |
Axios 默认会将通常不在 200--299 范围内的 HTTP 响应视为请求失败。
例如:
ts
try {
await request.get('/users')
} catch (error) {
// HTTP 4xx、5xx 等情况通常会进入这里
}
但要注意,网络断开、DNS 错误、超时等情况也可能进入 catch,而且它们不一定有 error.response。
2. 业务错误
有些请求在 HTTP 层面是成功的,但业务处理失败。
例如,服务器返回:
json
{
"code": 10001,
"message": "用户余额不足",
"data": null
}
HTTP 状态码可能是 200。
此时:
- HTTP 请求成功。
- Axios 通常会进入响应成功拦截器。
- 但业务操作失败。
因此,必须区分两类错误:
HTTP 错误:请求在 HTTP 层面失败。
业务错误:请求成功到达业务处理流程,但业务规则不允许操作成功。
需要注意,具体业务错误码和响应结构由后端接口契约决定,并不是所有项目都使用 code、message 和 data 这种格式。
3. 不要把所有错误都放在响应拦截器里处理
响应拦截器适合处理全局问题,例如:
- Token 失效。
- 服务器不可用。
- 统一的错误对象转换。
但以下错误通常更适合由业务模块或组件处理:
- 用户名已存在。
- 订单库存不足。
- 表单校验失败。
- 当前用户没有满足某项业务条件。
原因很简单:请求封装层不知道这些业务错误应该如何展示。
全局处理基础设施错误,业务层处理业务规则错误。
四、完整实战:构建 Vue 3 + TypeScript + Axios 请求层
本节只实现一套完整的请求封装,包括:
- Axios 实例。
- 请求拦截器。
- 响应拦截器。
- 统一错误处理。
- 业务 API 模块。
- Vue 组件调用。
- 请求取消。
假设项目使用 Vue 3、Vite 和 TypeScript。
安装 Axios:
bash
npm install axios
1. 推荐的目录结构
text
src/
├── api/
│ └── user.ts
├── components/
├── types/
│ └── user.ts
├── utils/
│ └── request.ts
├── App.vue
└── main.ts
各个文件的职责:
utils/request.ts:统一请求基础设施。api/user.ts:用户业务接口。types/user.ts:用户数据类型。App.vue:页面展示和用户交互。
2. 定义业务数据类型
创建 src/types/user.ts:
ts
export interface User {
id: number
name: string
email: string
}
export interface ApiResponse<T> {
code: number
message: string
data: T
}
export interface PageResult<T> {
list: T[]
total: number
}
这里定义了两个不同层次的类型。
User 表示用户实体。
ApiResponse<T> 表示后端统一的业务响应结构。
例如:
ts
const result: ApiResponse<User[]> = {
code: 0,
message: 'success',
data: [
{
id: 1,
name: 'Alice',
email: 'alice@example.com',
},
],
}
这里使用了 TypeScript 泛型。
ts
ApiResponse<User[]>
表示:
text
业务响应
└── data
└── User[]
需要特别注意:TypeScript 类型不会在运行时自动验证后端返回的数据。
如果后端实际返回的数据结构不符合接口契约,单纯声明 ApiResponse<User[]> 并不能保证数据安全。
对于数据来源不可信、接口结构复杂的场景,可以进一步使用 Zod 等运行时校验工具。
3. 创建 Axios 实例与拦截器
创建 src/utils/request.ts。
这部分是整个实战的核心。
ts
import axios from 'axios'
import type {
AxiosError,
InternalAxiosRequestConfig,
} from 'axios'
export interface ApiError {
message: string
status?: number
code?: string | number
}
const request = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// 请求拦截器
request.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error: unknown) => {
return Promise.reject(error)
},
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
// 保留完整 AxiosResponse,业务模块决定如何取 data
return response
},
(error: AxiosError<{ message?: string; code?: string | number }>) => {
const status = error.response?.status
const apiError: ApiError = {
message: '请求失败,请稍后重试',
status,
code: error.code,
}
if (error.response) {
// 服务器返回了 HTTP 错误状态码
switch (status) {
case 401:
apiError.message = '登录状态可能已失效,请重新登录'
break
case 403:
apiError.message = '没有权限执行此操作'
break
case 404:
apiError.message = '请求的资源不存在'
break
case 500:
apiError.message = '服务器内部错误'
break
default:
apiError.message =
error.response.data?.message ||
`请求失败:HTTP ${status}`
}
} else if (error.code === 'ECONNABORTED') {
// Axios 默认超时错误常见的 code
apiError.message = '请求超时,请稍后重试'
} else if (error.request) {
// 请求已发出,但没有收到可用响应
apiError.message = '网络异常,未收到服务器响应'
} else {
// 请求配置或请求创建阶段出错
apiError.message = error.message || '请求初始化失败'
}
return Promise.reject(apiError)
},
)
export default request
这段代码需要理解的几个关键点
第一:为什么请求拦截器要返回 config?
ts
return config
Axios 需要通过这个配置对象继续执行请求。
如果拦截器没有返回配置对象,请求链就无法按预期继续执行。
第二:为什么响应拦截器失败时使用 Promise.reject?
ts
return Promise.reject(apiError)
这样调用方才能通过 try...catch 接收到统一格式的错误:
ts
try {
await getUsers()
} catch (error) {
// error 是经过统一处理的错误对象
}
这里主动拒绝 Promise,而不是直接返回错误对象,是为了保留请求失败的语义。
如果直接:
ts
return apiError
那么 Promise 链会把它当作一次成功完成的结果处理。
这会导致调用方的 catch 无法按预期接收到错误。
第三:为什么保留完整的 AxiosResponse?
我们选择在拦截器中:
ts
return response
而不是:
ts
return response.data
因为统一请求层不一定适合替所有业务模块决定响应结构。
例如:
- 某个 API 需要
response.data。 - 某个 API 需要响应头中的分页信息。
- 某个下载接口需要 Blob。
- 某个接口需要读取自定义响应头。
保留完整响应,能让业务 API 模块根据实际需求决定如何处理。
当然,如果项目所有接口都严格遵循统一契约,也可以选择在响应拦截器中统一提取 response.data。关键是要在项目中保持一致。
第四:为什么没有在 401 中直接跳转登录页?
示例中只生成统一错误信息,没有直接跳转。
因为认证流程需要结合项目的实际设计,例如:
- Token 是否可以刷新。
- 是否需要重新认证。
- 是否允许游客访问。
- 是否需要保存当前页面地址。
- 是否需要防止多个并发请求重复跳转。
对于真实项目,建议将认证状态处理与路由跳转设计为明确的流程,而不是简单地在每个 401 中执行 location.href = '/login'。
此外,localStorage 只是这里用于演示的 Token 存储方式。实际项目应结合安全需求选择凭证存储和传递方案。
4. 配置环境变量
在项目根目录创建 .env.development:
env
VITE_API_BASE_URL=/api
创建 .env.production:
env
VITE_API_BASE_URL=https://api.example.com
这里的生产地址仅为示例,需要替换为实际后端地址。
Vite 中,只有以 VITE_ 开头的环境变量才会默认暴露给前端代码。
例如:
ts
import.meta.env.VITE_API_BASE_URL
可以读取上面的配置。
重要:不要把数据库密码、私密 API Key 或其他服务端机密放入 VITE_ 环境变量。 这些变量会被打包进客户端代码,用户可以查看。
开发环境中的 /api 也可以配合 Vite 代理转发到后端服务器,避免开发期间的跨域问题。
5. 编写业务 API 模块
创建 src/api/user.ts:
ts
import request from '@/utils/request'
import type {
AxiosResponse,
} from 'axios'
import type {
ApiResponse,
User,
} from '@/types/user'
export interface UserQuery {
page: number
pageSize: number
}
export function getUsers(
params: UserQuery,
signal?: AbortSignal,
) {
return request.get<
ApiResponse<User[]>
>('/users', {
params,
signal,
})
}
export function getUserById(id: number) {
return request.get<
ApiResponse<User>
>(`/users/${id}`)
}
export function createUser(
data: Pick<User, 'name' | 'email'>,
) {
return request.post<
ApiResponse<User>
>('/users', data)
}
注意这里的接口返回值类型。
例如:
ts
request.get<ApiResponse<User[]>>('/users')
由于我们在响应拦截器中保留了完整响应,因此该调用的返回类型是:
ts
Promise<AxiosResponse<ApiResponse<User[]>>>
也就是说:
text
response
└── data
├── code
├── message
└── data
└── User[]
所以在调用时需要访问:
ts
response.data.data
这虽然看起来有一点重复,但对应的是两个不同层次:
- 第一个
data:AxiosResponse 的响应体。 - 第二个
data:后端业务响应中的数据字段。
如果你觉得这种结构不够直观,也可以采用另一种方案:在响应拦截器中直接返回 response.data,然后业务 API 直接返回 ApiResponse<User[]>。
两种方式都可以,关键是类型声明必须与实际返回值一致。
6. 在 Vue 组件中调用 API
创建或修改 src/App.vue:
vue
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import axios from 'axios'
import { getUsers } from '@/api/user'
import type { User } from '@/types/user'
const users = ref<User[]>([])
const loading = ref(false)
const errorMessage = ref('')
let controller: AbortController | undefined
async function loadUsers() {
// 取消之前尚未完成的请求
controller?.abort()
controller = new AbortController()
loading.value = true
errorMessage.value = ''
try {
const response = await getUsers(
{
page: 1,
pageSize: 10,
},
controller.signal,
)
users.value = response.data.data
} catch (error: unknown) {
// 组件主动取消的请求不应该显示为普通错误
if (axios.isCancel(error)) {
return
}
if (
error &&
typeof error === 'object' &&
'message' in error
) {
errorMessage.value = String(error.message)
} else {
errorMessage.value = '发生未知错误'
}
} finally {
// 避免已取消的旧请求修改新请求的 loading 状态
if (controller?.signal.aborted === false) {
loading.value = false
}
}
}
onMounted(() => {
void loadUsers()
})
onUnmounted(() => {
controller?.abort()
})
</script>
<template>
<main>
<h1>用户列表</h1>
<button
:disabled="loading"
@click="loadUsers"
>
{{ loading ? '加载中...' : '重新加载' }}
</button>
<p v-if="errorMessage">
{{ errorMessage }}
</p>
<p v-else-if="loading">
正在加载用户数据...
</p>
<ul v-else>
<li
v-for="user in users"
:key="user.id"
>
{{ user.name }} --- {{ user.email }}
</li>
</ul>
</main>
</template>
这里有一个值得深入思考的问题
为什么组件里还需要 try...catch?
因为统一错误处理不意味着所有错误都应该被吞掉。
请求层负责把底层错误转换成更一致的形式。
组件仍然需要根据业务场景决定:
- 显示什么提示。
- 是否保留旧数据。
- 是否展示空状态。
- 是否允许重试。
- 是否跳转其他页面。
此外,上面的 finally 逻辑适用于演示当前请求取消流程。如果你需要支持更复杂的并发请求,建议使用请求序号或独立的请求状态对象,确保只有当前有效请求可以更新页面状态。
五、请求取消:为什么真实项目经常需要它?
请求取消也是 API 请求封装的重要组成部分。
常见场景包括:
- 用户快速切换搜索关键词。
- 用户离开页面。
- 用户重复点击查询按钮。
- 旧请求返回晚于新请求。
- 页面中的某个请求已经失去意义。
1. 为什么可能出现旧数据覆盖新数据?
假设用户连续搜索:
text
搜索 A
↓
发送请求 A
搜索 B
↓
发送请求 B
请求 B 先返回
↓
展示 B 的结果
请求 A 后返回
↓
错误地覆盖为 A 的结果
问题不在 Vue 的响应式系统,而在于多个异步请求之间存在竞争关系。
这种现象称为竞态条件(Race Condition)。
2. 使用 AbortController
Axios 支持通过 signal 取消请求:
ts
const controller = new AbortController()
request.get('/users', {
signal: controller.signal,
})
// 取消请求
controller.abort()
需要注意:
取消请求并不意味着服务器一定没有执行对应操作。
请求可能已经到达服务器,取消的主要是客户端等待和处理该请求的过程。对于创建订单、付款等写操作,不能把前端取消请求当作回滚业务操作的手段。
3. 请求取消与防抖有什么区别?
两者经常被混淆。
| 机制 | 解决的问题 |
|---|---|
| 防抖 | 减少短时间内重复发起的请求 |
| 节流 | 限制一段时间内的操作频率 |
| 请求取消 | 终止客户端不再需要的请求 |
| 请求序号 | 避免旧请求结果覆盖新请求结果 |
| 缓存 | 避免重复获取已经可复用的数据 |
实际项目可以组合使用这些机制。
例如搜索框可以使用防抖减少请求频率,同时取消旧请求,并且只接受最新一次请求的结果。
六、统一错误处理应该怎么设计?
实际项目中,建议把错误分成三个层次。
1. 网络请求层
负责识别和转换基础错误:
text
网络连接失败
请求超时
HTTP 401
HTTP 403
HTTP 404
HTTP 500
这一层不应该依赖具体页面组件。
2. 业务 API 层
负责理解接口契约。
例如:
json
{
"code": 2001,
"message": "用户名已存在",
"data": null
}
如果后端使用 HTTP 200 携带业务错误码,那么需要在某个明确的层次判断 code 是否表示业务成功。
可以选择:
- 在响应拦截器中统一检查业务码。
- 在业务 API 层检查。
- 在调用方处理特殊业务状态。
选择哪一种,取决于项目约定。
如果绝大多数业务错误都应被视为失败,可以统一转换成拒绝的 Promise;如果某些业务状态是正常流程的一部分,则不应简单地将它们全部抛出。
例如,"用户尚未完成实名认证"可能是业务流程中的一种正常状态,而不一定是需要全局弹窗的错误。
3. 页面展示层
负责决定如何向用户呈现结果。
例如:
ts
try {
await createUser({
name: 'Alice',
email: 'alice@example.com',
})
} catch (error) {
// 根据具体页面决定如何展示
}
实际项目中,可以根据业务需求显示:
- 行内错误提示。
- Toast 消息。
- 表单字段错误。
- 全屏错误页面。
- 重新登录提示。
不建议在所有请求失败时都自动弹出全局消息,否则用户可能在一次操作中看到大量重复提示。
七、常用请求工具与相关库
除了 Axios,还需要认识一些常见工具。
1. Axios
官方文档: https://axios-http.com/
适合:
- 需要拦截器的项目。
- 需要统一请求封装的项目。
- 需要处理复杂 HTTP 配置的项目。
- 已经建立 Axios 请求架构的团队。
2. 原生 Fetch
MDN 文档: https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
Fetch 是浏览器原生提供的网络请求 API。
优点:
- 不需要额外安装依赖。
- 支持 Promise。
- 支持
AbortController。 - 适合轻量请求场景。
需要注意,Fetch 遇到 HTTP 404 或 500 时,通常不会因为状态码本身而拒绝 Promise,因此需要检查:
ts
const response = await fetch('/api/users')
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
这与 Axios 的默认错误处理行为不同。
3. TanStack Query
官方文档: https://tanstack.com/query/latest
它主要解决服务端状态管理问题,例如:
- 请求缓存。
- 数据重新获取。
- 请求状态管理。
- 请求去重。
- 数据失效。
- 乐观更新。
- 分页和无限滚动数据管理。
Axios 和 TanStack Query 并不是相互替代的工具。
它们可以组合使用:
text
TanStack Query
↓
业务 API
↓
Axios
↓
后端服务
Axios 负责发送 HTTP 请求,TanStack Query 负责管理服务端数据的生命周期。
需要注意,Pinia 和 TanStack Query 的职责也有所不同:Pinia 更适合管理应用级状态,而 TanStack Query 更专注于服务端状态及其缓存和同步机制。
4. Zod
官方文档: https://zod.dev/
Zod 可以在运行时验证数据结构。
例如:
ts
import { z } from 'zod'
const UserSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string().email(),
})
const result = UserSchema.safeParse(data)
if (!result.success) {
console.error('接口数据结构不符合预期')
} else {
console.log(result.data)
}
TypeScript 解决的是开发阶段的静态类型检查。
Zod 则可以验证程序运行时拿到的数据是否符合预期。
对于外部 API、复杂表单、配置文件等数据来源,这种运行时验证尤其有价值。
八、高频面试题
面试题 1:为什么要封装 Axios?
参考回答:
封装 Axios 是为了统一管理请求配置、拦截器、Token 注入、错误处理和请求取消等公共逻辑。
同时,通过业务 API 模块隔离组件与底层网络请求,使组件更加关注业务逻辑。
在中大型项目中,还可以通过统一请求层降低维护成本,提升可测试性和代码一致性。
面试题 2:Axios 请求拦截器和响应拦截器有什么区别?
参考回答:
请求拦截器在请求发送前执行,适合添加公共请求头、Token 和请求追踪信息。
响应拦截器在收到响应后执行,适合处理响应结构、HTTP 错误和统一错误转换。
响应拦截器可以分别定义成功处理函数和失败处理函数。
面试题 3:HTTP 200 为什么也可能代表业务失败?
参考回答:
HTTP 状态码表示 HTTP 层面的请求结果,而业务是否成功由后端业务规则决定。
如果后端通过业务错误码表达失败,即使 HTTP 状态码是 200,前端也需要根据接口契约检查业务码。
因此需要区分 HTTP 错误和业务错误。
面试题 4:为什么响应拦截器中需要 Promise.reject?
参考回答:
因为拒绝 Promise 可以把请求失败继续传递到调用方,使调用方通过 catch 或 try...catch 处理错误。
如果在失败拦截器中直接返回错误对象,Promise 链可能会把它当作成功结果处理,导致调用方的错误处理逻辑失效。
面试题 5:为什么 TypeScript 不能保证接口数据正确?
参考回答:
TypeScript 的类型系统主要用于静态类型检查。后端返回的数据是运行时数据,通常不会自动经过 TypeScript 类型验证。
即使声明:
ts
axios.get<User[]>('/users')
也不能保证服务器实际返回的数据一定符合 User[]。
如果需要运行时保证,应使用 Zod 等工具验证数据。
面试题 6:请求取消和防抖有什么区别?
参考回答:
防抖用于减少短时间内重复触发的操作,通常是在请求发出前减少请求数量。
请求取消则用于终止客户端不再需要的请求。
两者可以组合使用,尤其适合搜索框、筛选列表等场景。
面试题 7:如何处理多个并发请求同时返回 401?
参考回答:
如果项目需要刷新 Token,可以设计一个统一的 Token 刷新流程。
常见做法是:
- 第一个失效请求启动 Token 刷新。
- 后续失效请求等待刷新结果。
- 刷新成功后,根据约定重试等待中的请求。
- 刷新失败后,统一清理认证状态并引导用户重新登录。
同时需要防止刷新请求本身再次进入相同的刷新流程,并避免无限重试。
具体实现需要结合后端的刷新凭证机制、并发控制和幂等性要求。
面试题 8:为什么不应该在每个组件中直接调用 Axios?
参考回答:
因为这样会导致组件与底层网络请求耦合,并且让公共配置、错误处理和接口定义散落在各处。
通过业务 API 模块和统一请求层,可以将页面逻辑、业务接口和网络基础设施分离。
这能提高代码复用性、可维护性和测试能力。
九、面试难题
以下问题比基础概念题更能考察工程设计能力。
难题 1:如果后端有多个服务,应该如何设计 Axios 实例?
假设项目包含:
text
用户服务:/user-api
订单服务:/order-api
文件服务:/file-api
如果所有服务的基础地址和认证规则完全相同,可以共用一个 Axios 实例。
但如果它们存在以下差异:
- 不同的基础地址。
- 不同的认证方式。
- 不同的超时时间。
- 不同的响应结构。
- 不同的文件上传和下载需求。
那么可以为不同服务创建独立实例。
例如:
ts
const userRequest = axios.create({
baseURL: '/user-api',
timeout: 10000,
})
const fileRequest = axios.create({
baseURL: '/file-api',
timeout: 60000,
})
关键不是实例越多越好,而是根据不同服务的边界组织配置。
如果每个服务都复制一份完整的错误处理逻辑,也会带来维护成本。因此可以把公共能力抽取为可复用的配置函数或工厂函数。
难题 2:如果后端使用 HTTP 200 返回所有业务错误,如何设计统一处理?
例如:
json
{
"code": 0,
"message": "success",
"data": {}
}
成功码为 0,其他业务码表示不同状态。
可以在统一请求层或业务 API 层检查:
ts
if (response.data.code !== 0) {
throw new Error(response.data.message)
}
但这段代码只是概念示例,实际实现还需要考虑错误类型、错误码枚举、业务状态和特殊接口。
更好的设计通常是:
- 明确后端业务响应契约。
- 为业务错误建立类型。
- 将可统一处理的错误转换为标准错误对象。
- 为需要特殊流程的业务状态保留明确处理入口。
- 避免所有错误都自动触发全局弹窗。
如果某个业务错误需要进入组件的 catch,就应该明确地将它转换为拒绝的 Promise,而不是返回一个错误对象并假装请求成功。
难题 3:如何防止旧请求覆盖新请求?
仅仅取消请求并不总是足够的。
因为:
- 旧请求可能已经完成。
- 请求取消与响应处理之间可能存在竞争。
- 某些异步处理并不完全由 HTTP 请求本身构成。
一个可靠的方法是使用请求序号:
ts
let requestId = 0
async function loadData() {
const currentId = ++requestId
const response = await getUsers({
page: 1,
pageSize: 10,
})
// 只有最新请求才能更新数据
if (currentId !== requestId) {
return
}
users.value = response.data.data
}
这里的关键是:
ts
if (currentId !== requestId) {
return
}
每次发起新请求时都会递增序号。
旧请求即使晚返回,也不能覆盖新请求的结果。
在真实项目中,还需要为失败状态和 Loading 状态制定相同的并发更新规则。
难题 4:请求失败后,所有请求都应该自动重试吗?
不应该。
需要区分:
- 网络瞬时故障。
- HTTP 429 限流。
- HTTP 503 服务暂时不可用。
- HTTP 401 身份认证失效。
- 参数错误。
- 业务规则错误。
对于部分临时性错误,可以在遵守服务端策略的前提下使用有限次数的重试,并采用退避策略。
但对于创建订单、扣款等写操作,盲目重试可能造成重复执行。
因此必须考虑:
- 请求是否幂等。
- 服务端是否支持幂等键。
- 是否可以安全重试。
- 是否需要读取
Retry-After等响应信息。 - 重试次数和退避时间如何限制。
不要把"请求失败"简单地等同于"可以重新请求"。
难题 5:为什么不能只依靠 Axios 的泛型保证 API 类型安全?
例如:
ts
const response = await request.get<User[]>('/users')
这只是在编译阶段告诉 TypeScript,开发者期望拿到 User[]。
它不会自动检查运行时响应。
如果后端实际返回:
json
{
"unexpected": true
}
TypeScript 不会因为泛型声明而自动拒绝这份数据。
更可靠的做法是:
- 使用 TypeScript 描述期望的数据结构。
- 使用运行时校验工具检查外部数据。
- 对校验失败进行统一处理。
- 尽可能通过 API 契约、自动生成类型和集成测试减少前后端不一致。
这体现了静态类型系统和运行时验证的区别。
难题 6:如何测试统一请求封装?
请求封装通常需要覆盖以下测试场景:
| 场景 | 需要验证的行为 |
|---|---|
| 正常请求 | URL、参数和响应处理正确 |
| HTTP 401 | 认证错误按约定处理 |
| HTTP 500 | 错误被转换为统一格式 |
| 网络异常 | 不依赖 error.response 仍能处理 |
| 请求超时 | 返回明确的超时错误 |
| 请求取消 | 不显示普通业务错误 |
| 业务错误码 | 按接口契约进行处理 |
| 并发请求 | 不出现重复刷新或旧数据覆盖 |
可以使用 Vitest 测试业务 API 和错误转换逻辑。
对于网络请求,可以使用 MSW 拦截请求并模拟后端响应。
这与上一节的 Mock 知识正好衔接起来:Mock 不仅用于开发阶段模拟后端,也可以用于测试请求层的各种异常情况。