JavaScript fetch 与 HTTP 请求实践

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Node.js 22 LTS

fetch 现在是浏览器和 Node 的通用 HTTP 客户端。它比 XMLHttpRequest 好用,但几个默认行为会让新手误判成功与否。

环境准备

浏览器直接可用;Node.js 22 内置全局 fetch,无需安装依赖。示例接口统一写成 /api/…,换成真实地址即可。

最小请求

const res = await fetch("https://api.example.com/items");
const data = await res.json();

fetch 只在网络层失败(DNS、断网、连接被拒)时 reject。服务器返回 404 或 500,Promise 依然 fulfilled——必须自己检查 res.ok:

async function getJson(url) {
  const res = await fetch(url);
  if (!res.ok) {
    throw new Error(`请求失败:${res.status} ${res.statusText}`);
  }
  return res.json();
}

Response 常用成员

res.status;        // 200
res.ok;            // 200–299
res.statusText;    // "OK"
res.headers.get("content-type"); // "application/json; charset=utf-8"
res.url;           // 最终地址(跟随重定向后)

读取响应体只能选一种方式,且只能读一次:

await res.json();   // 解析 JSON
await res.text();   // 原始文本
await res.blob();   // 二进制
await res.arrayBuffer();

需要读两次就先克隆:const copy = res.clone()。调试时直接 console.log(res) 看不到内容,得 await res.text()。

POST 与请求头

const res = await fetch("/api/items", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({ title: "写一篇稿子" }),
});

注意:

  • body 必须是字符串或二进制;给对象要自己 JSON.stringify。
  • 手写 content-type 后,服务端才会按 JSON 解析;漏了会当成表单文本。
  • 需要带 cookie 时,浏览器端写 credentials: "include",同时服务端要允许该来源凭证。

超时与取消

fetch 默认没有超时,一个卡住的请求会一直挂着。用 AbortSignal:

// 固定超时
const res = await fetch("/api/slow", { signal: AbortSignal.timeout(5000) });

// 手动取消(比如用户点了「取消」)
const controller = new AbortController();
const promise = fetch("/api/search?q=go", { signal: controller.signal });
cancelButton.onclick = () => controller.abort();

try {
  const res = await promise;
} catch (err) {
  if (err.name === "AbortError") return; // 主动取消,不算错误
  throw err;
}

超时触发的是 TimeoutError(AbortSignal.timeout),用户取消是 AbortError,两者要分开处理。

失败重试与退避

网络抖动、限流(429)、网关错误(502/503/504)都值得重试;400/401/404 重试没有意义。

async function fetchWithRetry(url, { retries = 3, baseDelay = 300 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
    const retryable = res.status === 429 || res.status >= 500;

    if (res.ok || !retryable || attempt >= retries) return res;

    const retryAfter = Number(res.headers.get("retry-after"));
    const delay = Number.isFinite(retryAfter)
      ? retryAfter * 1000
      : baseDelay * 2 ** attempt + Math.random() * 100; // 指数退避 + 抖动

    await new Promise((r) => setTimeout(r, delay));
  }
}

指数退避加随机抖动,避免所有客户端在同一秒重试。

并发请求

const urls = ["/api/a", "/api/b", "/api/c"];

// 全部成功才继续
const [a, b, c] = await Promise.all(urls.map(getJson));

// 允许部分失败
const results = await Promise.allSettled(urls.map(getJson));
for (const r of results) {
  if (r.status === "fulfilled") console.log(r.value);
  else console.warn("跳过失败项", r.reason.message);
}

并发数要控制(见上一篇的 mapLimit),几十个以上就可能触发浏览器同域连接上限或服务端限流。

流式读取

响应体是 ReadableStream,可以边下边处理,适合大文件或 SSE:

const res = await fetch("/api/export.ndjson");
const reader = res.body.getReader();
const decoder = new TextDecoder();

let buffer = "";
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const lines = buffer.split("\n");
  buffer = lines.pop() ?? "";
  for (const line of lines) if (line) console.log(JSON.parse(line));
}

真要逐条解析 SSE 事件,优先用 EventSource 或现成库,别自己切分。

CORS 与跨域

浏览器端跨域请求受同源策略限制,报错信息通常是 blocked by CORS policy。这是浏览器在拦截,不是服务端没返回数据:

  • 服务端需返回 Access-Control-Allow-Origin(不能同时用 * 和凭证)。
  • 带自定义头或 PUT/DELETE 会先发 OPTIONS 预检。
  • curl 能通、页面报 CORS,说明是响应头问题。
  • 开发阶段用 Vite 的 server.proxy 转发,比让后端放开 * 干净。

Node 里没有同源策略,fetch 不受 CORS 限制;相反,浏览器里的 file:// 页面请求本地文件会被拦。

Node 与浏览器的差异

项 浏览器 Node.js
相对 URL 相对当前页面 必须绝对 URL(/api 会报错)
Cookie 同源自动带 需手动传 headers.cookie
CORS 受限 不适用
代理 受浏览器/服务端配置 需自己处理或设置全局 undici dispatcher

在 Node 里请求本地服务,写成 http://localhost:3000/api/x 才有效。

常见错误与注意事项

1. 把 404 当成异常

fetch 不 reject,只有自己检查 res.ok 才不会把错误数据当成功处理。

2. 忘记 await 就直接用 res.json()

res.json() 返回 Promise,忘了 await 会拿到 Promise { <pending> }。

3. 读取两次响应体

res.json() 之后再 res.text() 会抛 Body is unusable,需要 res.clone()。

4. 用 JSON.stringify 处理 FormData

上传文件用 FormData,此时不要设 content-type,浏览器会自动带上 boundary。

5. 重试所有失败

POST 请求盲目重试可能造成重复下单。只重试幂等请求,或给请求加幂等键。

6. 错误信息只有 Failed to fetch

浏览器出于安全不暴露细节。用 try/catch 打印 err.name、err.cause,并在 Network 面板确认请求是否真的发出。

7. 忘了 AbortSignal

页面切走了请求还在跑,回来时用过期数据覆盖新状态。组件卸载/路由切换时记得 abort()。

小结

  • fetch 只在网络层失败时 reject,业务错误要查 res.ok。
  • 响应体只能读一次,需要复用就 clone()。
  • 超时用 AbortSignal.timeout,可取消用 AbortController。
  • 重试只针对 429/5xx,配合指数退避与抖动。
  • 无依赖请求并行,批量任务限并发;浏览器端注意 CORS,Node 端注意绝对 URL。

下一步