實戰專案: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 | 類型安全 |
> **設計原則**:
- 單一職責
- 可擴展性
- 類型安全
- 錯誤處理
進階挑戰
- 添加請求去重(相同請求只發一次)。
- 實作請求取消功能。
- 添加離線支援(請求佇列)。