Fetch API 与请求模式

What — 什么是 Fetch API

Fetch API 是浏览器提供的现代 HTTP 客户端接口,用于发起网络请求并处理响应。它是 XMLHttpRequest(XHR)的替代方案,基于 Promise 设计,提供了更简洁、更强大的请求处理能力。

核心对象

对象说明
fetch()发起请求的全局函数,返回 Promise
Request请求对象,封装 URL、方法、头、体等信息
Response响应对象,封装状态码、头、体等信息
Headers请求/响应头对象,支持增删改查
AbortController请求取消控制器
AbortSignal取消信号,传递给 Request

最简示例

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

与 XMLHttpRequest 对比

// XMLHttpRequest —— 回调嵌套,代码冗长
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/users');
xhr.onload = function () {
  if (xhr.status >= 200 && xhr.status < 300) {
    const data = JSON.parse(xhr.responseText);
    console.log(data);
  }
};
xhr.onerror = function () {
  console.error('请求失败');
};
xhr.send();

// Fetch —— 简洁直观,基于 Promise
const response = await fetch('https://api.example.com/users');
if (response.ok) {
  const data = await response.json();
  console.log(data);
}

Why — 为什么需要 Fetch API

1. XHR 的回调地狱

XHR 基于事件回调,多个请求串联时产生深层嵌套:

// XHR 回调地狱
xhr1.onload = function () {
  xhr2.onload = function () {
    xhr3.onload = function () {
      // 三层嵌套,可读性极差
    };
    xhr3.send();
  };
  xhr2.send();
};
xhr1.send();

Fetch 基于 Promise,天然支持链式调用和 async/await

// Fetch —— 扁平化、可读性强
const res1 = await fetch('/api/step1');
const data1 = await res1.json();
const res2 = await fetch('/api/step2', {
  method: 'POST',
  body: JSON.stringify(data1)
});
const data2 = await res2.json();

2. XHR API 设计过时

XHR 在 2000 年代初设计,API 存在诸多缺陷:

问题XHRFetch
接口风格事件回调Promise + async/await
状态管理readyState 四个状态Promise resolve/reject
请求构造open() + setRequestHeader() + send() 分步一个 fetch() 调用
响应解析手动 JSON.parse(xhr.responseText)response.json() 等方法
不可变请求无法冻结请求配置Request 对象可复用、可克隆

3. 流式读取支持

Fetch 原生支持 ReadableStream,可以分块读取响应体,这是 XHR 无法做到的:

// 流式读取大文件,不阻塞内存
const response = await fetch('/api/large-file');
const reader = response.body.getReader();
let done = false;
while (!done) {
  const { value, done: readerDone } = await reader.read();
  done = readerDone;
  if (value) {
    // 逐块处理,无需等待全部下载
    processChunk(value);
  }
}

4. Service Worker 必需

Service Worker 拦截请求使用的是 Fetch 事件模型,请求和响应都是 Request/Response 对象:

// Service Worker 拦截请求
self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request).then((cachedResponse) => {
      return cachedResponse || fetch(event.request);
    })
  );
});

Fetch vs XHR vs Axios 对比

维度FetchXHRAxios
语法Promise/async-await事件回调Promise/async-await
HTTP 错误处理不 reject(需手动判断 okonerror 仅网络错误4xx/5xx 自动 reject
请求超时AbortController + setTimeoutxhr.timeout 原生支持timeout 配置项
进度监控无原生支持(流式可模拟)onprogress 原生支持无(浏览器端)
拦截器需手动封装需手动封装请求/响应拦截器内置
请求取消AbortControllerxhr.abort()CancelToken / AbortController
流式读取ReadableStream 原生支持不支持不支持
Cookie 发送默认不发送跨域 Cookie默认发送默认发送同域
响应数据转换手动调用 .json()手动解析自动转换
兼容性IE 不支持,其余全支持全浏览器全浏览器
体积浏览器原生,0KB浏览器原生,0KB~13KB(gzip)
Node.jsNode 18+ 内置不支持支持

何时选什么

场景推荐理由
现代浏览器项目Fetch原生、零依赖、流式支持
需要上传进度XHR原生 onprogress 支持
需要拦截器 + 自动错误处理Axios开箱即用的完整功能
Service WorkerFetch唯一选择
流式响应/SSEFetch唯一原生支持流式读取
Node.js + 浏览器同构Axios统一 API

How — 怎么用

1. fetch 基础

GET 请求

// 最简 GET
const response = await fetch('/api/users');
const users = await response.json();

// 带查询参数
const params = new URLSearchParams({
  page: 1,
  size: 10,
  keyword: 'fetch'
});
const response = await fetch(`/api/users?${params}`);
const data = await response.json();

// 带请求头
const response = await fetch('/api/users', {
  headers: {
    'Accept': 'application/json',
    'Authorization': 'Bearer token123'
  }
});

POST 请求

// JSON 数据
const response = await fetch('/api/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    name: '张三',
    age: 25
  })
});

// 表单数据
const formData = new FormData();
formData.append('name', '张三');
formData.append('age', '25');
const response = await fetch('/api/users', {
  method: 'POST',
  body: formData  // FormData 不需要设置 Content-Type,浏览器自动设置
});

// URL 编码数据
const params = new URLSearchParams();
params.append('name', '张三');
params.append('age', '25');
const response = await fetch('/api/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded'
  },
  body: params.toString()
});

PUT / PATCH / DELETE

// PUT —— 全量更新
const response = await fetch('/api/users/1', {
  method: 'PUT',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: '李四', age: 30, email: 'lisi@example.com' })
});

// PATCH —— 部分更新
const response = await fetch('/api/users/1', {
  method: 'PATCH',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ age: 31 }) // 只更新 age
});

// DELETE
const response = await fetch('/api/users/1', {
  method: 'DELETE'
});

Headers 对象

// 创建 Headers
const headers = new Headers({
  'Content-Type': 'application/json',
  'Authorization': 'Bearer token123'
});

// 增删改查
headers.append('X-Custom', 'value');   // 追加
headers.set('Content-Type', 'text/plain'); // 覆盖
headers.get('Content-Type');            // 'text/plain'
headers.has('Authorization');           // true
headers.delete('X-Custom');             // 删除

// 遍历
for (const [key, value] of headers) {
  console.log(`${key}: ${value}`);
}

// Headers 合并
const h1 = new Headers({ 'A': '1' });
const h2 = new Headers({ 'B': '2' });
const merged = new Headers([...h1, ...h2]);

body 支持的类型

类型Content-Type说明
string手动指定纯文本
JSON.stringify(data)application/jsonJSON 数据
FormDatamultipart/form-data(自动)文件上传、表单
URLSearchParamsapplication/x-www-form-urlencodedURL 编码表单
Blob手动指定二进制大对象
ArrayBuffer手动指定原始二进制
ReadableStream手动指定流式上传
// Blob 上传
const blob = new Blob(['Hello, World!'], { type: 'text/plain' });
const response = await fetch('/api/upload', {
  method: 'POST',
  body: blob
});

// ArrayBuffer 上传
const buffer = new TextEncoder().encode('Hello');
const response = await fetch('/api/upload', {
  method: 'POST',
  headers: { 'Content-Type': 'application/octet-stream' },
  body: buffer
});

2. Request 对象

Request 对象封装了请求的全部信息,可复用、可克隆。

// 创建 Request
const request = new Request('https://api.example.com/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ name: '张三' })
});

// 使用 Request 发起请求
const response = await fetch(request);

// Request 复用 —— 基于旧 Request 创建新请求
const newRequest = new Request(request, {
  headers: {
    'Content-Type': 'text/plain' // 覆盖 headers
  }
  // 其他属性继承自 request
});

Request 完整配置项

const request = new Request(url, {
  method: 'POST',            // 请求方法:GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS
  headers: {                 // 请求头,Headers 对象或普通对象
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({}),  // 请求体,GET/HEAD 不能有 body
  mode: 'cors',              // 请求模式:cors / no-cors / same-origin / navigate
  credentials: 'same-origin',// Cookie 策略:omit / same-origin / include
  cache: 'default',          // 缓存模式:default / no-store / reload / no-cache / force-cache / only-if-cached
  redirect: 'follow',        // 重定向策略:follow / error / manual
  referrer: 'about:client',  // 来源:URL / '' / 'about:client' / 'no-referrer'
  referrerPolicy: 'no-referrer-when-downgrade', // 来源策略
  integrity: '',             // 子资源完整性校验(SRI)
  keepalive: false,          // 长连接(页面卸载后仍可发送)
  signal: abortController.signal, // AbortSignal,用于取消请求
  priority: 'auto'           // 优先级:high / low / auto
});

mode 详解

mode说明
cors跨域请求(默认),需要服务器返回 CORS 头
no-cors不透明请求,只能发送简单请求,响应 type 为 opaque,无法读取内容
same-origin只允许同源请求,跨域直接报错
navigate导航请求,浏览器自动使用

cache 详解

cache 值行为
default有缓存且未过期则用缓存,过期则条件请求
no-store完全不使用缓存,也不缓存响应
reload不使用缓存,但响应会被缓存
no-cache有缓存也发条件请求验证
force-cache有缓存就用,即使过期
only-if-cached只用缓存,无缓存则 504(仅 mode 为 same-origin

3. Response 对象

const response = await fetch('/api/users');

// 状态信息
response.status;       // 200(HTTP 状态码,数字)
response.ok;           // true(status 在 200-299 之间)
response.statusText;   // 'OK'(状态描述)
response.type;         // 'basic' / 'cors' / 'opaque' / 'opaqueredirect'
response.url;          // 最终 URL(经过重定向后)
response.redirected;   // 是否经过了重定向

// 响应头
response.headers.get('Content-Type');       // 'application/json'
response.headers.get('X-Request-Id');       // 'abc123'
response.headers.has('Set-Cookie');         // true
for (const [key, value] of response.headers) {
  console.log(`${key}: ${value}`);
}

// 响应体 —— 只能读取一次
const json = await response.json();         // 解析为 JSON 对象
const text = await response.text();         // 解析为字符串
const blob = await response.blob();         // 解析为 Blob
const arrayBuffer = await response.arrayBuffer(); // 解析为 ArrayBuffer
const formData = await response.formData(); // 解析为 FormData

Response type

type说明
basic同源请求的响应,可访问全部头
cors跨域请求的响应,只能访问 CORS 暴露的头
opaqueno-cors 跨域请求的响应,无法读取任何内容
opaqueredirectredirect: 'manual' 时的重定向响应

clone() —— 复制 Response

Response 的 body 只能读取一次,读取后再读会报错。需要多次使用时必须 clone()

const response = await fetch('/api/users');

// 错误:body 已被消费
const data1 = await response.json();
const data2 = await response.json(); // TypeError: Already read

// 正确:先克隆再读取
const response = await fetch('/api/users');
const cloned = response.clone();
const data1 = await response.json();
const data2 = await cloned.json(); // OK

手动构造 Response

// 构造 Response 对象(Service Worker 中常用)
const response = new Response(JSON.stringify({ message: 'Hello' }), {
  status: 200,
  statusText: 'OK',
  headers: {
    'Content-Type': 'application/json'
  }
});

// Response.error() —— 网络错误的 Response
const errorResponse = Response.error();

// Response.redirect() —— 重定向的 Response
const redirectResponse = Response.redirect('/login', 302);

credentials 三种模式

credentials 值同源 Cookie跨域 Cookie说明
omit不发送不发送从不发送 Cookie
same-origin(默认)发送不发送仅同源发送 Cookie
include发送发送始终发送 Cookie
// 同源请求 —— 默认发送 Cookie
const res1 = await fetch('/api/me'); // credentials: 'same-origin'

// 跨域请求 —— 默认不发送 Cookie!
// 服务器必须设置:Access-Control-Allow-Credentials: true
// 且 Access-Control-Allow-Origin 不能为 *
const res2 = await fetch('https://other-api.com/me', {
  credentials: 'include' // 必须显式指定
});

Authorization 头与 Bearer Token

// Bearer Token 认证
const token = localStorage.getItem('access_token');
const response = await fetch('/api/protected', {
  headers: {
    'Authorization': `Bearer ${token}`
  }
});

// Token 刷新模式
async function fetchWithAuth(url, options = {}) {
  let token = localStorage.getItem('access_token');

  const response = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${token}`
    }
  });

  // Token 过期,尝试刷新
  if (response.status === 401) {
    const refreshToken = localStorage.getItem('refresh_token');
    const refreshRes = await fetch('/api/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken })
    });

    if (refreshRes.ok) {
      const { accessToken } = await refreshRes.json();
      localStorage.setItem('access_token', accessToken);

      // 用新 Token 重试原请求
      return fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${accessToken}`
        }
      });
    }

    throw new Error('认证失败,请重新登录');
  }

  return response;
}

Basic Auth

// Basic 认证
const username = 'admin';
const password = 'secret';
const encoded = btoa(`${username}:${password}`);

const response = await fetch('/api/admin', {
  headers: {
    'Authorization': `Basic ${encoded}`
  }
});

5. 错误处理

Fetch 的”陷阱”:HTTP 错误不 reject

// 陷阱:4xx/5xx 不会触发 reject!
try {
  const response = await fetch('/api/not-found');
  // 即使 404,fetch 也会 resolve!
  console.log(response.status); // 404
  console.log(response.ok);     // false
} catch (error) {
  // 只有网络错误才会到这里
  console.error(error); // TypeError: Failed to fetch
}

只有这些情况才会 reject

情况是否 reject
网络断开reject
DNS 解析失败reject
CORS 被拒绝reject
请求被 abortreject(AbortError)
URL 格式错误reject
200 OKresolve
301/302 重定向resolve(自动跟随)
400 Bad Requestresolve(需手动判断)
401 Unauthorizedresolve(需手动判断)
404 Not Foundresolve(需手动判断)
500 Internal Server Errorresolve(需手动判断)

统一错误处理封装

class HttpError extends Error {
  constructor(message, status, response) {
    super(message);
    this.name = 'HttpError';
    this.status = status;
    this.response = response;
  }
}

async function fetchJson(url, options = {}) {
  const response = await fetch(url, {
    headers: {
      'Accept': 'application/json',
      ...options.headers
    },
    ...options
  });

  // HTTP 错误统一抛出
  if (!response.ok) {
    let errorBody;
    try {
      errorBody = await response.json();
    } catch {
      errorBody = await response.text();
    }
    throw new HttpError(
      errorBody?.message || `HTTP ${response.status}`,
      response.status,
      errorBody
    );
  }

  // 204 No Content 无响应体
  if (response.status === 204) {
    return null;
  }

  return response.json();
}

// 使用
try {
  const user = await fetchJson('/api/users/1');
  console.log(user);
} catch (error) {
  if (error instanceof HttpError) {
    console.error(`HTTP ${error.status}: ${error.message}`);
    if (error.status === 401) {
      // 跳转登录
      window.location.href = '/login';
    }
  } else {
    console.error('网络错误:', error.message);
  }
}

6. 超时处理

Fetch 没有原生的 timeout 配置,需要用 AbortController + setTimeout 实现:

// 基础超时
async function fetchWithTimeout(url, options = {}, timeout = 5000) {
  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 data = await fetchWithTimeout('/api/slow', {}, 3000);
  console.log(await data.json());
} catch (error) {
  if (error.name === 'AbortError') {
    console.error('请求超时');
  } else {
    console.error('请求失败:', error);
  }
}

区分超时与手动取消

async function fetchWithTimeout(url, options = {}, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => {
    controller.abort(new DOMException('请求超时', 'TimeoutError'));
  }, timeout);

  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } catch (error) {
    if (error.name === 'TimeoutError') {
      throw new Error(`请求超时(${timeout}ms): ${url}`);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

7. 请求取消

AbortController 基础

const controller = new AbortController();

fetch('/api/users', { signal: controller.signal })
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求已取消');
    }
  });

// 取消请求
controller.abort();

取消多个请求

const controller = new AbortController();
const signal = controller.signal;

// 多个请求共享同一个 signal
const [users, posts] = await Promise.all([
  fetch('/api/users', { signal }),
  fetch('/api/posts', { signal })
]);

// 一次性取消全部
controller.abort();

React 中取消请求

function useFetch(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchData() {
      try {
        setLoading(true);
        const response = await fetch(url, { signal: controller.signal });
        const result = await response.json();
        setData(result);
      } catch (err) {
        if (err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    }

    fetchData();

    // 组件卸载时取消请求
    return () => controller.abort();
  }, [url]);

  return { data, error, loading };
}

abort 原因

// 传入 abort 原因(Chrome 98+)
const controller = new AbortController();
controller.abort(new Error('用户手动取消'));

fetch('/api/users', { signal: controller.signal })
  .catch(err => {
    console.log(err.name);    // 'AbortError'
    console.log(err.cause);   // Error: 用户手动取消(如果支持)
  });

// 也可以用 AbortSignal.timeout() —— 原生超时(Chrome 103+)
const response = await fetch('/api/slow', {
  signal: AbortSignal.timeout(3000) // 3秒超时
});

AbortSignal.any() —— 组合多个信号

// 组合超时信号和手动取消信号(Chrome 116+)
const manualController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000);
const combinedSignal = AbortSignal.any([
  manualController.signal,
  timeoutSignal
]);

fetch('/api/data', { signal: combinedSignal });

// 任何一个信号触发都会取消
manualController.abort(); // 手动取消
// 或 5 秒后自动超时取消

8. 重试模式

指数退避重试

async function fetchWithRetry(url, options = {}, retries = 3) {
  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url, options);

      if (response.ok) {
        return response;
      }

      // 5xx 服务器错误才重试,4xx 客户端错误不重试
      if (response.status >= 500 && attempt < retries) {
        const delay = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
        const jitter = Math.random() * 1000;       // 随机抖动避免惊群
        await sleep(delay + jitter);
        continue;
      }

      return response; // 4xx 或重试耗尽,返回响应
    } catch (error) {
      // 网络错误才重试
      if (attempt < retries) {
        const delay = Math.pow(2, attempt) * 1000;
        await sleep(delay);
        continue;
      }
      throw error; // 重试耗尽,抛出错误
    }
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 使用
const response = await fetchWithRetry('/api/unstable', {}, 3);

幂等方法判断

const IDEMPOTENT_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'];

async function fetchWithSmartRetry(url, options = {}, retries = 3) {
  const method = (options.method || 'GET').toUpperCase();
  const isIdempotent = IDEMPOTENT_METHODS.includes(method);

  for (let attempt = 0; attempt <= retries; attempt++) {
    try {
      const response = await fetch(url, options);

      if (response.ok) return response;

      // 非幂等方法(POST/PATCH)只在 5xx 且确认安全时重试
      if (!isIdempotent && response.status !== 503) {
        return response; // 不重试
      }

      if (response.status >= 500 && attempt < retries) {
        const delay = Math.pow(2, attempt) * 1000 + Math.random() * 500;
        await sleep(delay);
        continue;
      }

      return response;
    } catch (error) {
      if (!isIdempotent && attempt > 0) {
        throw error; // POST 网络错误可能已执行,谨慎重试
      }
      if (attempt < retries) {
        await sleep(Math.pow(2, attempt) * 1000);
        continue;
      }
      throw error;
    }
  }
}

9. 请求拦截器模式

Fetch 没有内置拦截器,但可以通过包装 fetch 来实现类似 Axios 的拦截器功能:

class FetchInterceptor {
  constructor() {
    this.requestInterceptors = [];
    this.responseInterceptors = [];
    this.originalFetch = window.fetch.bind(window);
  }

  // 添加请求拦截器
  addRequestInterceptor(onFulfilled, onRejected) {
    this.requestInterceptors.push({ onFulfilled, onRejected });
    return this; // 链式调用
  }

  // 添加响应拦截器
  addResponseInterceptor(onFulfilled, onRejected) {
    this.responseInterceptors.push({ onFulfilled, onRejected });
    return this;
  }

  // 执行请求拦截器链
  async runRequestInterceptors(url, options) {
    let config = { url, options };

    for (const interceptor of this.requestInterceptors) {
      try {
        config = await interceptor.onFulfilled(config);
      } catch (error) {
        if (interceptor.onRejected) {
          config = await interceptor.onRejected(error);
        } else {
          throw error;
        }
      }
    }

    return config;
  }

  // 执行响应拦截器链
  async runResponseInterceptors(response) {
    let res = response;

    for (const interceptor of this.responseInterceptors) {
      try {
        res = await interceptor.onFulfilled(res);
      } catch (error) {
        if (interceptor.onRejected) {
          res = await interceptor.onRejected(error);
        } else {
          throw error;
        }
      }
    }

    return res;
  }

  // 包装后的 fetch
  async fetch(url, options = {}) {
    // 1. 请求拦截
    const config = await this.runRequestInterceptors(url, options);

    // 2. 发起请求
    let response = await this.originalFetch(config.url, config.options);

    // 3. 响应拦截
    response = await this.runResponseInterceptors(response);

    return response;
  }

  // 便捷方法
  get(url, options = {}) {
    return this.fetch(url, { ...options, method: 'GET' });
  }

  post(url, body, options = {}) {
    return this.fetch(url, {
      ...options,
      method: 'POST',
      headers: { 'Content-Type': 'application/json', ...options.headers },
      body: JSON.stringify(body)
    });
  }

  put(url, body, options = {}) {
    return this.fetch(url, {
      ...options,
      method: 'PUT',
      headers: { 'Content-Type': 'application/json', ...options.headers },
      body: JSON.stringify(body)
    });
  }

  delete(url, options = {}) {
    return this.fetch(url, { ...options, method: 'DELETE' });
  }
}

使用拦截器

const http = new FetchInterceptor();

// 请求拦截器:添加 Token
http.addRequestInterceptor((config) => {
  const token = localStorage.getItem('access_token');
  if (token) {
    config.options.headers = {
      ...config.options.headers,
      'Authorization': `Bearer ${token}`
    };
  }
  return config;
});

// 请求拦截器:添加时间戳防缓存
http.addRequestInterceptor((config) => {
  if (config.options.method === 'GET') {
    const separator = config.url.includes('?') ? '&' : '?';
    config.url = `${config.url}${separator}_t=${Date.now()}`;
  }
  return config;
});

// 响应拦截器:统一错误处理
http.addResponseInterceptor((response) => {
  if (!response.ok) {
    throw new HttpError(
      `HTTP ${response.status}`,
      response.status,
      response
    );
  }
  return response;
});

// 响应拦截器:自动解析 JSON
http.addResponseInterceptor(async (response) => {
  const contentType = response.headers.get('Content-Type');
  if (contentType && contentType.includes('application/json')) {
    const data = await response.json();
    return { data, status: response.status, headers: response.headers };
  }
  return response;
});

// 响应拦截器:Token 过期自动刷新
http.addResponseInterceptor(
  async (response) => response,
  async (error) => {
    if (error.status === 401) {
      const newToken = await refreshToken();
      if (newToken) {
        // 重试原请求
        return http.fetch(error.response.url);
      }
    }
    throw error;
  }
);

// 使用
const result = await http.get('/api/users');
const created = await http.post('/api/users', { name: '张三' });

10. 并发请求

Promise.all —— 全部成功才成功

// 并发请求,全部完成后一起处理
const [usersRes, postsRes, commentsRes] = await Promise.all([
  fetch('/api/users'),
  fetch('/api/posts'),
  fetch('/api/comments')
]);

const users = await usersRes.json();
const posts = await postsRes.json();
const comments = await commentsRes.json();

Promise.allSettled —— 全部完成(无论成败)

// 不在乎部分失败,获取全部结果
const results = await Promise.allSettled([
  fetch('/api/users'),
  fetch('/api/unstable'),  // 可能失败
  fetch('/api/comments')
]);

results.forEach((result) => {
  if (result.status === 'fulfilled') {
    console.log('成功:', result.value);
  } else {
    console.error('失败:', result.reason);
  }
});

Promise.race —— 最快的那个

// 超时模式:谁先完成用谁
const response = await Promise.race([
  fetch('/api/fast-mirror'),
  fetch('/api/slow-mirror')
]);

Promise.any —— 第一个成功的

// 多源容灾:任一成功即可
const response = await Promise.any([
  fetch('https://cdn1.example.com/data.json'),
  fetch('https://cdn2.example.com/data.json'),
  fetch('https://cdn3.example.com/data.json')
]);

批量请求控制并发数

async function batchFetch(urls, options = {}, concurrency = 5) {
  const results = new Array(urls.length);
  let index = 0;

  async function worker() {
    while (index < urls.length) {
      const currentIndex = index++;
      try {
        results[currentIndex] = {
          status: 'fulfilled',
          value: await fetch(urls[currentIndex], options)
        };
      } catch (error) {
        results[currentIndex] = {
          status: 'rejected',
          reason: error
        };
      }
    }
  }

  // 创建 concurrency 个 worker 并行执行
  const workers = Array.from({ length: Math.min(concurrency, urls.length) }, () => worker());
  await Promise.all(workers);

  return results;
}

// 使用:10 个请求,最多同时 3 个
const urls = Array.from({ length: 10 }, (_, i) => `/api/items/${i + 1}`);
const results = await batchFetch(urls, {}, 3);

更优雅的并发控制(基于队列)

class ConcurrencyPool {
  constructor(limit) {
    this.limit = limit;
    this.running = 0;
    this.queue = [];
  }

  async add(fn) {
    if (this.running >= this.limit) {
      await new Promise(resolve => this.queue.push(resolve));
    }

    this.running++;
    try {
      return await fn();
    } finally {
      this.running--;
      if (this.queue.length > 0) {
        this.queue.shift()(); // 唤醒下一个等待的任务
      }
    }
  }
}

// 使用
const pool = new ConcurrencyPool(3);
const results = await Promise.allSettled(
  urls.map(url => pool.add(() => fetch(url).then(r => r.json())))
);

11. 流式读取

ReadableStream 基础

const response = await fetch('/api/stream');

// 获取 ReadableStream
const stream = response.body;
const reader = stream.getReader();

// 分块读取
let chunks = [];
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  chunks.push(value);
  console.log(`收到 ${value.length} 字节`);
}

// 合并所有块
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
  result.set(chunk, offset);
  offset += chunk.length;
}
const text = new TextDecoder().decode(result);
console.log(text);

TextDecoderStream —— 流式文本解码

const response = await fetch('/api/stream');
const reader = response.body
  .pipeThrough(new TextDecoderStream())
  .getReader();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(value); // 直接是字符串
}

SSE(Server-Sent Events)事件流

async function readSSE(url) {
  const response = await fetch(url, {
    headers: { 'Accept': 'text/event-stream' }
  });

  const reader = response.body
    .pipeThrough(new TextDecoderStream())
    .getReader();

  let buffer = '';

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

    buffer += value;
    const lines = buffer.split('\n');
    buffer = lines.pop(); // 未完成的行保留

    for (const line of lines) {
      if (line.startsWith('data: ')) {
        const data = line.slice(6);
        if (data === '[DONE]') return;
        try {
          const parsed = JSON.parse(data);
          console.log('SSE 事件:', parsed);
        } catch {
          console.log('SSE 文本:', data);
        }
      }
    }
  }
}

// 调用 SSE 流(如 ChatGPT 流式响应)
readSSE('/api/chat/stream');

大文件流式下载 + 进度

async function downloadWithProgress(url, filename) {
  const response = await fetch(url);
  const contentLength = +response.headers.get('Content-Length');
  let receivedLength = 0;

  const reader = response.body.getReader();
  const chunks = [];

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

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

    // 计算进度
    const progress = contentLength
      ? Math.round((receivedLength / contentLength) * 100)
      : 0;
    console.log(`下载进度: ${progress}% (${receivedLength}/${contentLength})`);
  }

  // 合并并下载
  const blob = new Blob(chunks);
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = filename;
  a.click();
  URL.revokeObjectURL(a.href);
}

流式处理 TransformStream

// 将流式 JSON NDJSON 逐行解析
async function readNDJSON(url) {
  const response = await fetch(url);

  const ndjsonParser = new TransformStream({
    buffer: '',
    transform(chunk, controller) {
      this.buffer += chunk;
      const lines = this.buffer.split('\n');
      this.buffer = lines.pop();
      for (const line of lines) {
        if (line.trim()) {
          controller.enqueue(JSON.parse(line));
        }
      }
    },
    flush(controller) {
      if (this.buffer.trim()) {
        controller.enqueue(JSON.parse(this.buffer));
      }
    }
  });

  const reader = response.body
    .pipeThrough(new TextDecoderStream())
    .pipeThrough(ndjsonParser)
    .getReader();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    console.log('NDJSON 对象:', value);
  }
}

12. 文件上传

FormData 基础上传

// 单文件上传
const input = document.querySelector('input[type="file"]');
const file = input.files[0];

const formData = new FormData();
formData.append('file', file);
formData.append('description', '用户头像');

const response = await fetch('/api/upload', {
  method: 'POST',
  body: formData // 不设置 Content-Type,浏览器自动添加 boundary
});

多文件上传

// 多文件上传
const input = document.querySelector('input[type="file"]');
const files = input.files;

const formData = new FormData();
for (const file of files) {
  formData.append('files', file); // 同名 append,后端接收数组
}
formData.append('category', 'documents');

const response = await fetch('/api/upload/batch', {
  method: 'POST',
  body: formData
});

拖拽上传

const dropZone = document.getElementById('drop-zone');

dropZone.addEventListener('dragover', (e) => {
  e.preventDefault();
  dropZone.classList.add('drag-over');
});

dropZone.addEventListener('dragleave', () => {
  dropZone.classList.remove('drag-over');
});

dropZone.addEventListener('drop', async (e) => {
  e.preventDefault();
  dropZone.classList.remove('drag-over');

  const files = e.dataTransfer.files;
  const formData = new FormData();
  for (const file of files) {
    formData.append('files', file);
  }

  const response = await fetch('/api/upload', {
    method: 'POST',
    body: formData
  });
  console.log('上传结果:', await response.json());
});

上传进度 —— 用 XHR 补充

Fetch 不支持上传进度监控,需要上传进度时用 XHR:

function uploadWithProgress(url, formData, onProgress) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', url);

    xhr.upload.onprogress = (e) => {
      if (e.lengthComputable) {
        const percent = Math.round((e.loaded / e.total) * 100);
        onProgress(percent);
      }
    };

    xhr.onload = () => {
      if (xhr.status >= 200 && xhr.status < 300) {
        resolve(JSON.parse(xhr.responseText));
      } else {
        reject(new Error(`上传失败: HTTP ${xhr.status}`));
      }
    };

    xhr.onerror = () => reject(new Error('网络错误'));
    xhr.send(formData);
  });
}

// 使用
const result = await uploadWithProgress(
  '/api/upload',
  formData,
  (percent) => console.log(`上传进度: ${percent}%`)
);

大文件分片上传

async function uploadLargeFile(file, chunkSize = 5 * 1024 * 1024) {
  const totalChunks = Math.ceil(file.size / chunkSize);

  for (let i = 0; i < totalChunks; i++) {
    const start = i * chunkSize;
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end);

    const formData = new FormData();
    formData.append('file', chunk);
    formData.append('chunkIndex', i);
    formData.append('totalChunks', totalChunks);
    formData.append('filename', file.name);

    const response = await fetch('/api/upload/chunk', {
      method: 'POST',
      body: formData
    });

    if (!response.ok) {
      throw new Error(`分片 ${i} 上传失败`);
    }

    console.log(`分片 ${i + 1}/${totalChunks} 上传完成`);
  }

  // 通知服务器合并
  const mergeRes = await fetch('/api/upload/merge', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      filename: file.name,
      totalChunks,
      chunkSize
    })
  });

  return mergeRes.json();
}

常见问题

1. CORS 错误

Access to fetch at 'https://api.other.com/data' from origin 'https://myapp.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is
present on the requested resource.

原因: 浏览器同源策略限制,跨域请求需要服务器返回 CORS 头。

解决方案:

// 服务端(Node.js Express 示例)
app.use((req, res, next) => {
  res.header('Access-Control-Allow-Origin', 'https://myapp.com');
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
  res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  res.header('Access-Control-Allow-Credentials', 'true');
  if (req.method === 'OPTIONS') {
    return res.sendStatus(204); // 预检请求
  }
  next();
});

// 前端 —— 跨域携带 Cookie 时 credentials 必须为 include
const res = await fetch('https://api.other.com/data', {
  credentials: 'include'
});

// 开发环境可用代理(vite.config.js)
export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.other.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
};

2. credentials 默认值变更

Fetch 的 credentials 默认值为 same-origin,而 XHR 的 withCredentials 默认值为 false。关键区别:

  • Fetch 同源请求默认发送 Cookie
  • Fetch 跨域请求默认不发送 Cookie
  • 如果需要跨域发送 Cookie,必须设置 credentials: 'include',且服务端不能设置 Access-Control-Allow-Origin: *

3. 4xx/5xx 不 reject

这是 Fetch 最常见的”陷阱”。HTTP 错误状态码不会触发 Promise reject,只有网络级别的错误才会。必须手动检查 response.okresponse.status

4. body 只能读一次

Response 的 body 是一个 ReadableStream,消费后即失效:

const res = await fetch('/api/users');
const text = await res.text();
const json = await res.json(); // TypeError: body stream already read

// 解决:clone()
const res = await fetch('/api/users');
const cloned = res.clone();
const text = await res.text();
const json = await cloned.json(); // OK

5. GET/HEAD 不能有 body

// 错误:GET 请求不能有 body
fetch('/api/search', {
  method: 'GET',
  body: JSON.stringify({ keyword: 'test' }) // TypeError
});

// 正确:用查询参数
fetch('/api/search?keyword=test');

6. no-cors 模式的限制

// no-cors 模式下
const res = await fetch('https://other.com/api', {
  mode: 'no-cors',
  method: 'POST',
  headers: {
    'Content-Type': 'application/json' // 无效!只允许简单头
  },
  body: JSON.stringify({ data: 'test' }) // 无效!只允许简单类型
});

console.log(res.type);    // 'opaque'
console.log(res.status);  // 0 —— 无法获取状态码
console.log(res.headers); // 空 —— 无法获取头
// 完全读不到任何内容

面试题

1. Fetch 和 XMLHttpRequest 有什么区别?

答:

维度FetchXHR
设计模式基于 Promise基于事件回调
错误处理HTTP 错误不 reject,需手动判断 okonerror 仅网络错误,HTTP 错误在 onload 中处理
流式读取支持 ReadableStream不支持
请求取消AbortControllerxhr.abort()
进度监控不支持原生上传进度onprogress 支持上传/下载进度
Cookiecredentials 控制跨域 CookiewithCredentials 控制
API 风格声明式,一个函数发起请求命令式,open() + send() 分步
Service Worker核心依赖不支持

关键点: Fetch 更现代、更简洁,但缺少进度监控;XHR 更成熟但 API 过时。两者互补,根据场景选用。


2. Fetch 如何处理 HTTP 错误(如 404、500)?

答:

Fetch 只有网络级别的错误(DNS 失败、网络断开、CORS 拒绝等)才会 reject。HTTP 4xx/5xx 错误仍然会 resolve,需要手动检查:

const response = await fetch('/api/not-found');

// 404 也会 resolve!必须手动判断
if (!response.ok) {
  // response.ok === (status >= 200 && status < 300)
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const data = await response.json();

建议封装统一的错误处理函数,将 !response.ok 的情况统一抛出自定义 HttpError,与网络错误的 TypeError 区分处理。


3. 如何实现 Fetch 请求超时?

答:

Fetch 没有原生 timeout,需要用 AbortController + setTimeout

async function fetchWithTimeout(url, options = {}, timeout = 5000) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), timeout);

  try {
    return await fetch(url, { ...options, signal: controller.signal });
  } catch (error) {
    if (error.name === 'AbortError') {
      throw new Error(`请求超时(${timeout}ms)`);
    }
    throw error;
  } finally {
    clearTimeout(timeoutId);
  }
}

Chrome 103+ 支持原生 AbortSignal.timeout()

const res = await fetch(url, { signal: AbortSignal.timeout(5000) });

4. AbortController 的原理是什么?

答:

AbortController 是浏览器提供的请求取消机制:

  1. 创建控制器new AbortController() 产生一个控制器和关联的 signal
  2. 传递信号:将 controller.signal 传入 fetchsignal 选项
  3. 触发取消:调用 controller.abort() 时,signal 通知 fetch 终止请求
  4. fetch 抛出错误:被取消的请求 reject 一个 AbortError

核心原理是观察者模式 —— AbortSignal 继承自 EventTarget,fetch 内部监听 signal 的 abort 事件,收到后终止底层网络连接。

一个 signal 可以传给多个请求,实现批量取消。AbortSignal.any() 可以组合多个信号,任一触发即取消。


5. 如何实现 Fetch 请求重试?

答:

重试需要考虑三点:重试条件(5xx 或网络错误重试,4xx 不重试)、退避策略(指数退避 + 随机抖动)、幂等性(GET/PUT/DELETE 安全重试,POST 需谨慎):

async function fetchWithRetry(url, options = {}, retries = 3) {
  for (let i = 0; i <= retries; i++) {
    try {
      const res = await fetch(url, options);
      if (res.ok) return res;
      if (res.status < 500 || i === retries) return res;
    } catch (err) {
      if (i === retries) throw err;
    }
    await sleep(Math.pow(2, i) * 1000 + Math.random() * 500);
  }
}

指数退避(1s, 2s, 4s…)避免大量请求同时重试压垮服务器。随机抖动(jitter)防止惊群效应。


6. credentials 的三个值有什么区别?

答:

同源 Cookie跨域 Cookie
omit不发送不发送
same-origin发送不发送
include发送发送

默认值为 same-origin,跨域请求默认不发送 Cookie,这与 XHR 的 withCredentials: false 行为一致。

使用 include 时,服务端必须:

  1. 设置 Access-Control-Allow-Credentials: true
  2. Access-Control-Allow-Origin 不能为 *,必须是具体域名

常见场景:前后端分离部署在不同域名时,登录态 Cookie 需 credentials: 'include' 才能携带。


7. 如何读取流式响应(Streaming Response)?

答:

通过 response.body 获取 ReadableStream,用 getReader() 逐块读取:

const response = await fetch('/api/stream');
const reader = response.body.getReader();

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  // value 是 Uint8Array
  const text = new TextDecoder().decode(value);
  console.log(text);
}

也可以用 pipeThrough 管道处理:

const reader = response.body
  .pipeThrough(new TextDecoderStream())
  .getReader();
// value 直接是字符串

典型应用场景:SSE 事件流(ChatGPT 流式输出)、大文件下载进度、NDJSON 流式解析。


8. 如何给 Fetch 实现类似 Axios 的拦截器?

答:

核心思路是包装 fetch,在请求前后插入拦截器链:

  1. 维护 requestInterceptorsresponseInterceptors 两个数组
  2. 请求前,依次执行请求拦截器,修改 URL/options/headers
  3. 调用原始 fetch 发起请求
  4. 响应后,依次执行响应拦截器,处理 Response 或错误

请求拦截器常用场景:添加 Token、添加时间戳、请求日志。 响应拦截器常用场景:统一错误处理、自动解析 JSON、Token 过期刷新。

拦截器采用 Promise 链式执行,支持 onFulfilledonRejected 两个回调,与 Axios 拦截器行为一致。详见上文”请求拦截器模式”的完整实现。


相关链接:HTTP与缓存策略 Cookie与认证 Service Worker与PWA