跳至主要內容
Skip to content

實戰專案:API Client 封裝

一個好的 API Client 能大幅提升開發效率。本篇將從零打造一個功能完整的 HTTP 客戶端。


一、 設計目標

1.1 功能需求

  • ✅ 基礎 CRUD 操作
  • ✅ 請求/回應攔截器
  • ✅ 錯誤統一處理
  • ✅ 自動重試
  • ✅ 請求超時
  • ✅ Token 自動刷新
  • ✅ TypeScript 類型安全

1.2 架構設計


二、 基礎實作

2.1 核心類別

typescript
// api-client.ts
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

interface RequestConfig {
  method?: HttpMethod;
  headers?: Record<string, string>;
  body?: any;
  timeout?: number;
  retry?: number;
}

interface ApiResponse<T> {
  data: T;
  status: number;
  headers: Headers;
}

class ApiClient {
  private baseUrl: string;
  private defaultHeaders: Record<string, string>;
  private timeout: number;

  constructor(config: {
    baseUrl: string;
    defaultHeaders?: Record<string, string>;
    timeout?: number;
  }) {
    this.baseUrl = config.baseUrl;
    this.defaultHeaders = {
      "Content-Type": "application/json",
      ...config.defaultHeaders,
    };
    this.timeout = config.timeout || 30000;
  }

  private async request<T>(
    path: string,
    config: RequestConfig = {}
  ): Promise<ApiResponse<T>> {
    const { method = "GET", headers = {}, body, timeout } = config;

    const url = `${this.baseUrl}${path}`;
    const controller = new AbortController();
    const timeoutId = setTimeout(
      () => controller.abort(),
      timeout || this.timeout
    );

    try {
      const response = await fetch(url, {
        method,
        headers: { ...this.defaultHeaders, ...headers },
        body: body ? JSON.stringify(body) : undefined,
        signal: controller.signal,
      });

      clearTimeout(timeoutId);

      if (!response.ok) {
        throw new ApiError(response.status, await response.text());
      }

      const data = await response.json();

      return {
        data,
        status: response.status,
        headers: response.headers,
      };
    } catch (error) {
      clearTimeout(timeoutId);

      if (error instanceof DOMException && error.name === "AbortError") {
        throw new ApiError(408, "Request timeout");
      }

      throw error;
    }
  }

  async get<T>(path: string, config?: RequestConfig): Promise<T> {
    const response = await this.request<T>(path, { ...config, method: "GET" });
    return response.data;
  }

  async post<T>(path: string, body: any, config?: RequestConfig): Promise<T> {
    const response = await this.request<T>(path, {
      ...config,
      method: "POST",
      body,
    });
    return response.data;
  }

  async put<T>(path: string, body: any, config?: RequestConfig): Promise<T> {
    const response = await this.request<T>(path, {
      ...config,
      method: "PUT",
      body,
    });
    return response.data;
  }

  async patch<T>(path: string, body: any, config?: RequestConfig): Promise<T> {
    const response = await this.request<T>(path, {
      ...config,
      method: "PATCH",
      body,
    });
    return response.data;
  }

  async delete<T>(path: string, config?: RequestConfig): Promise<T> {
    const response = await this.request<T>(path, {
      ...config,
      method: "DELETE",
    });
    return response.data;
  }
}

class ApiError extends Error {
  constructor(public status: number, public message: string) {
    super(message);
    this.name = "ApiError";
  }
}

三、 攔截器

3.1 攔截器介面

typescript
type RequestInterceptor = (
  config: RequestConfig
) => RequestConfig | Promise<RequestConfig>;
type ResponseInterceptor = <T>(
  response: ApiResponse<T>
) => ApiResponse<T> | Promise<ApiResponse<T>>;
type ErrorInterceptor = (error: ApiError) => ApiError | Promise<ApiError>;

class ApiClient {
  private requestInterceptors: RequestInterceptor[] = [];
  private responseInterceptors: ResponseInterceptor[] = [];
  private errorInterceptors: ErrorInterceptor[] = [];

  onRequest(interceptor: RequestInterceptor) {
    this.requestInterceptors.push(interceptor);
    return this;
  }

  onResponse(interceptor: ResponseInterceptor) {
    this.responseInterceptors.push(interceptor);
    return this;
  }

  onError(interceptor: ErrorInterceptor) {
    this.errorInterceptors.push(interceptor);
    return this;
  }

  private async applyRequestInterceptors(config: RequestConfig) {
    let result = config;
    for (const interceptor of this.requestInterceptors) {
      result = await interceptor(result);
    }
    return result;
  }

  private async applyResponseInterceptors<T>(response: ApiResponse<T>) {
    let result = response;
    for (const interceptor of this.responseInterceptors) {
      result = (await interceptor(result)) as ApiResponse<T>;
    }
    return result;
  }

  private async applyErrorInterceptors(error: ApiError) {
    let result = error;
    for (const interceptor of this.errorInterceptors) {
      result = await interceptor(result);
    }
    return result;
  }
}

3.2 使用攔截器

typescript
const api = new ApiClient({ baseUrl: "https://api.example.com" });

// 添加 Token
api.onRequest((config) => {
  const token = localStorage.getItem("token");
  if (token) {
    config.headers = {
      ...config.headers,
      Authorization: `Bearer ${token}`,
    };
  }
  return config;
});

// 記錄請求
api.onRequest((config) => {
  console.log(`[API] ${config.method} ${config.url}`);
  return config;
});

// 處理回應
api.onResponse((response) => {
  console.log(`[API] Response: ${response.status}`);
  return response;
});

// 錯誤處理
api.onError((error) => {
  if (error.status === 401) {
    window.location.href = "/login";
  }
  return error;
});

四、 重試機制

4.1 指數退避重試

typescript
interface RetryConfig {
  maxRetries: number;
  baseDelay: number;
  maxDelay: number;
  retryOn: number[];
}

class ApiClient {
  private retryConfig: RetryConfig = {
    maxRetries: 3,
    baseDelay: 1000,
    maxDelay: 10000,
    retryOn: [500, 502, 503, 504],
  };

  private async requestWithRetry<T>(
    path: string,
    config: RequestConfig
  ): Promise<ApiResponse<T>> {
    const maxRetries = config.retry ?? this.retryConfig.maxRetries;
    let lastError: ApiError | null = null;

    for (let attempt = 0; attempt <= maxRetries; attempt++) {
      try {
        return await this.request<T>(path, config);
      } catch (error) {
        lastError = error as ApiError;

        // 檢查是否應該重試
        if (
          attempt < maxRetries &&
          this.retryConfig.retryOn.includes(lastError.status)
        ) {
          const delay = this.calculateDelay(attempt);
          console.log(`[API] Retry attempt ${attempt + 1} in ${delay}ms`);
          await this.sleep(delay);
          continue;
        }

        throw error;
      }
    }

    throw lastError;
  }

  private calculateDelay(attempt: number): number {
    const delay = this.retryConfig.baseDelay * Math.pow(2, attempt);
    const jitter = delay * 0.1 * Math.random();
    return Math.min(delay + jitter, this.retryConfig.maxDelay);
  }

  private sleep(ms: number): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, ms));
  }
}

五、 Token 刷新

5.1 自動刷新

typescript
class ApiClient {
  private refreshPromise: Promise<string> | null = null;
  private refreshToken: string | null = null;

  async refreshAccessToken(): Promise<string> {
    // 避免並發刷新
    if (this.refreshPromise) {
      return this.refreshPromise;
    }

    this.refreshPromise = this.doRefresh();

    try {
      return await this.refreshPromise;
    } finally {
      this.refreshPromise = null;
    }
  }

  private async doRefresh(): Promise<string> {
    const response = await fetch(`${this.baseUrl}/auth/refresh`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ refreshToken: this.refreshToken }),
    });

    if (!response.ok) {
      throw new ApiError(401, "Refresh failed");
    }

    const data = await response.json();
    localStorage.setItem("token", data.accessToken);
    this.refreshToken = data.refreshToken;

    return data.accessToken;
  }
}

// 401 時自動刷新
api.onError(async (error) => {
  if (error.status === 401) {
    try {
      await api.refreshAccessToken();
      // 重試原請求
      return error;
    } catch {
      window.location.href = "/login";
    }
  }
  return error;
});

六、 TypeScript 類型

6.1 類型定義

typescript
// types.ts
interface User {
  id: string;
  name: string;
  email: string;
  createdAt: string;
}

interface CreateUserInput {
  name: string;
  email: string;
  password: string;
}

interface PaginatedResponse<T> {
  data: T[];
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

6.2 類型安全的 API

typescript
// services/user.service.ts
class UserService {
  constructor(private api: ApiClient) {}

  async getUsers(params?: {
    page?: number;
    limit?: number;
  }): Promise<PaginatedResponse<User>> {
    const query = new URLSearchParams(params as any).toString();
    return this.api.get(`/users?${query}`);
  }

  async getUser(id: string): Promise<User> {
    return this.api.get(`/users/${id}`);
  }

  async createUser(input: CreateUserInput): Promise<User> {
    return this.api.post("/users", input);
  }

  async updateUser(id: string, input: Partial<CreateUserInput>): Promise<User> {
    return this.api.patch(`/users/${id}`, input);
  }

  async deleteUser(id: string): Promise<void> {
    return this.api.delete(`/users/${id}`);
  }
}

// 使用
const userService = new UserService(api);

const users = await userService.getUsers({ page: 1, limit: 10 });
// users 自動推斷為 PaginatedResponse<User>

const user = await userService.getUser("123");
// user 自動推斷為 User

七、 完整範例

7.1 初始化

typescript
// api/index.ts
import { ApiClient } from "./api-client";
import { UserService } from "./services/user.service";
import { AuthService } from "./services/auth.service";

const api = new ApiClient({
  baseUrl: import.meta.env.VITE_API_URL,
  timeout: 30000,
});

// 請求攔截器
api.onRequest((config) => {
  const token = localStorage.getItem("token");
  if (token) {
    config.headers = {
      ...config.headers,
      Authorization: `Bearer ${token}`,
    };
  }
  return config;
});

// 錯誤攔截器
api.onError((error) => {
  console.error("[API Error]", error.status, error.message);

  if (error.status === 401) {
    localStorage.removeItem("token");
    window.location.href = "/login";
  }

  return error;
});

// 匯出服務
export const userService = new UserService(api);
export const authService = new AuthService(api);
export { api };

7.2 使用

typescript
// components/UserList.vue
import { userService } from "@/api";

const users = ref<User[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);

async function loadUsers() {
  loading.value = true;
  error.value = null;

  try {
    const response = await userService.getUsers({ page: 1 });
    users.value = response.data;
  } catch (e) {
    error.value = (e as Error).message;
  } finally {
    loading.value = false;
  }
}

onMounted(loadUsers);

總結

功能用途
攔截器Token、日誌、錯誤處理
重試網路不穩定
超時避免無限等待
Token 刷新無縫認證
TypeScript類型安全

> **設計原則**:

  • 單一職責
  • 可擴展性
  • 類型安全
  • 錯誤處理

進階挑戰

  1. 添加請求去重(相同請求只發一次)。
  2. 實作請求取消功能。
  3. 添加離線支援(請求佇列)。

延伸閱讀與資源