前端 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),
};

API 架构

错误处理

统一错误类

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 封装应该:

  1. 统一入口:所有请求走同一通道
  2. 类型安全:TypeScript 类型完整
  3. 错误处理:统一捕获、统一展示
  4. 可配置:拦截器、超时、重试
  5. 易测试:接口定义独立

不要过度封装,够用就好。