Fetch API 完全指南:现代 HTTP 请求详解

掌握 Fetch 请求配置、响应处理、拦截器封装和错误处理

Fetch API 完全指南:现代 HTTP 请求详解

Fetch API 是现代浏览器的标准 HTTP 请求接口。本文探讨 Fetch 的各种用法和最佳实践。

基础用法

简单请求

// GET 请求
const response = await fetch('https://api.example.com/users');
const data = await response.json();

// 检查响应状态
if (!response.ok) {
  throw new Error(`HTTP error! status: ${response.status}`);
}

// POST 请求
const createUser = await fetch('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    name: 'John',
    email: 'john@example.com'
  })
});

请求配置

const response = await fetch(url, {
  // HTTP 方法
  method: 'POST', // GET, POST, PUT, DELETE, PATCH

  // 请求头
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer token123',
    'X-Custom-Header': 'value'
  },

  // 请求体
  body: JSON.stringify(data),

  // 凭证模式
  credentials: 'include', // include, same-origin, omit

  // 缓存模式
  cache: 'no-cache', // default, no-store, reload, no-cache, force-cache

  // 重定向模式
  redirect: 'follow', // follow, error, manual

  // 引用策略
  referrerPolicy: 'no-referrer', // no-referrer, origin, same-origin, strict-origin

  // 请求模式
  mode: 'cors', // cors, no-cors, same-origin

  // 信号(用于取消请求)
  signal: abortController.signal
});

响应处理

响应对象

const response = await fetch(url);

// 响应属性
console.log(response.ok);         // 200-299 为 true
console.log(response.status);     // HTTP 状态码
console.log(response.statusText); // 状态文本
console.log(response.url);        // 最终 URL
console.log(response.headers);    // Headers 对象
console.log(response.redirected); // 是否重定向

// 读取响应头
const contentType = response.headers.get('Content-Type');
const allHeaders = [...response.headers.entries()];

响应体解析

// JSON
const jsonData = await response.json();

// 文本
const textData = await response.text();

// Blob(二进制)
const blobData = await response.blob();
const imageUrl = URL.createObjectURL(blobData);

// ArrayBuffer
const bufferData = await response.arrayBuffer();

// FormData
const formData = await response.formData();

// 注意:响应体只能读取一次
// 如需多次读取,先克隆
const clone = response.clone();
const data1 = await response.json();
const data2 = await clone.json();

流式读取

const response = await fetch(url);
const reader = response.body?.getReader();

if (reader) {
  const decoder = new TextDecoder();
  let result = '';

  while (true) {
    const { done, value } = await reader.read();

    if (done) break;

    result += decoder.decode(value, { stream: true });
    console.log('Received chunk:', result.length);
  }

  console.log('Complete:', result);
}

请求取消

AbortController

const controller = new AbortController();

// 发起请求
const fetchPromise = fetch(url, {
  signal: controller.signal
});

// 超时取消
const timeoutId = setTimeout(() => {
  controller.abort();
}, 5000);

try {
  const response = await fetchPromise;
  clearTimeout(timeoutId);
  return await response.json();
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    console.log('Request was cancelled');
  } else {
    throw error;
  }
}

超时封装

async function fetchWithTimeout(
  url: string,
  options: RequestInit = {},
  timeout = 5000
): Promise<Response> {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    });
    return response;
  } finally {
    clearTimeout(timeoutId);
  }
}

// 使用
try {
  const response = await fetchWithTimeout('/api/data', {}, 3000);
  const data = await response.json();
} catch (error) {
  if (error instanceof Error && error.name === 'AbortError') {
    console.log('Request timed out');
  }
}

封装 HTTP 客户端

基础封装

interface RequestOptions extends Omit<RequestInit, 'body'> {
  params?: Record<string, string>;
  data?: unknown;
  timeout?: number;
}

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

class HttpClient {
  private baseURL: string;
  private defaultHeaders: HeadersInit;

  constructor(baseURL: string, defaultHeaders: HeadersInit = {}) {
    this.baseURL = baseURL;
    this.defaultHeaders = defaultHeaders;
  }

  private buildURL(endpoint: string, params?: Record<string, string>): string {
    const url = new URL(endpoint, this.baseURL);
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        url.searchParams.append(key, value);
      });
    }
    return url.toString();
  }

  async request<T>(
    endpoint: string,
    options: RequestOptions = {}
  ): Promise<ApiResponse<T>> {
    const { params, data, timeout = 10000, ...init } = options;

    const url = this.buildURL(endpoint, params);
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), timeout);

    try {
      const response = await fetch(url, {
        ...init,
        headers: {
          'Content-Type': 'application/json',
          ...this.defaultHeaders,
          ...init.headers
        },
        body: data ? JSON.stringify(data) : undefined,
        signal: controller.signal
      });

      if (!response.ok) {
        throw new HttpError(response.status, response.statusText);
      }

      const responseData = await response.json();

      return {
        data: responseData,
        status: response.status,
        headers: response.headers
      };
    } finally {
      clearTimeout(timeoutId);
    }
  }

  get<T>(endpoint: string, options?: RequestOptions) {
    return this.request<T>(endpoint, { ...options, method: 'GET' });
  }

  post<T>(endpoint: string, data: unknown, options?: RequestOptions) {
    return this.request<T>(endpoint, { ...options, method: 'POST', data });
  }

  put<T>(endpoint: string, data: unknown, options?: RequestOptions) {
    return this.request<T>(endpoint, { ...options, method: 'PUT', data });
  }

  delete<T>(endpoint: string, options?: RequestOptions) {
    return this.request<T>(endpoint, { ...options, method: 'DELETE' });
  }
}

class HttpError extends Error {
  constructor(
    public status: number,
    public statusText: string
  ) {
    super(`HTTP ${status}: ${statusText}`);
    this.name = 'HttpError';
  }
}

拦截器模式

type Interceptor<T> = (value: T) => T | Promise<T>;

interface Interceptors {
  request: Interceptor<RequestInit>[];
  response: Interceptor<Response>[];
}

class HttpClientWithInterceptors extends HttpClient {
  private interceptors: Interceptors = {
    request: [],
    response: []
  };

  addRequestInterceptor(interceptor: Interceptor<RequestInit>) {
    this.interceptors.request.push(interceptor);
  }

  addResponseInterceptor(interceptor: Interceptor<Response>) {
    this.interceptors.response.push(interceptor);
  }

  async request<T>(
    endpoint: string,
    options: RequestOptions = {}
  ): Promise<ApiResponse<T>> {
    // 应用请求拦截器
    let config: RequestInit = { ...options };
    for (const interceptor of this.interceptors.request) {
      config = await interceptor(config);
    }

    // 发起请求
    let response = await fetch(endpoint, config);

    // 应用响应拦截器
    for (const interceptor of this.interceptors.response) {
      response = await interceptor(response);
    }

    const data = await response.json();
    return { data, status: response.status, headers: response.headers };
  }
}

// 使用拦截器
const client = new HttpClientWithInterceptors('https://api.example.com');

// 添加认证头
client.addRequestInterceptor(async (config) => {
  const token = localStorage.getItem('token');
  if (token) {
    config.headers = {
      ...config.headers,
      Authorization: `Bearer ${token}`
    };
  }
  return config;
});

// 处理 401 错误
client.addResponseInterceptor(async (response) => {
  if (response.status === 401) {
    // 刷新 token 或跳转登录
    localStorage.removeItem('token');
    window.location.href = '/login';
  }
  return response;
});

常见场景

文件上传

async function uploadFile(file: File, onProgress?: (percent: number) => void) {
  const formData = new FormData();
  formData.append('file', file);

  // 基础上传
  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
    // 不要设置 Content-Type,浏览器会自动处理
  });

  return response.json();
}

// 多文件上传
async function uploadMultiple(files: FileList) {
  const formData = new FormData();
  Array.from(files).forEach((file, index) => {
    formData.append(`file${index}`, file);
  });

  return fetch('/api/upload-multiple', {
    method: 'POST',
    body: formData
  });
}

下载文件

async function downloadFile(url: string, filename: string) {
  const response = await fetch(url);
  const blob = await response.blob();

  // 创建下载链接
  const downloadUrl = URL.createObjectURL(blob);
  const link = document.createElement('a');
  link.href = downloadUrl;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);

  // 清理 URL
  URL.revokeObjectURL(downloadUrl);
}

// 带进度的下载
async function downloadWithProgress(
  url: string,
  onProgress: (percent: number) => void
) {
  const response = await fetch(url);
  const contentLength = response.headers.get('Content-Length');
  const total = contentLength ? parseInt(contentLength, 10) : 0;

  const reader = response.body?.getReader();
  if (!reader) throw new Error('No reader available');

  const chunks: Uint8Array[] = [];
  let received = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    chunks.push(value);
    received += value.length;

    if (total > 0) {
      onProgress((received / total) * 100);
    }
  }

  return new Blob(chunks);
}

并发请求

// Promise.all - 全部成功或任一失败
async function fetchAll<T>(urls: string[]): Promise<T[]> {
  const responses = await Promise.all(
    urls.map(url => fetch(url))
  );

  return Promise.all(
    responses.map(res => res.json())
  );
}

// Promise.allSettled - 获取所有结果
async function fetchAllSettled<T>(urls: string[]) {
  const results = await Promise.allSettled(
    urls.map(async url => {
      const res = await fetch(url);
      return res.json();
    })
  );

  return results.map(result => {
    if (result.status === 'fulfilled') {
      return { success: true, data: result.value };
    }
    return { success: false, error: result.reason };
  });
}

// 限制并发数
async function fetchWithConcurrency<T>(
  urls: string[],
  concurrency: number
): Promise<T[]> {
  const results: T[] = [];
  const executing: Promise<void>[] = [];

  for (const url of urls) {
    const promise = fetch(url)
      .then(res => res.json())
      .then(data => { results.push(data); });

    executing.push(promise);

    if (executing.length >= concurrency) {
      await Promise.race(executing);
      executing.splice(
        executing.findIndex(p => p === promise),
        1
      );
    }
  }

  await Promise.all(executing);
  return results;
}

最佳实践总结

Fetch API 最佳实践:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   错误处理                                          │
│   ├── 检查 response.ok                             │
│   ├── 处理网络错误                                 │
│   ├── 处理超时                                     │
│   └── 提供有意义的错误信息                         │
│                                                     │
│   性能优化                                          │
│   ├── 使用请求取消                                 │
│   ├── 实现请求缓存                                 │
│   ├── 控制并发数量                                 │
│   └── 使用 keep-alive                              │
│                                                     │
│   代码组织                                          │
│   ├── 封装 HTTP 客户端                             │
│   ├── 使用拦截器                                   │
│   ├── 类型安全                                     │
│   └── 统一错误处理                                 │
│                                                     │
└─────────────────────────────────────────────────────┘
方法用途
response.json()解析 JSON
response.text()获取文本
response.blob()获取二进制
response.clone()克隆响应

掌握 Fetch API,让 HTTP 请求更优雅。