L21 · Axios 封装:前后端联通
🎯 本节目标:封装 Axios 实例,实现请求/响应拦截器,构建 API 模块化架构
📦 本节产出:Axios 封装层 + useRequest composable + 错误统一处理
🔗 前置钩子:L20 的 RESTful API(有了后端接口可以调用)
🔗 后续钩子:L22 将在拦截器中添加 JWT Token1. 为什么需要封装 Axios
直接在组件中用 fetch 或裸 axios:
// ❌ 每个组件都要写一遍
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
- 错误处理散落各处
- 超时、重试逻辑重复
封装后:
// ✅ 一行调用
const { data } = await productApi.getList()2. 安装 Axios
在 client/ 目录安装 Axios 1,并提交更新后的锁文件:
npm install axios@13. 创建 Axios 实例
// 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 requestrequest 是我们写的 body 包装层,http 才是原始 Axios 实例;后面的拦截器都注册在 http 上。Axios 泛型声明不会验证实际 JSON 格式。这里不预设全局 Content-Type,普通对象由 Axios 序列化为 JSON,L27 的 FormData 也能保留浏览器生成的 multipart boundary。
4. 请求拦截器
在请求发出之前统一处理。第 4、5 节代码接着写在同一个 request.ts 文件中;L22 尚未完成时,本地没有 token,公开商品请求仍可调用:
// 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 再替换这个拦截器,加入刷新与退出登录。登录失败本身不应在底层触发无条件页面重载。
// 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 类型直接搬到前端。
// 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 }
}// 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
}// 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 }// 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
}// 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 })
},
}// 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 })
},
}// 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
// 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 }
}组件中使用
<!-- 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 不会取消网络请求:
// 调用链示意: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,移除旧任务路由和旧模拟登录守卫。后续各课会向这份电商路由加入登录、详情、购物车和订单页面。
// 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' },
],
})<!-- client/src/App.vue -->
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router'
</script>
<template>
<header><RouterLink to="/products">商品</RouterLink></header>
<RouterView />
</template>// 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 中取消请求,并拒绝过时结果 |
📐 最佳实践
- 按服务配置实例:不同 baseURL、鉴权或超时策略的后端分别创建实例,避免把凭证发给错误的服务
- API 分模块:按业务领域拆分(
productApi/authApi),不要全放一个文件 - 读取结果只接受最新一次:搜索、切页可取消上次请求,同时用序号挡住迟到响应;不要把取消当作服务端回滚
- 错误提示分级:拦截器只处理通用错误(401/500),业务错误留给组件处理
Git 提交
git add .
git commit -m "L21: Axios 封装 + 拦截器 + API 模块 + useRequest"🔗 → 下一节
L22 将加入注册、登录、Token 刷新和路由守卫,并在后端保护商品写入接口。