前端 API 请求封装最佳实践
每个项目都要处理 API 请求,封装得好坏差别很大。
基础封装
使用 Fetch
// api/request.js
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
async function request(url, options = {}) {
const response = await fetch(`${BASE_URL}${url}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.message || 'Request failed');
}
return response.json();
}
export const api = {
get: (url, params) => {
const searchParams = new URLSearchParams(params);
return request(`${url}?${searchParams}`);
},
post: (url, data) => request(url, { method: 'POST', body: JSON.stringify(data) }),
put: (url, data) => request(url, { method: 'PUT', body: JSON.stringify(data) }),
delete: (url) => request(url, { method: 'DELETE' }),
};
使用 Axios
import axios from 'axios';
const instance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截
instance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 响应拦截
instance.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
// Token 过期,跳转登录
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default instance;
分层设计
api/
├── request.js # 基础请求封装
├── endpoints.js # 接口地址
├── user.js # 用户相关接口
├── post.js # 文章相关接口
└── index.js # 统一导出
接口定义
// api/endpoints.js
export const ENDPOINTS = {
USER: {
LOGIN: '/auth/login',
LOGOUT: '/auth/logout',
PROFILE: '/user/profile',
UPDATE: '/user/update',
},
POST: {
LIST: '/posts',
DETAIL: (id) => `/posts/${id}`,
CREATE: '/posts',
UPDATE: (id) => `/posts/${id}`,
DELETE: (id) => `/posts/${id}`,
},
};
// api/user.js
import request from './request';
import { ENDPOINTS } from './endpoints';
export const userApi = {
login: (credentials) => request.post(ENDPOINTS.USER.LOGIN, credentials),
logout: () => request.post(ENDPOINTS.USER.LOGOUT),
getProfile: () => request.get(ENDPOINTS.USER.PROFILE),
updateProfile: (data) => request.put(ENDPOINTS.USER.UPDATE, data),
};
TypeScript 类型定义
// types/api.ts
interface ApiResponse<T> {
code: number;
message: string;
data: T;
}
interface PaginatedResponse<T> {
list: T[];
total: number;
page: number;
pageSize: number;
}
// types/user.ts
interface User {
id: number;
name: string;
email: string;
avatar?: string;
}
interface LoginRequest {
email: string;
password: string;
}
interface LoginResponse {
token: string;
user: User;
}
// api/user.ts
import type { ApiResponse, LoginRequest, LoginResponse, User } from '@/types';
export const userApi = {
login: (data: LoginRequest): Promise<ApiResponse<LoginResponse>> =>
request.post(ENDPOINTS.USER.LOGIN, data),
getProfile: (): Promise<ApiResponse<User>> =>
request.get(ENDPOINTS.USER.PROFILE),
};
错误处理
统一错误类
class ApiError extends Error {
constructor(code, message, data) {
super(message);
this.code = code;
this.data = data;
this.name = 'ApiError';
}
}
// 在请求中使用
instance.interceptors.response.use(
(response) => {
const { code, message, data } = response.data;
if (code !== 0) {
throw new ApiError(code, message, data);
}
return data;
},
(error) => {
if (error.response) {
throw new ApiError(
error.response.status,
error.response.data.message,
error.response.data
);
}
throw new ApiError(0, 'Network error');
}
);
全局错误处理
// 全局 toast 或 modal
function handleApiError(error) {
if (error instanceof ApiError) {
switch (error.code) {
case 401:
router.push('/login');
break;
case 403:
toast.error('没有权限');
break;
case 500:
toast.error('服务器错误');
break;
default:
toast.error(error.message);
}
} else {
toast.error('未知错误');
}
}
请求取消
// 使用 AbortController
class RequestManager {
controllers = new Map();
request(key, url, options) {
// 取消之前的请求
this.cancel(key);
const controller = new AbortController();
this.controllers.set(key, controller);
return fetch(url, {
...options,
signal: controller.signal,
}).finally(() => {
this.controllers.delete(key);
});
}
cancel(key) {
const controller = this.controllers.get(key);
if (controller) {
controller.abort();
this.controllers.delete(key);
}
}
cancelAll() {
this.controllers.forEach((controller) => controller.abort());
this.controllers.clear();
}
}
请求缓存
// 简单内存缓存
const cache = new Map();
function cachedRequest(key, requestFn, ttl = 60000) {
const cached = cache.get(key);
if (cached && Date.now() - cached.time < ttl) {
return Promise.resolve(cached.data);
}
return requestFn().then((data) => {
cache.set(key, { data, time: Date.now() });
return data;
});
}
// 使用
const userProfile = await cachedRequest('user-profile', () =>
userApi.getProfile()
);
重试机制
async function requestWithRetry(fn, maxRetries = 3, delay = 1000) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (i === maxRetries - 1) throw error;
// 只重试网络错误和 5xx 错误
if (error.code === 0 || (error.code >= 500 && error.code < 600)) {
await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)));
} else {
throw error;
}
}
}
}
实战对比
| 方案 | 优点 | 缺点 |
|---|---|---|
| 原生 fetch | 零依赖 | 封装成本高 |
| Axios | 功能完善 | 体积较大 |
| ky | 轻量现代 | 生态较小 |
| tanstack-query | 缓存管理好 | 学习成本 |
总结
好的 API 封装应该:
- 统一入口:所有请求走同一通道
- 类型安全:TypeScript 类型完整
- 错误处理:统一捕获、统一展示
- 可配置:拦截器、超时、重试
- 易测试:接口定义独立
不要过度封装,够用就好。