Vue 基础体系 · 第 47/70 篇。示例基于 Vue 3、Composition API、TypeScript 与现代 Vite 工具链;版本敏感能力会单独标注。

Vue HTTP 客户端封装:Axios、拦截器、取消、重试和错误模型

在 Vue 应用中,HTTP 请求通常分散在组件、Composable、Pinia Store 和路由守卫之间。如果每个调用点都自行处理鉴权、超时、取消、错误提示和重试,最终会出现几类问题:

  • 同一种后端错误在不同页面被解释成不同含义;
  • 组件卸载后,请求仍然返回并尝试修改已失效的状态;
  • 网络抖动导致重复请求,写操作却被错误重试;
  • 401、业务错误、请求取消和网络断开都被粗暴地显示为“请求失败”;
  • Axios 的拦截器逻辑与 Vue 生命周期、Pinia 状态和 Vue Router 导航互相耦合。

一个可维护的 HTTP 层,需要明确四件事:

  1. Axios 实例负责什么;
  2. 拦截器改变了哪些请求和响应;
  3. 取消、超时、重试分别代表什么;
  4. 应用层最终接收到什么稳定的错误模型。

下面的示例基于 Vue 3、Composition API、TypeScript 和现代 Vite 工具链。Axios 的 AbortController 支持属于 Axios 0.22 及之后版本;CancelToken 已被弃用,不应作为新代码的主要取消 API。


一、先区分 HTTP 客户端、Vue 状态和业务 API

HTTP 客户端的职责是发送请求并接收响应。它不应该直接知道某个页面的 loading 文案,也不应该把所有错误直接弹成 Toast。

可以把一次请求拆成四层:

Vue 组件 / Composable
        │
        ▼
Pinia Store 或 API 函数
        │
        ▼
Axios HTTP 客户端
        │
        ▼
浏览器网络栈 / 后端服务

每一层处理不同问题:

  • 组件:决定何时请求、何时取消,以及如何展示状态;
  • Composable 或 Store:管理页面或领域状态;
  • API 函数:描述具体的资源接口和参数;
  • Axios 客户端:统一处理 Base URL、请求头、超时、错误转换和重试策略。

例如,GET /api/users/42 返回 404 时:

  • Axios 负责识别这是一个 HTTP 响应错误;
  • API 层可以把响应体解析为后端错误;
  • Store 决定用户是否进入“用户不存在”状态;
  • 组件决定显示空状态还是错误页面。

如果 Axios 直接调用全局 Toast,它就越过了状态层和展示层,测试和复用都会变差。


二、Axios 实例:不要把全局默认配置当作应用边界

Axios 提供了默认导出对象,也允许通过 axios.create() 创建实例。应用通常应创建自己的实例:

// src/http/client.ts
import axios from 'axios'

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
  timeout: 15_000,
  headers: {
    Accept: 'application/json',
  },
})

这里有三个重要含义:

  • baseURL 会与相对 URL 合并,例如 http.get('/users') 可能最终请求 /api/users
  • timeout 是请求级别的响应等待限制,不应被理解成覆盖所有网络阶段的万能断网检测;
  • 使用独立实例可以避免修改 Axios 全局默认值,从而降低测试和多后端场景之间的相互影响。

Vite 只会把符合约定的环境变量暴露给前端代码,常见形式是:

# .env.development
VITE_API_BASE_URL=http://localhost:3000/api

前端环境变量会被打包进浏览器,不能存放密钥。真正的服务端凭据必须留在后端。

请求配置的优先级

Axios 配置通常存在三个层级:

  1. Axios 全局默认配置;
  2. 实例默认配置;
  3. 单次请求配置。

越靠近单次请求,优先级越高。例如:

http.get('/reports', {
  timeout: 30_000,
})

这个请求的超时会覆盖实例上的 15_000 毫秒。配置优先级的意义在于:实例提供统一默认值,特殊接口可以局部覆盖,而不必复制整个客户端。


三、拦截器到底改变了什么

Axios 拦截器分为两类:

  • 请求拦截器:请求发出前执行;
  • 响应拦截器:响应成功或失败后执行。

它们形成一条处理链:

调用 http.get()
    │
    ▼
请求拦截器
    │
    ▼
发送 HTTP 请求
    │
    ├── 成功响应 → 响应成功拦截器 → 调用方 then
    │
    └── 失败响应 → 响应失败拦截器 → 调用方 catch

拦截器不是 Vue 生命周期钩子,也不是每个组件都会重新创建的一段逻辑。它应在 HTTP 实例初始化时注册一次。

1. 请求拦截器:注入可变信息

典型用途是注入访问令牌和请求追踪 ID:

// src/http/interceptors.ts
import type { InternalAxiosRequestConfig } from 'axios'
import { http } from './client'
import { authStore } from '@/stores/auth'

function onRequest(config: InternalAxiosRequestConfig) {
  const token = authStore.getAccessToken()

  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }

  config.headers['X-Request-Id'] = crypto.randomUUID()

  return config
}

http.interceptors.request.use(onRequest)

这里的 authStore 只是示意。实际 Pinia Store 通常需要在应用安装 Pinia 后才能安全使用。如果 HTTP 模块在应用初始化前就导入并执行,直接调用 Store 可能导致“未安装 Pinia”的运行时错误。

更稳妥的方式是把令牌读取封装成不依赖组件实例的函数,或者显式传入 Store:

// src/http/auth-token.ts
export function getAccessToken(): string | null {
  return localStorage.getItem('access_token')
}
import type { InternalAxiosRequestConfig } from 'axios'
import { http } from './client'
import { getAccessToken } from './auth-token'

http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
  const token = getAccessToken()

  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }

  return config
})

使用 localStorage 保存令牌只是示例,不代表它在所有安全模型下都合适。可访问的令牌可能受到 XSS 风险影响;使用 HttpOnly Cookie 时,客户端通常不需要手动注入 Authorization,但需要正确处理跨域、CSRF 和 withCredentials

2. 响应拦截器:统一转换,而不是吞掉错误

Axios 默认行为受 validateStatus 影响。默认情况下,HTTP 状态码不在 2xx 范围内会进入 rejected 分支:

http.interceptors.response.use(
  (response) => response,
  (error) => {
    return Promise.reject(error)
  },
)

失败拦截器必须继续 throwPromise.reject,否则调用方会误以为请求成功:

http.interceptors.response.use(
  (response) => response,
  (error) => {
    console.error('HTTP request failed', error)
    return Promise.reject(error)
  },
)

下面这种写法会制造严重错误:

http.interceptors.response.use(
  (response) => response,
  (error) => {
    console.error(error)
    return undefined
  },
)

因为 Promise 链会从 rejected 变为 fulfilled,调用方的 catch 不再执行,返回值却是 undefined


四、响应数据结构:Axios 响应不等于后端数据

Axios 的响应通常类似:

{
  data: ...,       // 后端响应体
  status: 200,
  statusText: 'OK',
  headers: ...,
  config: ...
}

后端业务数据位于 response.data,而不是整个响应对象中。可以用泛型明确这一点:

// src/api/users.ts
import { http } from '@/http/client'

export interface User {
  id: string
  name: string
  email: string
}

export interface UserListResponse {
  items: User[]
  total: number
}

export function getUsers(signal?: AbortSignal) {
  return http.get<UserListResponse>('/users', {
    signal,
  })
}

调用时:

const response = await getUsers()
console.log(response.data.items)

也可以在 API 边界直接返回 data,减少上层对 Axios 的依赖:

export async function fetchUsers(signal?: AbortSignal) {
  const response = await http.get<UserListResponse>('/users', { signal })
  return response.data
}

这两种形式没有绝对对错:

  • 保留 Axios 响应,适合需要读取响应头、状态码或分页头的场景;
  • 只返回 data,适合让业务层与 Axios 解耦。

关键是整个项目保持一致,不要有的 API 返回 AxiosResponse<T>,有的 API 返回 T,却没有命名或类型上的区别。


五、取消请求:取消不是失败,也不是超时

1. AbortController 的基本模型

AbortController 产生一个 AbortSignal,请求接收这个 signal:

const controller = new AbortController()

http.get('/users', {
  signal: controller.signal,
})

// 之后取消
controller.abort()

取消发生后,Axios 通常会拒绝 Promise,并将错误标记为取消错误。现代 Axios 推荐使用 signal,而不是已弃用的 CancelToken

在 Vue 组件中,最常见的用途是组件卸载时取消未完成请求:

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { getUsers, type User } from '@/api/users'

const users = ref<User[]>([])
const loading = ref(false)
const error = ref<unknown>(null)

const controller = new AbortController()

onMounted(async () => {
  loading.value = true

  try {
    const data = await getUsers(controller.signal)
    users.value = data.data.items
  } catch (err) {
    if (!isCanceledError(err)) {
      error.value = err
    }
  } finally {
    loading.value = false
  }
})

onUnmounted(() => {
  controller.abort()
})

function isCanceledError(error: unknown): boolean {
  return (
    typeof error === 'object' &&
    error !== null &&
    'code' in error &&
    error.code === 'ERR_CANCELED'
  )
}
</script>

这里的 finally 仍然会执行,因此如果组件已经卸载,异步回调中的状态写入就需要谨慎。Vue 组件卸载后,写入已经脱离页面的 ref 通常没有用户价值;更稳妥的实现是同时维护生命周期状态,或者使用封装好的 Composable。

2. 取消一个请求不会自动取消另一个请求

每个请求可以共享同一个 signal:

const controller = new AbortController()

await Promise.all([
  http.get('/profile', { signal: controller.signal }),
  http.get('/notifications', { signal: controller.signal }),
])

controller.abort()

这样会形成一个取消组。适用于“离开页面时同时取消页面请求”。

但如果一个请求只应独立取消,就必须为它创建独立的 AbortController。不要把一个全局 controller 复用于整个应用,否则取消一个页面可能误伤其他页面的请求。

3. 搜索框中的竞态

搜索输入会产生竞态:

输入 "v"   → 请求 A
输入 "vue" → 请求 B

如果 A 比 B 晚返回,旧结果可能覆盖新结果。取消旧请求可以减少这种问题:

<script setup lang="ts">
import { ref, watch } from 'vue'
import { http } from '@/http/client'

const keyword = ref('')
const results = ref<string[]>([])
let controller: AbortController | null = null

watch(keyword, async (value) => {
  controller?.abort()
  controller = new AbortController()

  if (!value.trim()) {
    results.value = []
    return
  }

  try {
    const response = await http.get<string[]>('/search', {
      params: { q: value },
      signal: controller.signal,
    })

    results.value = response.data
  } catch (error) {
    if (!isCanceledError(error)) {
      console.error(error)
    }
  }
})
</script>

这里有两个机制共同成立:

  1. 取消旧请求,降低无用网络和服务器压力;
  2. 即使底层实现没有及时停止响应,也应在应用层保证旧结果不能覆盖新结果。

因此,“取消”不是解决竞态的唯一逻辑保障;请求序号或当前关键词校验仍可作为防御。

4. 超时与取消的区别

超时表示客户端等待某个时间窗口后主动放弃;取消表示业务或生命周期主动终止请求。

const controller = new AbortController()

http.get('/slow-endpoint', {
  timeout: 5_000,
  signal: controller.signal,
})

这段配置表达两种独立条件:

  • 5 秒内没有满足 Axios 的响应等待条件,可能触发超时;
  • 组件卸载或用户操作调用 controller.abort(),请求被取消。

生产代码通常同时使用二者,因为超时可以防止请求无限等待,而取消可以避免页面已经不需要的请求继续占用资源。


六、错误模型:不要让业务层依赖 Axios 的全部细节

1. Axios 错误包含哪些类别

Axios 的错误可能来自不同阶段:

  1. 请求配置阶段:配置不合法;
  2. 网络阶段:DNS、连接失败、CORS、浏览器离线等;
  3. 超时阶段:请求超过 timeout
  4. 取消阶段AbortController.abort()
  5. HTTP 状态阶段:服务器返回非 2xx,且 validateStatus 判定为失败;
  6. 响应解析或业务阶段:请求成功,但响应结构不符合预期,或后端业务码表示失败。

Axios 的 AxiosError 通常可能包含:

  • message
  • name
  • code
  • config
  • request
  • response

但业务层不应依赖 error.response!.data 这种未经判断的访问,因为网络错误可能根本没有 response

2. 用判别联合建立稳定模型

可以定义一个应用层错误模型:

// src/http/errors.ts
import axios, { type AxiosError } from 'axios'

export type AppError =
  | {
      kind: 'canceled'
      message: string
    }
  | {
      kind: 'timeout'
      message: string
      cause: AxiosError
    }
  | {
      kind: 'network'
      message: string
      cause: AxiosError
    }
  | {
      kind: 'http'
      status: number
      message: string
      requestId?: string
      cause: AxiosError
    }
  | {
      kind: 'business'
      code: string | number
      message: string
      cause: unknown
    }
  | {
      kind: 'unknown'
      message: string
      cause: unknown
    }

interface BackendErrorBody {
  code?: string | number
  message?: string
  requestId?: string
}

export function normalizeError(error: unknown): AppError {
  if (axios.isCancel(error)) {
    return {
      kind: 'canceled',
      message: '请求已取消',
    }
  }

  if (axios.isAxiosError(error)) {
    if (error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT') {
      return {
        kind: 'timeout',
        message: '请求超时,请稍后重试',
        cause: error,
      }
    }

    if (!error.response) {
      return {
        kind: 'network',
        message: '网络不可用或服务器未连接',
        cause: error,
      }
    }

    const body = error.response.data as BackendErrorBody | undefined

    return {
      kind: 'http',
      status: error.response.status,
      message: body?.message ?? `服务器返回 HTTP ${error.response.status}`,
      requestId:
        body?.requestId ??
        getHeader(error.response.headers, 'x-request-id'),
      cause: error,
    }
  }

  return {
    kind: 'unknown',
    message: error instanceof Error ? error.message : '未知错误',
    cause: error,
  }
}

function getHeader(
  headers: Record<string, unknown>,
  name: string,
): string | undefined {
  const value = headers[name] ?? headers[name.toLowerCase()]
  return typeof value === 'string' ? value : undefined
}

这里使用判别字段 kind。调用方可以安全地进行穷尽分支:

function getUserMessage(error: AppError): string {
  switch (error.kind) {
    case 'canceled':
      return ''
    case 'timeout':
      return '服务器响应较慢,请稍后再试'
    case 'network':
      return '请检查网络连接'
    case 'http':
      if (error.status === 401) return '登录状态已失效'
      if (error.status === 403) return '没有访问权限'
      if (error.status === 404) return '资源不存在'
      return error.message
    case 'business':
      return error.message
    case 'unknown':
      return '发生未知错误'
  }
}

3. HTTP 错误与业务错误不是同一层

假设后端返回:

{
  "code": "INSUFFICIENT_BALANCE",
  "message": "余额不足"
}

如果 HTTP 状态是 200,Axios 默认会认为请求成功,响应会进入成功拦截器。此时这是业务错误,不是 Axios 的 HTTP 错误。

如果后端使用 422 返回同样的数据,Axios 默认会把它视为 rejected,应用层需要从 error.response.data 中提取业务错误。

因此需要先约定后端协议。若统一使用 HTTP 状态表达成功或失败,客户端模型更简单;若所有请求都返回 200,就必须在 Axios 成功拦截器或 API 层检查业务码:

interface ApiEnvelope<T> {
  success: boolean
  code: string
  message: string
  data: T
}

export async function getUser(id: string) {
  const response = await http.get<ApiEnvelope<User>>(`/users/${id}`)
  const body = response.data

  if (!body.success) {
    throw {
      kind: 'business',
      code: body.code,
      message: body.message,
      cause: body,
    } satisfies AppError
  }

  return body.data
}

如果没有明确协议,客户端无法仅凭 HTTP 状态码判断业务是否成功。

4. validateStatus 会改变错误流向

可以自定义哪些状态码进入 fulfilled 分支:

const response = await http.get('/users', {
  validateStatus: (status) => status < 500,
})

这样 404 也会作为正常响应返回。它适合调用方确实需要把 404 当作一种可处理结果的场景,例如检查资源是否存在。

但这也意味着通用响应失败拦截器不会自动处理 404。因此 validateStatus 是改变控制流的配置,不应随意全局设置成“所有状态都成功”。


七、拦截器中的认证失效与 Vue Router

401 Unauthorized 常见处理方式是清理认证状态,并跳转到登录页。Vue Router 官方文档中的路由实例应由应用创建和安装;HTTP 模块如果在应用初始化阶段就导入,直接依赖组件内的 useRouter() 是不成立的,因为 useRouter() 需要处于组件 setup() 上下文中。

可以把路由实例显式导出:

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'

export const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/login',
      component: () => import('@/views/LoginView.vue'),
    },
    {
      path: '/',
      component: () => import('@/views/HomeView.vue'),
    },
  ],
})

然后在应用入口安装:

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import './http/setup'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.mount('#app')

拦截器可以使用导出的 router:

// src/http/setup.ts
import axios from 'axios'
import { http } from './client'
import { normalizeError } from './errors'
import { router } from '@/router'
import { clearAccessToken } from './auth-token'

http.interceptors.response.use(
  (response) => response,
  async (error: unknown) => {
    const appError = normalizeError(error)

    if (appError.kind === 'http' && appError.status === 401) {
      clearAccessToken()

      if (router.currentRoute.value.path !== '/login') {
        await router.push({
          path: '/login',
          query: {
            redirect: router.currentRoute.value.fullPath,
          },
        })
      }
    }

    return Promise.reject(appError)
  },
)

这里的关键是:拦截器把底层错误统一转换成 AppError,后续调用方收到的就不再是结构不稳定的 Axios 原始对象。

需要注意并发问题:如果同时有十个请求收到 401,上述代码可能触发多次清理和导航。通常路由跳转本身会去重,但刷新令牌场景更复杂,不能简单地在每个 401 中都发起一次刷新请求。


八、重试:Axios 不会自动替你正确重试

Axios 本身提供请求能力和拦截器能力,但不会自动根据业务安全性替你决定是否重试。重试必须回答三个问题:

  1. 哪些失败可以重试;
  2. 重试之间等待多久;
  3. 重试是否会重复执行危险操作。

1. 为什么 GET 和 POST 不能简单区别

HTTP 方法的“幂等性”是重试的重要依据。

如果一个请求重复执行,最终服务器状态与执行一次相同,它通常被称为幂等操作。常见情况下:

  • GETHEADOPTIONS 通常设计为安全且幂等;
  • PUTDELETE 按 HTTP 语义通常是幂等的,但具体仍取决于接口实现;
  • POST 通常不是幂等的。

例如:

POST /orders

第一次请求可能已经在服务器创建订单,但客户端因为网络断开没有收到响应。如果客户端自动重试,服务器可能创建第二个订单。

因此,“请求没有收到响应”不等于“服务器没有执行请求”。

2. 指数退避和抖动

一个常见的等待公式是:

dn=min(dmax,d0×2n)+Jd_n = \min(d_{\max}, d_0 \times 2^n) + J

其中:

  • nn 是已经完成的重试次数,从 0 开始;
  • d0d_0 是初始等待时间;
  • dmaxd_{\max} 是最大等待上限;
  • JJ 是随机抖动,用于避免多个客户端在同一时刻再次请求。

例如初始等待 300ms,最大 5000ms

第 1 次重试:300ms + 随机抖动
第 2 次重试:600ms + 随机抖动
第 3 次重试:1200ms + 随机抖动
第 4 次重试:2400ms + 随机抖动

如果没有抖动,服务恢复时大量客户端可能同时重试,形成“惊群”。

3. 一个显式、可审计的重试函数

不要把复杂重试逻辑隐藏在一个无法配置的全局拦截器中。可以为请求配置重试元数据:

// src/http/retry.ts
import axios, {
  type AxiosError,
  type AxiosRequestConfig,
  type AxiosResponse,
} from 'axios'

declare module 'axios' {
  interface AxiosRequestConfig {
    retry?: {
      count?: number
      baseDelayMs?: number
      maxDelayMs?: number
    }
  }
}

export async function requestWithRetry<T>(
  request: (config: AxiosRequestConfig) => Promise<AxiosResponse<T>>,
  config: AxiosRequestConfig = {},
): Promise<AxiosResponse<T>> {
  const retryConfig = config.retry
  const maxRetries = retryConfig?.count ?? 0
  const baseDelayMs = retryConfig?.baseDelayMs ?? 300
  const maxDelayMs = retryConfig?.maxDelayMs ?? 5_000

  let attempt = 0

  while (true) {
    try {
      return await request(config)
    } catch (error) {
      if (!shouldRetry(error, config, attempt, maxRetries)) {
        throw error
      }

      const delay = Math.min(
        maxDelayMs,
        baseDelayMs * 2 ** attempt,
      ) + Math.floor(Math.random() * 100)

      await sleep(delay)
      attempt += 1
    }
  }
}

function shouldRetry(
  error: unknown,
  config: AxiosRequestConfig,
  attempt: number,
  maxRetries: number,
): boolean {
  if (attempt >= maxRetries) return false
  if (axios.isCancel(error)) return false

  const method = (config.method ?? 'get').toUpperCase()

  // 默认只允许自动重试通常幂等的读取请求。
  if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
    return false
  }

  if (!axios.isAxiosError(error)) {
    return false
  }

  const axiosError = error as AxiosError

  // 有响应时,只对常见临时性状态重试。
  if (axiosError.response) {
    return [408, 425, 429, 500, 502, 503, 504].includes(
      axiosError.response.status,
    )
  }

  // 没有响应,通常表示网络或连接层失败。
  return axiosError.request !== undefined
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

调用:

import { requestWithRetry } from '@/http/retry'
import { http } from '@/http/client'

const response = await requestWithRetry(
  (config) => http.get('/users', config),
  {
    retry: {
      count: 3,
      baseDelayMs: 300,
      maxDelayMs: 3_000,
    },
    signal: controller.signal,
  },
)

4. 取消必须打断等待中的重试

上面的简单实现有一个边界:如果请求已经失败,代码正在 sleep(),此时调用 abort(),等待仍可能继续到期。可以让 sleep 也监听 signal:

function sleepWithAbort(
  ms: number,
  signal?: AbortSignal,
): Promise<void> {
  if (signal?.aborted) {
    return Promise.reject(new DOMException('Aborted', 'AbortError'))
  }

  return new Promise((resolve, reject) => {
    const timer = window.setTimeout(() => {
      signal?.removeEventListener('abort', onAbort)
      resolve()
    }, ms)

    function onAbort() {
      window.clearTimeout(timer)
      reject(new DOMException('Aborted', 'AbortError'))
    }

    signal?.addEventListener('abort', onAbort, { once: true })
  })
}

将重试函数中的:

await sleep(delay)

替换为:

await sleepWithAbort(delay, config.signal)

这样取消请求时,正在执行的 HTTP 请求和等待中的退避阶段都可以结束。

5. Retry-After 不能被忽略

服务器返回 429 Too Many Requests503 Service Unavailable 时,可能通过 Retry-After 告诉客户端何时再试。该值可能是秒数,也可能是 HTTP 日期。

客户端至少应避免无条件忽略它。一个简化解析函数如下:

function parseRetryAfter(value: unknown): number | undefined {
  if (typeof value !== 'string') return undefined

  const seconds = Number(value)
  if (Number.isFinite(seconds)) {
    return Math.max(0, seconds * 1000)
  }

  const timestamp = Date.parse(value)
  if (Number.isNaN(timestamp)) return undefined

  return Math.max(0, timestamp - Date.now())
}

实际策略可以取“服务器建议值”和“客户端退避值”中的较大者,避免客户端过早重试。


九、不要在重试和令牌刷新之间制造循环

认证拦截器常见的错误路径是:

业务请求 → 401 → 刷新令牌 → 刷新请求也 401
                     │
                     └→ 再次进入 401 拦截器
                              │
                              └→ 无限循环

解决办法是给刷新请求加标记,并限制每个原始请求最多刷新一次:

declare module 'axios' {
  interface AxiosRequestConfig {
    skipAuthRefresh?: boolean
    authRefreshAttempted?: boolean
  }
}

示意逻辑:

http.interceptors.response.use(
  (response) => response,
  async (error) => {
    const original = error.config

    if (
      error.response?.status === 401 &&
      !original?.skipAuthRefresh &&
      !original?.authRefreshAttempted
    ) {
      original.authRefreshAttempted = true

      await refreshToken({
        skipAuthRefresh: true,
      })

      return http.request(original)
    }

    return Promise.reject(error)
  },
)

但多个请求同时过期时,不能让每个请求各自刷新。通常需要一个共享的刷新 Promise:

let refreshPromise: Promise<string> | null = null

function refreshOnce(): Promise<string> {
  if (!refreshPromise) {
    refreshPromise = requestRefreshToken()
      .finally(() => {
        refreshPromise = null
      })
  }

  return refreshPromise
}

这样并发请求会等待同一个刷新操作,而不是同时打出多个刷新请求。刷新失败后,所有等待者都应收到失败结果,并清理认证状态。

这段机制与 Pinia 的认证 Store 有关,但不应把刷新 Promise 存入响应式状态。它是 HTTP 层的并发控制变量,不是需要驱动界面的业务状态。


十、完整的 API 层示例

定义后端响应:

// src/api/types.ts
export interface User {
  id: string
  name: string
  email: string
}

export interface PageResult<T> {
  items: T[]
  total: number
  page: number
  pageSize: number
}

定义资源 API:

// src/api/user-api.ts
import { http } from '@/http/client'
import type { PageResult, User } from './types'

export async function listUsers(
  page: number,
  pageSize: number,
  signal?: AbortSignal,
) {
  const response = await http.get<PageResult<User>>('/users', {
    params: { page, pageSize },
    signal,
  })

  return response.data
}

export async function deleteUser(
  id: string,
  signal?: AbortSignal,
) {
  await http.delete(`/users/${encodeURIComponent(id)}`, {
    signal,
  })
}

encodeURIComponent 用于避免资源 ID 中的特殊字符破坏 URL 路径。它不能替代后端鉴权和权限检查。

如果删除接口使用 DELETE,是否可以自动重试取决于服务器实现。即使 HTTP 语义通常认为 DELETE 幂等,也要确认后端没有把每次调用记录成独立副作用。


十一、在 Composition API 中管理请求状态

一个请求至少有以下状态:

type RequestState<T> =
  | { status: 'idle'; data: T | null; error: null }
  | { status: 'loading'; data: T | null; error: null }
  | { status: 'success'; data: T; error: null }
  | { status: 'error'; data: T | null; error: AppError }

使用明确状态比单独维护三个布尔值更可靠,因为 loadingsuccesserror 之间的关系由类型表达。

// src/composables/useUsers.ts
import { ref, onUnmounted } from 'vue'
import { listUsers } from '@/api/user-api'
import { normalizeError, type AppError } from '@/http/errors'
import type { PageResult, User } from '@/api/types'

export function useUsers() {
  const data = ref<PageResult<User> | null>(null)
  const error = ref<AppError | null>(null)
  const loading = ref(false)

  let controller: AbortController | null = null
  let requestId = 0

  async function load(page: number, pageSize: number) {
    controller?.abort()
    controller = new AbortController()

    const currentRequestId = ++requestId
    loading.value = true
    error.value = null

    try {
      const result = await listUsers(
        page,
        pageSize,
        controller.signal,
      )

      // 只接受最新请求的结果。
      if (currentRequestId === requestId) {
        data.value = result
      }
    } catch (cause) {
      const normalized = normalizeError(cause)

      if (
        currentRequestId === requestId &&
        normalized.kind !== 'canceled'
      ) {
        error.value = normalized
      }
    } finally {
      if (currentRequestId === requestId) {
        loading.value = false
      }
    }
  }

  function cancel() {
    controller?.abort()
  }

  onUnmounted(cancel)

  return {
    data,
    error,
    loading,
    load,
    cancel,
  }
}

这里同时使用了两种并发保护:

  • AbortController 取消旧请求;
  • requestId 防止旧 Promise 即使返回,也覆盖最新状态。

finally 中也检查请求 ID,否则旧请求结束时可能把新请求的 loading 错误设置为 false

组件使用:

<script setup lang="ts">
import { onMounted } from 'vue'
import { useUsers } from '@/composables/useUsers'

const {
  data,
  error,
  loading,
  load,
} = useUsers()

onMounted(() => {
  void load(1, 20)
})
</script>

<template>
  <section>
    <p v-if="loading">加载中……</p>

    <p v-else-if="error?.kind === 'network'">
      网络连接失败,请检查网络。
    </p>

    <p v-else-if="error?.kind === 'http' && error.status === 403">
      没有权限查看用户列表。
    </p>

    <p v-else-if="error">
      {{ error.message }}
    </p>

    <ul v-else-if="data">
      <li v-for="user in data.items" :key="user.id">
        {{ user.name }} — {{ user.email }}
      </li>
    </ul>
  </section>
</template>

模板只处理稳定的 AppError,没有直接读取 AxiosError.response.data,因此后端错误格式变化时,修改范围集中在 HTTP 错误转换层。


十二、Pinia 中的请求状态:何时集中,何时局部

Pinia 适合管理跨组件共享的领域状态,例如当前用户、购物车、权限和通知。某个页面一次性加载的表格数据,通常更适合放在 Composable 或页面 Store 中。

一个用户 Store 可以这样组织:

// src/stores/user-list.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { listUsers } from '@/api/user-api'
import { normalizeError, type AppError } from '@/http/errors'
import type { User } from '@/api/types'

export const useUserListStore = defineStore('user-list', () => {
  const users = ref<User[]>([])
  const error = ref<AppError | null>(null)
  const loading = ref(false)

  async function fetchUsers(
    page: number,
    pageSize: number,
    signal?: AbortSignal,
  ) {
    loading.value = true
    error.value = null

    try {
      const result = await listUsers(page, pageSize, signal)
      users.value = result.items
    } catch (cause) {
      const normalized = normalizeError(cause)

      if (normalized.kind !== 'canceled') {
        error.value = normalized
      }

      throw normalized
    } finally {
      loading.value = false
    }
  }

  return {
    users,
    error,
    loading,
    fetchUsers,
  }
})

Store 中重新抛出错误很重要。否则组件或调用者无法知道操作失败,只能观察状态,且无法区分“请求失败”和“请求被取消”。

使用 Pinia 时,应遵守它的安装生命周期:

const app = createApp(App)
const pinia = createPinia()

app.use(pinia)
app.use(router)
app.mount('#app')

在组件 setup() 中调用 Store 没有问题,因为此时 Pinia 已由应用提供。若在路由、HTTP 模块或普通工具函数中调用 Store,则应显式传入 Pinia 实例,或设计不依赖组件上下文的状态访问方式。


十三、用请求元数据控制全局行为

并非所有请求都应显示全局 loading,也并非所有请求都应自动刷新令牌或重试。可以通过扩展 Axios 配置表达这些差异:

declare module 'axios' {
  interface AxiosRequestConfig {
    skipAuth?: boolean
    skipGlobalError?: boolean
    retry?: {
      count?: number
      baseDelayMs?: number
      maxDelayMs?: number
    }
  }
}

请求拦截器:

http.interceptors.request.use((config) => {
  if (!config.skipAuth) {
    const token = getAccessToken()

    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
  }

  return config
})

响应拦截器:

http.interceptors.response.use(
  (response) => response,
  (error) => {
    const config = error.config

    if (!config?.skipGlobalError) {
      // 这里可以上报、记录或转换错误,
      // 但不应无条件弹出 UI。
    }

    return Promise.reject(normalizeError(error))
  },
)

请求元数据的价值是让策略显式化。例如登录请求可以设置 skipAuth: true,刷新令牌请求可以设置 skipAuth: trueskipGlobalError: true,避免它们再次触发相同的认证逻辑。


十四、常见错误及诊断路径

误解一:所有 catch 都是服务器返回了错误

如果 error.response 不存在,服务器可能根本没有返回 HTTP 响应。诊断时应检查:

if (axios.isAxiosError(error)) {
  console.log({
    code: error.code,
    message: error.message,
    hasRequest: Boolean(error.request),
    hasResponse: Boolean(error.response),
    status: error.response?.status,
    data: error.response?.data,
  })
}

典型判断:

  • response:服务器确实返回了响应;
  • request 但没有 response:请求已发出,但没有收到可用响应;
  • 两者都没有:可能在请求构造或配置阶段失败。

浏览器中的 CORS 错误经常表现为没有可读的响应状态,不能仅凭前端错误对象判断后端是否返回了 500

误解二:取消请求应该显示错误提示

取消通常是用户主动操作或组件生命周期的正常结果。如果把 ERR_CANCELED 当作普通错误,搜索框、路由切换和页面卸载都会频繁出现错误提示。

错误处理首先应判断:

if (axios.isCancel(error)) {
  return
}

之后再处理超时、网络和 HTTP 错误。

误解三:请求失败就可以重试

网络失败时,服务器可能已经完成了操作。对于付款、创建订单、发送消息等有副作用的请求,自动重试可能造成重复操作。

如果业务确实需要重试非幂等请求,应采用服务端支持的幂等键:

http.post(
  '/orders',
  orderPayload,
  {
    headers: {
      'Idempotency-Key': crypto.randomUUID(),
    },
  },
)

幂等键必须由服务端持久化并执行一致性校验;只在客户端生成请求头,不能单独保证幂等。

误解四:拦截器注册在组件里

如果每次组件挂载都执行:

http.interceptors.response.use(...)

那么每次挂载都会增加一组拦截器。组件销毁后,拦截器仍然存在,最终一个响应可能被重复处理多次。

如果确实需要动态注册,必须保存返回的 interceptor ID 并在卸载时移除:

const id = http.interceptors.response.use(onFulfilled, onRejected)

onUnmounted(() => {
  http.interceptors.response.eject(id)
})

但全局 HTTP 规则通常应在应用启动时注册一次。

误解五:401 一定应该立刻跳转登录页

某些系统使用短期访问令牌和刷新令牌,第一次 401 应先尝试刷新,而不是立即跳转。另一些系统不支持刷新,则应清理认证状态并跳转。

这不是 Axios 的规范行为,而是认证协议的业务约定。客户端不能仅凭状态码猜测正确策略。


十五、测试 HTTP 封装时应验证什么

HTTP 层的测试不应只验证“请求函数被调用”,还要验证故障路径:

  1. 请求拦截器是否注入正确的令牌;
  2. skipAuth 是否阻止注入;
  3. 401 是否按策略刷新或跳转;
  4. 取消是否不会转成用户可见错误;
  5. 500503、网络断开是否按不同条件重试;
  6. POST 默认是否不会自动重试;
  7. 重试次数是否准确;
  8. 取消是否能打断退避等待;
  9. 错误模型是否保留状态码和 request ID;
  10. 旧请求返回时是否不能覆盖新请求状态。

测试时应使用 Mock Service Worker、测试服务器或 Axios adapter,而不是依赖真实后端。真实网络会引入不可控的 DNS、代理、CORS 和时序因素,难以稳定验证重试与取消。


十六、一个可执行的最小初始化结构

目录可以保持如下结构:

src/
├── api/
│   ├── types.ts
│   └── user-api.ts
├── composables/
│   └── useUsers.ts
├── http/
│   ├── auth-token.ts
│   ├── client.ts
│   ├── errors.ts
│   ├── retry.ts
│   └── setup.ts
├── router/
│   └── index.ts
├── stores/
│   └── user-list.ts
├── App.vue
└── main.ts

main.ts 中只初始化一次:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import './http/setup'

const app = createApp(App)

app.use(createPinia())
app.use(router)
app.mount('#app')

http/setup.ts 负责注册拦截器;client.ts 只负责创建实例;errors.ts 只负责错误归一化;retry.ts 只负责重试机制。这样的拆分不是为了增加文件数量,而是为了让每个故障路径都有清晰的归属。


十七、最终的数据流与取舍

一次正常请求的状态流可以表示为:

sequenceDiagram
    participant C as Vue 组件
    participant S as Composable/Pinia
    participant A as API 函数
    participant H as Axios 实例
    participant B as 后端

    C->>S: load()
    S->>A: listUsers(signal)
    A->>H: GET /users
    H->>H: 请求拦截器注入令牌
    H->>B: HTTP 请求
    B-->>H: 2xx 响应
    H->>H: 响应成功拦截器
    H-->>A: response.data
    A-->>S: 领域数据
    S-->>C: success 状态

失败和取消则走不同路径:

flowchart TD
    R[发起请求] --> Q{结果}
    Q -->|2xx| OK[返回数据]
    Q -->|401| AUTH[刷新或清理认证状态]
    Q -->|408/429/5xx| RETRY{满足重试条件?}
    Q -->|网络断开| RETRY
    Q -->|AbortController.abort| CANCEL[标记为 canceled]
    Q -->|其他 HTTP 错误| HTTP[转换为 http 错误]
    RETRY -->|是| WAIT[退避等待]
    WAIT --> R
    RETRY -->|否| NET[转换为 network/timeout 错误]
    AUTH --> FAIL[返回统一 AppError 或重新请求]

一个可靠的封装并不是“把所有逻辑都放到拦截器里”,而是让每种机制只承担自己的语义:

  • Axios 实例:统一传输配置;
  • 请求拦截器:注入当前请求所需的元数据;
  • 响应拦截器:转换和分流,不吞掉错误;
  • AbortController:终止不再需要的请求;
  • 超时:限制等待窗口;
  • 重试:只针对明确、安全、可观测的临时失败;
  • 错误模型:把底层差异转换成业务层可判断的类型;
  • Composable 或 Pinia:管理响应式状态和并发结果;
  • Vue Router:处理认证失效后的导航,而不是由 Axios 直接操纵页面展示。

当取消、重试和错误转换都显式建模后,HTTP 层才能在页面切换、搜索竞态、服务暂时不可用、认证过期和网络断开等情况下保持可预测行为。


系列导航与关联阅读

官方资料

本文依据 Vue、Vite 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。