Skip to content

L21 · Axios 封装:前后端联通 ​

🎯 本节目标:封装 Axios 实例,实现请求/响应拦截器,构建 API 模块化架构
📦 本节产出:Axios 封装层 + useRequest composable + 错误统一处理
🔗 前置钩子:L20 的 RESTful API(有了后端接口可以调用)
🔗 后续钩子:L22 将在拦截器中添加 JWT Token

1. 为什么需要封装 Axios ​

直接在组件中用 fetch 或裸 axios:

typescript
// ❌ 每个组件都要写一遍
const res = await fetch('http://localhost:3000/api/products', {
  headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
})
if (!res.ok) throw new Error('请求失败')
const data = await res.json()

问题:

  • 每次都要写 baseURL
  • 每次都要手动加 Token
  • 错误处理散落各处
  • 超时、重试逻辑重复

封装后:

typescript
// ✅ 一行调用
const { data } = await productApi.getList()

2. 安装 Axios ​

在 client/ 目录安装 Axios 1,并提交更新后的锁文件:

bash
npm install axios@1

3. 创建 Axios 实例 ​

typescript
// client/src/utils/request.ts
import axios, { type AxiosRequestConfig } from 'axios'

export const http = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://127.0.0.1:3000/api',
  timeout: 10000,
})

// 保留 AxiosResponse 的类型;只在这一层取出服务端 JSON body
const request = {
  async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response = await http.get<T>(url, config)
    return response.data
  },
  async post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    const response = await http.post<T>(url, data, config)
    return response.data
  },
  async put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    const response = await http.put<T>(url, data, config)
    return response.data
  },
  async patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
    const response = await http.patch<T>(url, data, config)
    return response.data
  },
  async delete(url: string, config?: AxiosRequestConfig): Promise<void> {
    await http.delete(url, config) // 本课程 DELETE 成功返回 204,没有 body
  },
}

export default request

request 是我们写的 body 包装层,http 才是原始 Axios 实例;后面的拦截器都注册在 http 上。Axios 泛型声明不会验证实际 JSON 格式。这里不预设全局 Content-Type,普通对象由 Axios 序列化为 JSON,L27 的 FormData 也能保留浏览器生成的 multipart boundary。

4. 请求拦截器 ​

在请求发出之前统一处理。第 4、5 节代码接着写在同一个 request.ts 文件中;L22 尚未完成时,本地没有 token,公开商品请求仍可调用:

typescript
// client/src/utils/request.ts(续)
http.interceptors.request.use(config => {
  const token = localStorage.getItem('access-token')
  if (token) config.headers.set('Authorization', `Bearer ${token}`)

  if (import.meta.env.DEV) {
    // 只记录方法和路径,不输出密码、token 或完整请求体
    console.debug(`${config.method?.toUpperCase()} ${config.url}`)
  }
  return config
})

5. 响应拦截器 ​

在响应返回之后统一错误形状,保留 status 和字段错误。成功分支仍返回 AxiosResponse,由第 3 节包装层解包一次。直接在拦截器返回 response.data 不会自动改变 Axios 实例的 TypeScript 方法签名;这里用显式包装避免两者错位。Axios 拦截器

L21 只把 401 作为错误交给调用方;L22 再替换这个拦截器,加入刷新与退出登录。登录失败本身不应在底层触发无条件页面重载。

typescript
// client/src/utils/request.ts(续)
export class ApiError extends Error {
  constructor(message: string, public status?: number, public details?: string[]) {
    super(message)
    this.name = 'ApiError'
  }
}

export function toApiError(error: unknown): Error {
  if (axios.isCancel(error)) return error
  if (axios.isAxiosError(error)) {
    if (error.response) {
      const { status, data } = error.response
      const message = typeof data?.message === 'string' ? data.message : `请求失败 (${status})`
      const details = Array.isArray(data?.errors)
        ? data.errors.filter((item: unknown): item is string => typeof item === 'string') : undefined
      return new ApiError(message, status, details)
    }
    if (['ECONNABORTED', 'ETIMEDOUT'].includes(error.code || '')) {
      return new ApiError('请求超时,请稍后重试')
    }
    return new ApiError(navigator.onLine ? '无法连接服务端' : '网络已断开')
  }
  return error instanceof Error ? error : new Error('请求失败')
}

http.interceptors.response.use(
  response => response,
  error => Promise.reject(toApiError(error)),
)

6. API 模块化 ​

先定义传输类型,再按业务拆分 API。MongoDB 的 ObjectId 和 Date 经过 JSON 序列化后分别是字符串和 ISO 时间字符串,不要把服务端 Mongoose Document 类型直接搬到前端。

typescript
// client/src/types/api.ts
export interface ApiResponse<T> { success: true; data: T }
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: { page: number; limit: number; total: number; totalPages: number }
}
typescript
// client/src/types/product.ts
export interface ProductInput {
  name: string
  description: string
  price: number
  category: string
  images: string[]
  stock: number
}
export interface Product extends ProductInput {
  _id: string
  rating: number
  reviewCount: number
  isActive: boolean
  createdAt: string
  updatedAt: string
}
typescript
// client/src/types/auth.ts
export interface User {
  id: string
  name: string
  email: string
  role: 'user' | 'admin'
  avatar?: string
}
export interface AuthTokens { accessToken: string; refreshToken: string }
export interface AuthData extends AuthTokens { user: User }
typescript
// client/src/types/order.ts
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'delivered' | 'reviewed'
  | 'cancelled' | 'refunding' | 'refunded'
export interface ShippingAddress { name: string; phone: string; address: string; city: string }
export interface OrderItem {
  product: string
  name: string
  price: number
  quantity: number
  image: string
}
export interface Order {
  _id: string
  user: string
  items: OrderItem[]
  totalAmount: number
  status: OrderStatus
  shippingAddress: ShippingAddress
  paymentMethod: string
  paidAt?: string
  deliveredAt?: string
  createdAt: string
  updatedAt: string
  availableActions?: OrderStatus[]
}
export interface CreateOrderInput {
  items: { productId: string; quantity: number }[]
  shippingAddress: ShippingAddress
}
typescript
// client/src/api/products.ts
import request from '@/utils/request'
import type { Product, ProductInput } from '@/types/product'
import type { ApiResponse, PaginatedResponse } from '@/types/api'

export interface ProductListParams {
  page?: number
  limit?: number
  search?: string
  category?: string
  sort?: '-createdAt' | 'createdAt' | 'price' | '-price' | '-rating' | '-reviewCount'
  minPrice?: number
  maxPrice?: number
}

export const productApi = {
  getList(params?: ProductListParams, signal?: AbortSignal) {
    return request.get<PaginatedResponse<Product>>('/products', { params, signal })
  },
  getById(id: string, signal?: AbortSignal) {
    return request.get<ApiResponse<Product>>(`/products/${encodeURIComponent(id)}`, { signal })
  },
  create(data: ProductInput) {
    return request.post<ApiResponse<Product>>('/products', data)
  },
  replace(id: string, data: ProductInput) {
    return request.put<ApiResponse<Product>>(`/products/${encodeURIComponent(id)}`, data)
  },
  update(id: string, data: Partial<ProductInput>) {
    return request.patch<ApiResponse<Product>>(`/products/${encodeURIComponent(id)}`, data)
  },
  delete(id: string, signal?: AbortSignal) {
    return request.delete(`/products/${encodeURIComponent(id)}`, { signal })
  },
}
typescript
// client/src/api/auth.ts
import request from '@/utils/request'
import type { ApiResponse } from '@/types/api'
import type { User, AuthData, AuthTokens } from '@/types/auth'

export const authApi = {
  login(email: string, password: string) {
    return request.post<ApiResponse<AuthData>>('/auth/login', { email, password })
  },
  register(name: string, email: string, password: string) {
    return request.post<ApiResponse<AuthData>>('/auth/register', { name, email, password })
  },
  getProfile() {
    return request.get<ApiResponse<User>>('/auth/profile')
  },
  refresh(refreshToken: string) {
    return request.post<ApiResponse<AuthTokens>>('/auth/refresh', { refreshToken })
  },
}
typescript
// client/src/api/orders.ts
import request from '@/utils/request'
import type { ApiResponse, PaginatedResponse } from '@/types/api'
import type { Order, OrderStatus, CreateOrderInput } from '@/types/order'

export const orderApi = {
  create(input: CreateOrderInput) {
    return request.post<ApiResponse<Order>>('/orders', input)
  },
  getMyOrders(params?: { status?: OrderStatus; page?: number; limit?: number }, signal?: AbortSignal) {
    return request.get<PaginatedResponse<Order>>('/orders/my', { params, signal })
  },
  getById(id: string, signal?: AbortSignal) {
    return request.get<ApiResponse<Order>>(`/orders/${encodeURIComponent(id)}`, { signal })
  },
  updateStatus(id: string, status: OrderStatus) {
    return request.patch<ApiResponse<Order>>(`/orders/${encodeURIComponent(id)}/status`, { status })
  },
}

认证和订单文件先约定类型,等 L22、L25 加入对应后端路由后再调用。购物车在 L24 采用本地 Pinia 状态,本课程没有独立 cart API。

目录结构:

client/src/api/
├── products.ts    # 商品 API
├── auth.ts        # 认证 API
└── orders.ts      # 订单 API(L25 接入)

7. useRequest Composable ​

把请求状态和取消逻辑放在同一个 composable 中。requestFn 的第一个参数是 AbortSignal;后面是 execute 的业务参数。immediateArgs: [] 表示立即用空参数执行,有参数时传入对应元组。请在组件 setup 中同步调用,作用域销毁时会自动取消。

API 数据用 shallowRef 保存,每次响应替换整体对象;若只修改内部字段,它不会像深层 ref 那样自动触发更新。Vue shallowRef

typescript
// client/src/composables/useRequest.ts
import { onScopeDispose, ref, shallowRef } from 'vue'
import axios from 'axios'

export function useRequest<T, Args extends unknown[] = []>(
  requestFn: (signal: AbortSignal, ...args: Args) => Promise<T>,
  options?: {
    immediateArgs?: Args
    initialData?: T
    onSuccess?: (data: T) => void
    onError?: (error: Error) => void
  },
) {
  const data = shallowRef<T | null>(options?.initialData ?? null)
  const loading = ref(false)
  const error = ref<string | null>(null)
  let controller: AbortController | null = null
  let generation = 0
  let disposed = false

  function cancel() {
    generation++
    controller?.abort()
    controller = null
    loading.value = false
  }

  async function execute(...args: Args): Promise<T | null> {
    if (disposed) return null
    controller?.abort()
    const current = ++generation
    const activeController = new AbortController()
    controller = activeController
    loading.value = true
    error.value = null
    try {
      const result = await requestFn(activeController.signal, ...args)
      // 即使某个适配器没有响应 abort,也不接受过时结果
      if (current !== generation || disposed) return null
      data.value = result
      options?.onSuccess?.(result)
      return result
    } catch (cause) {
      if (current !== generation || disposed || activeController.signal.aborted || axios.isCancel(cause)) return null
      const failure = cause instanceof Error ? cause : new Error('请求失败')
      error.value = failure.message
      options?.onError?.(failure)
      return null
    } finally {
      if (current === generation) {
        loading.value = false
        controller = null
      }
    }
  }

  onScopeDispose(() => {
    disposed = true
    cancel()
  })
  if (options?.immediateArgs) void execute(...options.immediateArgs)
  return { data, loading, error, execute, cancel }
}

组件中使用 ​

vue
<!-- client/src/views/ProductListView.vue:L21 联通与管理操作练习,L23 换成商店列表 -->
<script setup lang="ts">
import { productApi } from '@/api/products'
import { useRequest } from '@/composables/useRequest'

const { data: products, loading, error, execute: fetchProducts } = useRequest(
  signal => productApi.getList({ page: 1, limit: 20 }, signal),
  { immediateArgs: [] },
)
const { loading: deleting, error: deleteError, execute: deleteProduct } = useRequest(
  (signal, id: string) => productApi.delete(id, signal),
  { onSuccess: () => { void fetchProducts() } },
)
</script>

<template>
  <main>
    <h1>商品接口联通练习</h1>
    <p v-if="deleteError" role="alert">{{ deleteError }}</p>
    <p v-if="loading">加载中...</p>
    <p v-else-if="error" role="alert">{{ error }}</p>
    <section v-else-if="products">
      <p v-if="products.data.length === 0">还没有商品,请先完成 L20 的创建请求。</p>
      <article v-for="p in products.data" :key="p._id">
        <h2>{{ p.name }}</h2>
        <button :disabled="deleting" @click="deleteProduct(p._id)">
          {{ deleting ? '删除中...' : '删除测试商品' }}
        </button>
      </article>
    </section>
  </main>
</template>

8. 取消请求(AbortController) ​

第 7 节已经实现取消与请求序号检查,不需要再覆盖一次 useRequest。关键在于把 signal 一直传到 Axios;只创建 AbortController 而不传 signal 不会取消网络请求:

typescript
// 调用链示意:useRequest → API 方法 → request 包装层 → Axios
const { execute: loadProduct, cancel } = useRequest(
  (signal, id: string) => productApi.getById(id, signal),
)
// 在事件中调用 loadProduct(id);cancel() 可主动取消本次读取

取消仍以 rejected Promise 向上传递,由 useRequest 判断为取消并忽略。不要在拦截器里直接 return 吞掉错误,否则调用方会收到一个成功的 undefined。取消客户端请求不等于撤销已执行的服务端写入;删除、下单等操作还要禁止重复提交并按业务设计幂等。Axios 取消请求

接到电商页面入口 ​

保留 Phase 2 的 HomeView、任务组件和单元测试作为旧业务练习,不把它们重命名成商品页面;这些测试仍只覆盖任务管理功能。用下面代码替换 client/src/router/index.ts 和 client/src/App.vue,移除旧任务路由和旧模拟登录守卫。后续各课会向这份电商路由加入登录、详情、购物车和订单页面。

typescript
// client/src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
export default createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes: [
    { path: '/', redirect: '/products' },
    { path: '/products', name: 'products', component: () => import('@/views/ProductListView.vue') },
    { path: '/:pathMatch(.*)*', redirect: '/products' },
  ],
})
vue
<!-- client/src/App.vue -->
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
  <header><RouterLink to="/products">商品</RouterLink></header>
  <RouterView />
</template>
typescript
// client/src/main.ts:保留 Pinia 持久化插件,后续购物车会使用
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue'
import router from './router'
import './assets/main.css'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
createApp(App).use(pinia).use(router).mount('#app')

启动 L20 的后端,再在 client/ 执行 npm run dev,访问 /products。L22 之前不要保留旧 access-token / refresh-token 测试值。也可在 client/.env.development 设置 VITE_API_URL=http://127.0.0.1:3000/api;它是公开 API 地址,不能包含服务端密钥。


9. 本节总结 ​

架构图 ​

检查清单 ​

  • [ ] 能创建 Axios 自定义实例(baseURL / timeout / headers)
  • [ ] 能实现请求拦截器(自动注入 Token)
  • [ ] 能实现响应拦截器(统一错误形状,401 刷新留给 L22)
  • [ ] 能按业务模块拆分 API(products / auth / orders)
  • [ ] 能封装 useRequest composable(loading / error / data)
  • [ ] 能在组件卸载时取消未完成的请求
  • [ ] 理解 Axios 实例 vs 全局 axios 的区别

🐞 防坑指南 ​

坑说明正确做法
把取消改成成功拦截器直接 return,调用方收到 undefined保持 rejection,在 composable 中识别取消
Axios 与业务 data 混淆去掉 Axios 层后,后端 envelope 仍有 data包装层解一次;API 返回 ApiResponse 或 PaginatedResponse
401 无限跳转登录页的请求也触发 401 → 循环登录/注册接口排除在 401 处理外
组件卸载后仍更新状态异步请求回来时组件已销毁onScopeDispose 中取消请求,并拒绝过时结果

📐 最佳实践 ​

  1. 按服务配置实例:不同 baseURL、鉴权或超时策略的后端分别创建实例,避免把凭证发给错误的服务
  2. API 分模块:按业务领域拆分(productApi / authApi),不要全放一个文件
  3. 读取结果只接受最新一次:搜索、切页可取消上次请求,同时用序号挡住迟到响应;不要把取消当作服务端回滚
  4. 错误提示分级:拦截器只处理通用错误(401/500),业务错误留给组件处理

Git 提交 ​

bash
git add .
git commit -m "L21: Axios 封装 + 拦截器 + API 模块 + useRequest"

🔗 → 下一节 ​

L22 将加入注册、登录、Token 刷新和路由守卫,并在后端保护商品写入接口。