第 137 章:HTTP 与请求封装
学习目标
- 掌握 axios 在 Vue 3 项目中的封装
- 学会拦截器、错误处理、Token 刷新
- 理解请求缓存、并发控制、取消请求
- 实现完整的 HTTP 客户端
一、为什么需要封装
二、基础封装
2.1 安装
bash
pnpm add axios2.2 简单实例
typescript
// api/http.ts
import axios from 'axios';
const http = axios.create({
baseURL: import.meta.env.VITE_API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
});
export default http;三、类型化
3.1 响应类型
typescript
interface ApiResponse<T = unknown> {
code: number;
data: T;
message: string;
}
interface PageData<T> {
items: T[];
total: number;
page: number;
pageSize: number;
}3.2 泛型请求
typescript
async function getUser(id: number): Promise<User> {
const res = await http.get<ApiResponse<User>>(`/users/${id}`);
return res.data.data;
}3.3 简化返回
typescript
// 拦截器解包,直接返回 data
http.interceptors.response.use((res) => {
return res.data.data; // 直接返回业务数据
});
// 使用
const user = await http.get<User>('/users/1');
// user 就是 User 类型,无须再 .data四、拦截器
4.1 请求拦截
typescript
http.interceptors.request.use(
(config) => {
// 添加 token
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
// 添加时间戳
config.headers['X-Request-Time'] = Date.now().toString();
return config;
},
(error) => {
return Promise.reject(error);
}
);4.2 响应拦截
typescript
http.interceptors.response.use(
(response) => {
const { code, data, message } = response.data;
if (code === 0) {
return data;
}
if (code === 401) {
// 跳转登录
router.push('/login');
}
showMessage(message || '请求失败');
return Promise.reject(new Error(message));
},
(error) => {
if (error.response?.status === 401) {
// 未授权
} else if (error.response?.status === 500) {
showMessage('服务器错误');
}
return Promise.reject(error);
}
);4.3 拦截器分类
typescript
// http.ts
const instance = axios.create();
// 通用拦截器
instance.interceptors.request.use(addAuth);
instance.interceptors.response.use(handleResponse);
// 业务侧添加自定义
instance.interceptors.request.use(customLogic);五、Token 刷新
5.1 简单版本
typescript
let isRefreshing = false;
let refreshSubscribers: Array<(token: string) => void> = [];
http.interceptors.response.use(
(response) => response,
async (error) => {
const { config, response } = error;
if (response?.status === 401 && !config._retry) {
if (!isRefreshing) {
isRefreshing = true;
config._retry = true;
try {
const newToken = await refreshToken();
localStorage.setItem('token', newToken);
refreshSubscribers.forEach((cb) => cb(newToken));
refreshSubscribers = [];
config.headers.Authorization = `Bearer ${newToken}`;
return http(config);
} catch (refreshError) {
// 跳转登录
router.push('/login');
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
} else {
return new Promise((resolve) => {
refreshSubscribers.push((token) => {
config.headers.Authorization = `Bearer ${token}`;
resolve(http(config));
});
});
}
}
return Promise.reject(error);
}
);5.2 抽离 composable
typescript
// composables/useAuth.ts
export function useAuth() {
const router = useRouter();
async function refreshToken() {
const refreshToken = localStorage.getItem('refresh-token');
const res = await http.post<{ token: string }>('/auth/refresh', {
refreshToken
});
return res.token;
}
function logout() {
localStorage.removeItem('token');
router.push('/login');
}
return { refreshToken, logout };
}六、错误处理
6.1 错误类型
typescript
interface RequestError {
code: string;
message: string;
status?: number;
data?: unknown;
}
function handleError(error: any): RequestError {
if (axios.isAxiosError(error)) {
return {
code: error.code || 'AXIOS_ERROR',
message: error.message,
status: error.response?.status,
data: error.response?.data
};
}
if (error instanceof Error) {
return { code: 'UNKNOWN', message: error.message };
}
return { code: 'UNKNOWN', message: String(error) };
}6.2 错误日志
typescript
http.interceptors.response.use(
(res) => res,
(error) => {
const info = handleError(error);
console.error('[HTTP Error]', {
url: error.config?.url,
method: error.config?.method,
status: info.status,
message: info.message
});
// 上报到监控
if (import.meta.env.PROD) {
reportError(info);
}
return Promise.reject(error);
}
);6.3 用户提示
typescript
// composables/useMessage.ts
export function useMessage() {
function error(message: string) {
ElMessage.error(message);
}
function success(message: string) {
ElMessage.success(message);
}
return { error, success };
}七、请求缓存
7.1 简单缓存
typescript
const cache = new Map<string, { data: any; expire: number }>();
async function getWithCache<T>(url: string, ttl = 60000): Promise<T> {
const cached = cache.get(url);
if (cached && cached.expire > Date.now()) {
return cached.data;
}
const data = await http.get<T>(url);
cache.set(url, { data, expire: Date.now() + ttl });
return data;
}7.2 缓存策略
typescript
// SWR 风格
async function fetchSWR<T>(url: string): Promise<T> {
const cached = cache.get(url);
if (cached) {
fetch(url).then((data) => cache.set(url, { data, expire: Date.now() + 60000 }));
return cached.data;
}
return fetch(url);
}7.3 取消请求
typescript
const controller = new AbortController();
http.get('/users', { signal: controller.signal });
// 取消
controller.abort();八、并发控制
8.1 全部完成
typescript
const [users, posts] = await Promise.all([
http.get('/users'),
http.get('/posts')
]);8.2 限制并发
typescript
async function limit<T>(tasks: Array<() => Promise<T>>, max: number): Promise<T[]> {
const results: T[] = [];
const executing: Promise<unknown>[] = [];
for (const task of tasks) {
const p = task().then((res) => {
results.push(res);
});
executing.push(p);
if (executing.length >= max) {
await Promise.race(executing);
}
}
return Promise.all(results).then(() => results);
}8.3 失败重试
typescript
async function retry<T>(
fn: () => Promise<T>,
retries: number = 3,
delay: number = 1000
): Promise<T> {
try {
return await fn();
} catch (e) {
if (retries <= 0) throw e;
await new Promise((r) => setTimeout(r, delay));
return retry(fn, retries - 1, delay * 2);
}
}九、文件上传
9.1 单文件
vue
<template>
<input type="file" @change="handleUpload" />
</template>
<script setup lang="ts">
async function handleUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const res = await http.post('/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e) => {
const progress = (e.loaded / e.total!) * 100;
console.log(`上传进度: ${progress}%`);
}
});
console.log('上传成功:', res);
}
</script>9.2 多文件
typescript
const files = ref<FileList | null>(null);
async function uploadMultiple() {
if (!files.value) return;
const formData = new FormData();
Array.from(files.value).forEach((file) => {
formData.append('files', file);
});
await http.post('/upload/multiple', formData);
}9.3 进度
typescript
http.post('/upload', formData, {
onUploadProgress: (progressEvent) => {
const percent = (progressEvent.loaded / progressEvent.total!) * 100;
uploadProgress.value = percent;
}
});十、文件下载
typescript
async function downloadFile(url: string, filename: string) {
const res = await http.get(url, { responseType: 'blob' });
const blob = new Blob([res.data]);
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}十一、请求封装
11.1 通用请求
typescript
// api/http.ts
class HttpClient {
get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'GET', url });
}
post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'POST', url, data });
}
put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'PUT', url, data });
}
delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.request<T>({ ...config, method: 'DELETE', url });
}
private async request<T>(config: AxiosRequestConfig): Promise<T> {
const res = await http.request<ApiResponse<T>>(config);
return res.data;
}
}
export default new HttpClient();11.2 业务封装
typescript
// api/user.ts
import http from './http';
export const userApi = {
list: (params?: ListParams) => http.get<PageData<User>>('/users', { params }),
get: (id: number) => http.get<User>(`/users/${id}`),
create: (data: CreateUserDTO) => http.post<User>('/users', data),
update: (id: number, data: UpdateUserDTO) => http.put<User>(`/users/${id}`, data),
delete: (id: number) => http.delete(`/users/${id}`)
};十二、TypeScript 高级
12.1 路径参数
typescript
// 工具类型
type ReplaceParams<T extends string> = T extends `${infer Before}:${infer Param}/${infer After}`
? `${Before}${string}/${After}`
: T extends `${infer Before}:${infer Param}`
? `${Before}${string}`
: T;
function get<T extends string>(path: ReplaceParams<T>): Promise<any> {
return http.get(path);
}
get('/users/:id'); // OK12.2 接口类型推
typescript
async function request<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
return http.request<ApiResponse<T>, ApiResponse<T>>({ url, ...config }).then((res) => res.data);
}十三、测试
13.1 Mock axios
typescript
import { vi } from 'vitest';
import axios from 'axios';
vi.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
beforeEach(() => {
mockedAxios.create.mockReturnValue(mockedAxios);
});
it('fetch user', async () => {
mockedAxios.get.mockResolvedValue({ data: { code: 0, data: { id: 1, name: 'Tom' } } });
const user = await userApi.get(1);
expect(user).toEqual({ id: 1, name: 'Tom' });
});13.2 MSW
typescript
import { setupServer } from 'msw/node';
import { rest } from 'msw';
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json({ code: 0, data: [] }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());十四、推荐库
| 库 | 特点 |
|---|---|
| axios | 主流、功能丰富 |
| ofetch | 现代化、轻量 |
| ky | 浏览器 fetch 封装 |
| alova | 轻量级请求库 |
| Vue Query | 服务端状态 |
十五、本章小结
| 概念 | 关键 |
|---|---|
| 拦截器 | 请求添加 token、响应处理错误 |
| 类型化 | 泛型 T,直接返回数据 |
| Token 刷新 | 拦截器 + 队列 |
| 错误处理 | 统一错误类型 + 提示 |
| 缓存 | Map + TTL |
| 取消 | AbortController |
| 并发 | Promise.all / limit |
动手练习
- 基础封装:封装 axios 实例,统一拦截器
- Token 刷新:实现 401 自动刷新逻辑
- 错误处理:实现统一的错误提示与上报
- 文件上传:实现带进度条的文件上传
- 缓存策略:为 GET 请求添加短期缓存
推荐阅读
- 📖 axios 文档 — 完整 API
- 📖 TypeScript 集成 — 类型化
- 🌐 Vue Query — 服务端状态
- 📖 MSW — Mock 服务
下一章:第 138 章:用户认证与权限 →