HTTP 请求封装
概述
在实际项目中,通常需要封装统一的 HTTP 请求工具,处理通用逻辑如请求/响应拦截、错误处理、重试机制等。本文详细介绍 HTTP 请求工具的设计与实现。
封装思路
核心功能
HTTP 请求封装
├── 基础配置(baseURL、timeout、headers)
├── 请求拦截器(添加 token、loading 等)
├── 响应拦截器(统一错误处理、数据转换)
├── 错误处理(网络错误、业务错误、超时)
├── 取消请求(防重复提交、组件卸载取消)
├── 重试机制(网络不稳定时自动重试)
└── TypeScript 类型支持Axios 封装
基础封装
typescript
// src/utils/request.ts
import axios, {
AxiosInstance,
AxiosRequestConfig,
AxiosResponse,
InternalAxiosRequestConfig
} from 'axios';
// 响应数据接口
interface ApiResponse<T = any> {
code: number;
data: T;
message: string;
}
// 扩展配置
interface RequestConfig extends AxiosRequestConfig {
// 是否显示 loading
showLoading?: boolean;
// 是否显示错误提示
showError?: boolean;
// 重试次数
retryCount?: number;
// 是否需要 token
requireAuth?: boolean;
}
class HttpClient {
private instance: AxiosInstance;
private pendingRequests: Map<string, AbortController> = new Map();
constructor(config: AxiosRequestConfig) {
this.instance = axios.create({
timeout: 10000,
headers: {
'Content-Type': 'application/json'
},
...config
});
this.setupInterceptors();
}
// 生成请求唯一标识
private getRequestKey(config: AxiosRequestConfig): string {
const { method, url, params, data } = config;
return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&');
}
// 添加请求到 pending
private addPendingRequest(config: InternalAxiosRequestConfig): void {
const requestKey = this.getRequestKey(config);
// 取消之前相同的请求
if (this.pendingRequests.has(requestKey)) {
this.pendingRequests.get(requestKey)?.abort();
}
const controller = new AbortController();
config.signal = controller.signal;
this.pendingRequests.set(requestKey, controller);
}
// 移除 pending 请求
private removePendingRequest(config: AxiosRequestConfig): void {
const requestKey = this.getRequestKey(config);
this.pendingRequests.delete(requestKey);
}
// 设置拦截器
private setupInterceptors(): void {
// 请求拦截器
this.instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// 防重复请求
this.addPendingRequest(config);
// 添加 token
const token = localStorage.getItem('token');
if (token && (config as RequestConfig).requireAuth !== false) {
config.headers.Authorization = `Bearer ${token}`;
}
// 显示 loading
if ((config as RequestConfig).showLoading !== false) {
// showLoading();
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
this.instance.interceptors.response.use(
(response: AxiosResponse<ApiResponse>) => {
// 移除 pending
this.removePendingRequest(response.config);
// 隐藏 loading
// hideLoading();
const { code, data, message } = response.data;
// 业务成功
if (code === 0 || code === 200) {
return data;
}
// 业务错误
return Promise.reject(new Error(message || '请求失败'));
},
(error) => {
// 移除 pending
if (error.config) {
this.removePendingRequest(error.config);
}
// hideLoading();
// 处理错误
return this.handleError(error);
}
);
}
// 错误处理
private handleError(error: any): Promise<never> {
let message = '请求失败';
if (axios.isCancel(error)) {
message = '请求已取消';
} else if (error.code === 'ECONNABORTED') {
message = '请求超时';
} else if (!navigator.onLine) {
message = '网络连接失败';
} else if (error.response) {
const status = error.response.status;
const statusMessages: Record<number, string> = {
400: '请求参数错误',
401: '未授权,请重新登录',
403: '拒绝访问',
404: '请求资源不存在',
500: '服务器内部错误',
502: '网关错误',
503: '服务不可用',
504: '网关超时'
};
message = statusMessages[status] || `请求失败(${status})`;
// 401 跳转登录
if (status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
}
// 显示错误提示
// showToast(message);
return Promise.reject(new Error(message));
}
// 取消所有请求
public cancelAllRequests(): void {
this.pendingRequests.forEach((controller) => {
controller.abort();
});
this.pendingRequests.clear();
}
// 通用请求方法
public request<T = any>(config: RequestConfig): Promise<T> {
return this.instance.request(config);
}
public get<T = any>(url: string, params?: any, config?: RequestConfig): Promise<T> {
return this.instance.get(url, { params, ...config });
}
public post<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
return this.instance.post(url, data, config);
}
public put<T = any>(url: string, data?: any, config?: RequestConfig): Promise<T> {
return this.instance.put(url, data, config);
}
public delete<T = any>(url: string, config?: RequestConfig): Promise<T> {
return this.instance.delete(url, config);
}
// 文件上传
public upload<T = any>(url: string, file: File, onProgress?: (percent: number) => void): Promise<T> {
const formData = new FormData();
formData.append('file', file);
return this.instance.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
onUploadProgress: (event) => {
if (event.total && onProgress) {
const percent = Math.round((event.loaded * 100) / event.total);
onProgress(percent);
}
}
});
}
}
// 创建实例
const http = new HttpClient({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api'
});
export default http;带重试的封装
typescript
// 重试配置
interface RetryConfig {
retries: number; // 重试次数
retryDelay: number; // 重试延迟(ms)
retryCondition?: (error: any) => boolean; // 重试条件
}
// 添加重试拦截器
function setupRetryInterceptor(instance: AxiosInstance, config: RetryConfig) {
instance.interceptors.response.use(undefined, async (error) => {
const { config: requestConfig } = error;
// 初始化重试计数
requestConfig.__retryCount = requestConfig.__retryCount || 0;
// 检查是否应该重试
const shouldRetry = (
requestConfig.__retryCount < config.retries &&
(config.retryCondition ? config.retryCondition(error) : isRetryableError(error))
);
if (!shouldRetry) {
return Promise.reject(error);
}
requestConfig.__retryCount += 1;
// 延迟重试
await new Promise(resolve => setTimeout(resolve, config.retryDelay));
console.log(`重试第 ${requestConfig.__retryCount} 次: ${requestConfig.url}`);
return instance(requestConfig);
});
}
// 判断是否应该重试
function isRetryableError(error: any): boolean {
// 网络错误、超时、5xx 服务器错误可以重试
return (
!error.response ||
error.code === 'ECONNABORTED' ||
(error.response.status >= 500 && error.response.status < 600)
);
}Fetch 封装
typescript
// src/utils/fetch-client.ts
interface FetchConfig extends RequestInit {
baseURL?: string;
timeout?: number;
params?: Record<string, any>;
}
interface ApiResponse<T = any> {
code: number;
data: T;
message: string;
}
class FetchClient {
private baseURL: string;
private defaultConfig: RequestInit;
private abortControllers: Map<string, AbortController> = new Map();
constructor(config: FetchConfig = {}) {
this.baseURL = config.baseURL || '';
this.defaultConfig = {
headers: {
'Content-Type': 'application/json'
},
...config
};
}
// 构建完整 URL
private buildURL(url: string, params?: Record<string, any>): string {
const fullURL = url.startsWith('http') ? url : `${this.baseURL}${url}`;
if (!params) return fullURL;
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
return queryString ? `${fullURL}?${queryString}` : fullURL;
}
// 请求超时处理
private createTimeoutSignal(timeout: number): AbortSignal {
const controller = new AbortController();
setTimeout(() => controller.abort(), timeout);
return controller.signal;
}
// 合并 AbortSignal
private mergeSignals(...signals: (AbortSignal | undefined)[]): AbortSignal {
const controller = new AbortController();
signals.filter(Boolean).forEach(signal => {
signal!.addEventListener('abort', () => controller.abort());
});
return controller.signal;
}
// 核心请求方法
async request<T = any>(url: string, config: FetchConfig = {}): Promise<T> {
const {
baseURL,
timeout = 10000,
params,
...fetchConfig
} = config;
const fullURL = this.buildURL(url, params);
// 添加 token
const token = localStorage.getItem('token');
const headers = new Headers(this.defaultConfig.headers);
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
// 合并配置
const mergedConfig: RequestInit = {
...this.defaultConfig,
...fetchConfig,
headers,
signal: this.mergeSignals(
this.createTimeoutSignal(timeout),
config.signal
)
};
try {
const response = await fetch(fullURL, mergedConfig);
// HTTP 错误处理
if (!response.ok) {
throw await this.handleHttpError(response);
}
// 解析响应
const data: ApiResponse<T> = await response.json();
// 业务错误处理
if (data.code !== 0 && data.code !== 200) {
throw new Error(data.message || '请求失败');
}
return data.data;
} catch (error: any) {
// 超时处理
if (error.name === 'AbortError') {
throw new Error('请求超时');
}
throw error;
}
}
// HTTP 错误处理
private async handleHttpError(response: Response): Promise<Error> {
const status = response.status;
let message = `请求失败(${status})`;
try {
const data = await response.json();
message = data.message || message;
} catch {}
if (status === 401) {
localStorage.removeItem('token');
window.location.href = '/login';
}
return new Error(message);
}
// 快捷方法
get<T = any>(url: string, params?: Record<string, any>, config?: FetchConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'GET', params });
}
post<T = any>(url: string, data?: any, config?: FetchConfig): Promise<T> {
return this.request<T>(url, {
...config,
method: 'POST',
body: JSON.stringify(data)
});
}
put<T = any>(url: string, data?: any, config?: FetchConfig): Promise<T> {
return this.request<T>(url, {
...config,
method: 'PUT',
body: JSON.stringify(data)
});
}
delete<T = any>(url: string, config?: FetchConfig): Promise<T> {
return this.request<T>(url, { ...config, method: 'DELETE' });
}
}
export const fetchClient = new FetchClient({
baseURL: '/api'
});Vue 3 组合式封装
typescript
// src/composables/useRequest.ts
import { ref, Ref, UnwrapRef } from 'vue';
import http from '@/utils/request';
interface UseRequestOptions<T> {
// 是否立即执行
immediate?: boolean;
// 默认数据
defaultData?: T;
// 请求前回调
onBefore?: () => void;
// 请求成功回调
onSuccess?: (data: T) => void;
// 请求失败回调
onError?: (error: Error) => void;
// 请求结束回调
onFinally?: () => void;
}
interface UseRequestReturn<T, P extends any[]> {
data: Ref<UnwrapRef<T> | undefined>;
loading: Ref<boolean>;
error: Ref<Error | null>;
run: (...params: P) => Promise<T>;
refresh: () => Promise<T>;
cancel: () => void;
}
export function useRequest<T = any, P extends any[] = any[]>(
requestFn: (...args: P) => Promise<T>,
options: UseRequestOptions<T> = {}
): UseRequestReturn<T, P> {
const {
immediate = false,
defaultData,
onBefore,
onSuccess,
onError,
onFinally
} = options;
const data = ref<T | undefined>(defaultData);
const loading = ref(false);
const error = ref<Error | null>(null);
let lastParams: P;
let abortController: AbortController | null = null;
const run = async (...params: P): Promise<T> => {
lastParams = params;
// 取消之前的请求
cancel();
abortController = new AbortController();
loading.value = true;
error.value = null;
onBefore?.();
try {
const result = await requestFn(...params);
data.value = result as UnwrapRef<T>;
onSuccess?.(result);
return result;
} catch (e: any) {
error.value = e;
onError?.(e);
throw e;
} finally {
loading.value = false;
onFinally?.();
}
};
const refresh = () => {
return run(...lastParams);
};
const cancel = () => {
abortController?.abort();
abortController = null;
};
// 立即执行
if (immediate) {
run(...([] as unknown as P));
}
return {
data,
loading,
error,
run,
refresh,
cancel
};
}
// 分页请求 Hook
interface PaginationParams {
page: number;
pageSize: number;
}
interface PaginationResult<T> {
list: T[];
total: number;
}
export function usePagination<T = any>(
requestFn: (params: PaginationParams) => Promise<PaginationResult<T>>,
options: { defaultPageSize?: number } = {}
) {
const { defaultPageSize = 10 } = options;
const list = ref<T[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(defaultPageSize);
const loading = ref(false);
const fetchData = async () => {
loading.value = true;
try {
const result = await requestFn({
page: page.value,
pageSize: pageSize.value
});
list.value = result.list as UnwrapRef<T[]>;
total.value = result.total;
} finally {
loading.value = false;
}
};
const changePage = (newPage: number) => {
page.value = newPage;
fetchData();
};
const changePageSize = (newPageSize: number) => {
pageSize.value = newPageSize;
page.value = 1;
fetchData();
};
const refresh = () => {
page.value = 1;
fetchData();
};
return {
list,
total,
page,
pageSize,
loading,
fetchData,
changePage,
changePageSize,
refresh
};
}使用示例:
vue
<script setup lang="ts">
import { useRequest, usePagination } from '@/composables/useRequest';
import { getUserInfo, getUserList } from '@/api/user';
// 基础用法
const { data: userInfo, loading, error, run } = useRequest(getUserInfo, {
immediate: true,
onSuccess(data) {
console.log('获取成功', data);
}
});
// 手动触发
const handleSubmit = async () => {
await run(userId);
};
// 分页用法
const {
list,
total,
page,
pageSize,
loading: listLoading,
fetchData,
changePage
} = usePagination(getUserList);
// 初始加载
fetchData();
</script>React 封装
typescript
// src/hooks/useRequest.ts
import { useState, useCallback, useEffect, useRef } from 'react';
interface UseRequestOptions<T> {
manual?: boolean;
defaultData?: T;
onSuccess?: (data: T) => void;
onError?: (error: Error) => void;
debounceWait?: number;
throttleWait?: number;
}
interface UseRequestResult<T, P extends any[]> {
data: T | undefined;
loading: boolean;
error: Error | null;
run: (...params: P) => Promise<T>;
refresh: () => Promise<T>;
cancel: () => void;
mutate: (data: T | ((prevData: T | undefined) => T)) => void;
}
export function useRequest<T = any, P extends any[] = any[]>(
requestFn: (...args: P) => Promise<T>,
options: UseRequestOptions<T> = {}
): UseRequestResult<T, P> {
const {
manual = false,
defaultData,
onSuccess,
onError
} = options;
const [data, setData] = useState<T | undefined>(defaultData);
const [loading, setLoading] = useState(!manual);
const [error, setError] = useState<Error | null>(null);
const lastParamsRef = useRef<P>();
const mountedRef = useRef(true);
const run = useCallback(async (...params: P): Promise<T> => {
lastParamsRef.current = params;
setLoading(true);
setError(null);
try {
const result = await requestFn(...params);
if (mountedRef.current) {
setData(result);
onSuccess?.(result);
}
return result;
} catch (e: any) {
if (mountedRef.current) {
setError(e);
onError?.(e);
}
throw e;
} finally {
if (mountedRef.current) {
setLoading(false);
}
}
}, [requestFn, onSuccess, onError]);
const refresh = useCallback(() => {
if (lastParamsRef.current) {
return run(...lastParamsRef.current);
}
return run(...([] as unknown as P));
}, [run]);
const cancel = useCallback(() => {
mountedRef.current = false;
}, []);
const mutate = useCallback((
newData: T | ((prevData: T | undefined) => T)
) => {
if (typeof newData === 'function') {
setData(prevData => (newData as Function)(prevData));
} else {
setData(newData);
}
}, []);
// 自动请求
useEffect(() => {
if (!manual) {
run(...([] as unknown as P));
}
}, []);
// 清理
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
return {
data,
loading,
error,
run,
refresh,
cancel,
mutate
};
}
// 使用示例
function UserProfile({ userId }: { userId: string }) {
const { data: user, loading, error, refresh } = useRequest(
() => fetchUserById(userId),
{
onSuccess: (data) => {
console.log('User loaded:', data);
}
}
);
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<div>
<h1>{user?.name}</h1>
<button onClick={refresh}>刷新</button>
</div>
);
}接口并发优化
并行请求
typescript
// 多个独立接口并行请求
async function loadPageData() {
// ❌ 串行请求,耗时 = A + B + C
const userInfo = await getUserInfo();
const orderList = await getOrderList();
const statistics = await getStatistics();
// ✅ 并行请求,耗时 = max(A, B, C)
const [userInfo, orderList, statistics] = await Promise.all([
getUserInfo(),
getOrderList(),
getStatistics()
]);
}
// Promise.allSettled - 不因单个失败而中断
async function loadDashboard() {
const results = await Promise.allSettled([
getUserInfo(),
getOrderList(),
getStatistics()
]);
results.forEach((result, index) => {
if (result.status === 'fulfilled') {
console.log(`接口${index}成功:`, result.value);
} else {
console.log(`接口${index}失败:`, result.reason);
}
});
}请求合并
typescript
// 批量请求合并
class RequestBatcher<T> {
private queue: Array<{
id: string;
resolve: (value: T) => void;
reject: (error: any) => void;
}> = [];
private timer: NodeJS.Timeout | null = null;
private readonly delay: number = 50;
constructor(
private batchFn: (ids: string[]) => Promise<Map<string, T>>
) {}
async fetch(id: string): Promise<T> {
return new Promise((resolve, reject) => {
this.queue.push({ id, resolve, reject });
if (!this.timer) {
this.timer = setTimeout(() => this.flush(), this.delay);
}
});
}
private async flush() {
const batch = this.queue.splice(0);
this.timer = null;
if (batch.length === 0) return;
const ids = batch.map(item => item.id);
try {
const results = await this.batchFn(ids);
batch.forEach(({ id, resolve, reject }) => {
const result = results.get(id);
if (result !== undefined) {
resolve(result);
} else {
reject(new Error(`No result for ${id}`));
}
});
} catch (error) {
batch.forEach(({ reject }) => reject(error));
}
}
}
// 使用示例
const userBatcher = new RequestBatcher<User>(async (ids) => {
const users = await fetchUsersByIds(ids);
return new Map(users.map(u => [u.id, u]));
});
// 多次调用会被合并为一次请求
userBatcher.fetch('1');
userBatcher.fetch('2');
userBatcher.fetch('3');
// 50ms 后发送一次请求:fetchUsersByIds(['1', '2', '3'])常见面试题
1. HTTP 请求封装需要考虑哪些点?
答案要点:
- 基础配置:baseURL、timeout、headers
- 请求拦截:添加 token、loading、请求去重
- 响应拦截:数据转换、统一错误处理
- 错误处理:网络错误、业务错误、超时、401 跳转
- 取消请求:AbortController、组件卸载时取消
- 重试机制:网络不稳定时自动重试
- TypeScript:完善的类型定义
2. 如何实现请求取消?
typescript
// Axios - 使用 AbortController
const controller = new AbortController();
axios.get('/api/data', {
signal: controller.signal
});
// 取消请求
controller.abort();
// Fetch - 同样使用 AbortController
const controller = new AbortController();
fetch('/api/data', {
signal: controller.signal
});
controller.abort();3. 组件中调用多个无依赖接口如何优化?
typescript
// ❌ 串行,慢
useEffect(() => {
const fetchData = async () => {
setUserInfo(await getUserInfo());
setOrderList(await getOrderList());
setStats(await getStatistics());
};
fetchData();
}, []);
// ✅ 并行,快
useEffect(() => {
Promise.all([
getUserInfo(),
getOrderList(),
getStatistics()
]).then(([userInfo, orderList, stats]) => {
setUserInfo(userInfo);
setOrderList(orderList);
setStats(stats);
});
}, []);
// ✅ 或使用 Promise.allSettled 避免单个失败影响其他4. Axios 拦截器的执行顺序?
javascript
// 请求拦截器:后添加的先执行(栈结构)
axios.interceptors.request.use(config => {
console.log('请求拦截器 1');
return config;
});
axios.interceptors.request.use(config => {
console.log('请求拦截器 2'); // 先执行
return config;
});
// 响应拦截器:先添加的先执行(队列结构)
axios.interceptors.response.use(response => {
console.log('响应拦截器 1'); // 先执行
return response;
});
axios.interceptors.response.use(response => {
console.log('响应拦截器 2');
return response;
});
// 执行顺序:
// 请求拦截器 2 → 请求拦截器 1 → 发送请求 → 响应拦截器 1 → 响应拦截器 2