Vue 3 深入学习笔记 16:API 请求封装与统一错误处理

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

这样做有几个好处:

  1. 统一管理 baseURL。
  2. 统一管理超时时间。
  3. 可以单独配置请求和响应拦截器。
  4. 不影响项目中其他 Axios 实例。
  5. 方便针对不同后端服务创建不同实例。

例如:

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 请求层

本节只实现一套完整的请求封装,包括:

  1. Axios 实例。
  2. 请求拦截器。
  3. 响应拦截器。
  4. 统一错误处理。
  5. 业务 API 模块。
  6. Vue 组件调用。
  7. 请求取消。

假设项目使用 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. 用户快速切换搜索关键词。
  2. 用户离开页面。
  3. 用户重复点击查询按钮。
  4. 旧请求返回晚于新请求。
  5. 页面中的某个请求已经失去意义。

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 刷新流程。

常见做法是:

  1. 第一个失效请求启动 Token 刷新。
  2. 后续失效请求等待刷新结果。
  3. 刷新成功后,根据约定重试等待中的请求。
  4. 刷新失败后,统一清理认证状态并引导用户重新登录。

同时需要防止刷新请求本身再次进入相同的刷新流程,并避免无限重试。

具体实现需要结合后端的刷新凭证机制、并发控制和幂等性要求。

面试题 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)
}

但这段代码只是概念示例,实际实现还需要考虑错误类型、错误码枚举、业务状态和特殊接口。

更好的设计通常是:

  1. 明确后端业务响应契约。
  2. 为业务错误建立类型。
  3. 将可统一处理的错误转换为标准错误对象。
  4. 为需要特殊流程的业务状态保留明确处理入口。
  5. 避免所有错误都自动触发全局弹窗。

如果某个业务错误需要进入组件的 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 不会因为泛型声明而自动拒绝这份数据。

更可靠的做法是:

  1. 使用 TypeScript 描述期望的数据结构。
  2. 使用运行时校验工具检查外部数据。
  3. 对校验失败进行统一处理。
  4. 尽可能通过 API 契约、自动生成类型和集成测试减少前后端不一致。

这体现了静态类型系统和运行时验证的区别。

难题 6:如何测试统一请求封装?

请求封装通常需要覆盖以下测试场景:

场景 需要验证的行为
正常请求 URL、参数和响应处理正确
HTTP 401 认证错误按约定处理
HTTP 500 错误被转换为统一格式
网络异常 不依赖 error.response 仍能处理
请求超时 返回明确的超时错误
请求取消 不显示普通业务错误
业务错误码 按接口契约进行处理
并发请求 不出现重复刷新或旧数据覆盖

可以使用 Vitest 测试业务 API 和错误转换逻辑。

对于网络请求,可以使用 MSW 拦截请求并模拟后端响应。

这与上一节的 Mock 知识正好衔接起来:Mock 不仅用于开发阶段模拟后端,也可以用于测试请求层的各种异常情况。

相关推荐
小弥儿2 小时前
GitHub今日热榜 | 2026-10-20:智能体基建七席连庄,生成类项目回流
学习·开源·github
ACP广源盛139246256732 小时前
HDMI2.1 同源双屏方案选型,GSV6502@ACP 工程评估笔记
笔记·硬件架构·音视频·硬件工程·hdmi·国产芯片
wuyoutrans2 小时前
冰岛专利审查意见答复阶段,翻译文件多久可以交付,是否包含术语校对服务?
笔记
问天_观心2 小时前
模型微调学习(二)
开发语言·人工智能·学习·语言模型·github
dadaobusi2 小时前
学习:PCIe6.0 RS FEC
学习
_橙时_3 小时前
【MOEA/D的学习小笔记】
笔记·学习·多目标优化·moea/d
kaixin_learn_qt_ing3 小时前
QCommandLineParser 学习
学习
Java后端的Ai之路3 小时前
前端框架Vue-07_原生的Vue.js
前端·vue.js·前端框架
小诗懂技术3 小时前
V4L2视频采集流程复习笔记
笔记·mysql·音视频