02 - Fetch API

前置:AJAX 基础。fetch 是 2015 年进入标准的现代请求方案,原生返回 Promise,语法基于 Request/Response 对象。本章重点是它的设计哲学与那些坑。


1. fetch 的定位

fetch 是 XMLHttpRequest 的官方替代品:

维度XMLHttpRequestfetch
异步模型回调(onreadystatechange)原生 Promise
数据读取responseText 手动解析Response 对象方法链
请求取消abort()AbortController(标准化)
流式读取不支持response.body 可读流
Service Worker 拦截不支持完整支持
错误语义HTTP 错误也进 onerror 分支逻辑混乱只有网络层失败才 reject
// 最小示例:一行发起请求
fetch("/api/users")
  .then((res) => res.json())
  .then((data) => console.log(data));

2. 基本 GET:完整模板

async function getUser(id) {
  const res = await fetch(`/api/users/${id}`);
 
  // 第一步永远是检查 HTTP 层是否成功
  if (!res.ok) {
    throw new Error(`请求失败: ${res.status} ${res.statusText}`);
  }
 
  const user = await res.json(); // 第二步才解析数据
  console.log(user);
}

带查询参数的标准写法:

const params = new URLSearchParams({
  page: "1",
  size: "20",
  keyword: "无线鼠标",
});
 
const res = await fetch(`/api/products?${params}`);

3. POST JSON:完整模板

三个要素缺一不可:method、Content-Type 头、body 序列化:

async function createUser(user) {
  const res = await fetch("/api/users", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(user), // 必须手动序列化!
  });
 
  if (!res.ok) throw new Error(`创建失败: ${res.status}`);
  return res.json();
}
 
createUser({ name: "张三", email: "zhang@example.com" });

新手三大遗忘点自查清单:忘了 JSON.stringify(对象被隐式 toString 成 [object Object])、忘了设 Content-Type(后端解析失败)、GET 误传 body(fetch 会直接抛错)。

4. Response 对象

fetch resolve 出来的是一个 Response 实例:

成员类型说明
okbooleanstatus 在 200-299 之间
status / statusTextnumber / stringHTTP 状态码与文案
headersHeaders响应头,get() 取值
urlstring最终 URL(重定向后)
redirectedboolean是否发生过重定向

4.1 四种 body 读取方法

Response 的 body 只能读一次(流的物理限制),选对方法很重要:

const res = await fetch("/api/data");
 
await res.json();   // 解析为 JSON —— 接口默认选择
await res.text();   // 纯文本 —— HTML 片段、CSV
await res.blob();   // 二进制大对象 —— 图片、文件下载
await res.formData();// multipart 表单
 
// 读过一次后再读会报 TypeError: body stream already read
// 需要复用时先克隆:
const res2 = await fetch("/api/data");
const copy1 = res2.clone();
console.log(await res2.json());
console.log(await copy1.text());

4.2 读取响应头

const res = await fetch("/api/report");
console.log(res.headers.get("content-type"));
console.log(res.headers.get("x-request-id"));
 
// 注意:跨域时浏览器只暴露 CORS-Safelisted 响应头,
// 自定义头需服务器配置 Access-Control-Expose-Headers

5. 最大陷阱:404 和 500 不是错误

这是 fetch 与 XHR 语义差异最大、坑人最多的一点:

flowchart TD
    CALL["fetch(url)"] --> NET{"网络层成功?"}
    NET -->|"断网/DNS 失败/被墙"| REJECT["Promise reject<br/>TypeError: Failed to fetch"]
    NET -->|"收到任何响应"| RESOLVED["Promise resolved"]
    RESOLVED --> S{"状态码?"}
    S -->|"200-299"| OK["res.ok = true"]
    S -->|"404 / 500 / 401 ..."| BAD["res.ok = false<br/>但依然走 then!"]

    style REJECT fill:#ffebee
    style BAD fill:#fff3e0

反面教材(真实项目高频 bug):

// 错误认知:以为 catch 能接住 500
fetch("/api/users")
  .then((res) => res.json())     // 500 时 body 往往不是 JSON,
  .then(renderList)              // 这里会抛 SyntaxError,
  .catch((e) => {                // 报错信息误导你去查解析问题
    console.log("请求出错", e);  // 真正的 500 信息丢失了
  });

正确姿势——封装一个断言函数统一处理:

async function fetchJson(url, options) {
  const res = await fetch(url, options);
  if (!res.ok) {
    // 尽力把服务器的错误消息带上
    let detail = "";
    try { detail = (await res.json()).message ?? ""; } catch {}
    const err = new Error(`HTTP ${res.status}: ${detail || res.statusText}`);
    err.status = res.status;
    throw err;
  }
  return res.json();
}

记住口诀:fetch 只为网络层失败 reject,HTTP 错误要靠 res.ok 自己判

6. 请求配置全览

fetch(url, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    "Authorization": `Bearer ${token}`,
    "X-Custom-Id": "abc123",
  },
  body: JSON.stringify(payload),
  credentials: "same-origin", // cookie 策略,见下表
  signal: controller.signal,  // 取消信号
  cache: "default",           // no-cache / no-store / force-cache ...
  redirect: "follow",         // error / manual
});

credentials 决定跨域请求是否携带 cookie:

行为
”omit”从不发送 cookie
”same-origin”同源发送,跨域不发送(默认
“include”跨域也发送,要求服务端 Allow-Origin 不能是 * 且必须配 Allow-Credentials

老项目迁移注意:2017 年前的代码常见 credentials: "include" 全局开启;现在默认值已改为 same-origin,跨域会话突然失效多半是这里。

7. AbortController:超时与取消

7.1 超时的标准实现

async function fetchWithTimeout(url, ms = 8000, options = {}) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), ms);
 
  try {
    const res = await fetch(url, { ...options, signal: controller.signal });
    return res;
  } finally {
    clearTimeout(timer); // 无论成败都清理定时器
  }
}
 
try {
  const res = await fetchWithTimeout("/api/slow-report", 3000);
} catch (e) {
  if (e.name === "AbortError") console.error("超时取消");
  else console.error(e);
}

7.2 竞态场景:搜索框取消过期请求

用户连续输入时,先发出的请求可能后返回,旧结果覆盖新结果。AbortController 让过期请求直接作废:

sequenceDiagram
    participant U as 用户输入
    participant JS as 页面脚本
    participant S as 服务器
    U->>JS: 输入"手"
    JS->>S: 请求A(keyword=手)
    U->>JS: 继续输入成"手机"
    JS->>JS: abort 请求A
    JS->>S: 请求B(keyword=手机)
    S-->>JS: B 的结果
    Note over JS: A 被取消不会污染界面
    S--)JS: A 的迟到响应(已忽略)
let currentController = null;
 
searchInput.addEventListener("input", async (e) => {
  const keyword = e.target.value.trim();
 
  currentController?.abort();                 // 作废上一次
  const controller = new AbortController();
  currentController = controller;
 
  try {
    const res = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, {
      signal: controller.signal,
    });
    if (!res.ok) throw new Error(res.status);
    renderResults(await res.json());
  } catch (err) {
    if (err.name !== "AbortError") showError(err); // 取消不算错误,静默即可
  }
});

8. 流式读取:大文件进度

response.body 是 ReadableStream,可以边下载边处理,实现 XHR 时代很难做的下载进度:

async function downloadWithProgress(url, onProgress) {
  const res = await fetch(url);
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
 
  // Content-Length 可能缺失(chunked 传输),要做防御
  const total = Number(res.headers.get("content-length")) || 0;
  const reader = res.body.getReader();
 
  const chunks = [];
  let received = 0;
 
  while (true) {
    const { done, value } = await reader.read(); // value 是 Uint8Array
    if (done) break;
    chunks.push(value);
    received += value.length;
    onProgress(received, total); // 每收到一块就汇报
  }
 
  return new Blob(chunks); // 拼成完整 Blob
}
 
downloadWithProgress("/files/big-video.mp4", (loaded, total) => {
  const pct = total ? ((loaded / total) * 100).toFixed(1) : "?";
  bar.textContent = `${pct}%`;
}).then((blob) => {
  // 触发保存
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = "video.mp4";
  a.click();
  URL.revokeObjectURL(a.href);
});

9. 实战:封装通用 request 函数

把前面所有知识点收拢成一个生产可用的工具层:统一 baseURL、自动 JSON、超时、错误归一化:

/**
 * 轻量 request 封装(零依赖)
 */
function createRequest({ baseURL = "", timeout = 10000, getHeaders } = {}) {
  async function request(method, path, data, extra = {}) {
    const url = baseURL + path;
    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeout);
 
    const isGet = method === "GET";
    const fullUrl = isGet && data
      ? `${url}${url.includes("?") ? "&" : "?"}${new URLSearchParams(data)}`
      : url;
 
    try {
      const res = await fetch(fullUrl, {
        method,
        headers: {
          ...(isGet || data instanceof FormData
            ? {} : { "Content-Type": "application/json" }),
          ...getHeaders(),          // 每次 动态取 token,避免缓存旧凭证
        },
        body: isGet ? undefined
             : data instanceof FormData ? data
             : JSON.stringify(data),
        signal: controller.signal,
        ...extra,
      });
 
      if (!res.ok) {
        let body = null;
        try { body = await res.json(); } catch {}
        const err = new Error(body?.message ?? `HTTP ${res.status}`);
        err.status = res.status;
        err.body = body;
        throw err;
      }
      if (res.status === 204) return null;
 
      const type = res.headers.get("content-type") ?? "";
      return type.includes("json") ? res.json() : res.blob();
    } finally {
      clearTimeout(timer);
    }
  }
 
  return {
    get: (path, params, extra) => request("GET", path, params, extra),
    post: (path, data, extra) => request("POST", path, data, extra),
    put: (path, data, extra) => request("PUT", path, data, extra),
    delete: (path, extra) => request("DELETE", path, null, extra),
  };
}
 
/* ---------- 使用 ---------- */
const api = createRequest({
  baseURL: "/api",
  getHeaders: () => {
    const token = localStorage.getItem("token");
    return token ? { Authorization: `Bearer ${token}` } : {};
  },
});
 
const users = await api.get("/users", { page: 1 });       // GET + 参数
const created = await api.post("/users", { name: "李四" }); // POST JSON
const avatar = await api.post("/upload", formDataObj);     // FormData 自动识别
await api.delete("/users/42");

这个封装覆盖了日常九成场景。当需求长出拦截器、refresh token、并发助手这些能力时,就该换 axios 了——见下一章 axios 与拦截器


10. 小结

mindmap
  root(("Fetch API"))
    设计
      Promise 化
      Request Response 对象
      流式 body
    核心模板
      GET 拼 URLSearchParams
      POST 三件套 method headers stringify
    陷阱
      只在网络层失败 reject
      必须 res.ok 判断
      body 只能读一次
    进阶
      credentials 控制 cookie
      AbortController 超时与竞态
      reader.read 流式进度

一句话总结本章:fetch 把简单的事情变优雅了,但把”判断成功”的责任交还给了你——永远不要裸用 fetch。