前端并发请求工程指南

1 阅读10分钟

目录


一、为什么需要关心并发请求

前端"同时"发出多个请求时常见问题:

  • 页面卡顿:上百个并发挤占主线程 + 网络
  • 服务端被打垮:未限速时一次刷新打几十个 API
  • 数据错乱:先发的请求后到,覆盖了新数据(race condition)
  • 重复请求:组件多次挂载/快速切换都各发一遍
  • 资源泄漏:组件卸载后请求仍在跑、回调访问已 unmounted state
  • 失败传染:一个请求错误连带整个页面挂掉

并发请求工程的目标:让该并行的并行,该串行的串行,该取消的取消,该缓存的缓存。


二、浏览器并发限制

2.1 同源连接数

协议浏览器(典型)上限
HTTP/1.1 同域Chrome / Firefox / Safari6
HTTP/2 同域所有现代浏览器无硬限(通常上百路复用单连接)
HTTP/3 (QUIC)同 H2无硬限

HTTP/1.1 同域 6 个意味着:你 Promise.all([...100 fetches]) 实际只有 6 个在跑,其余在浏览器内部排队。

2.2 影响

  • 慢请求会阻塞同域其他请求(Head-of-Line Blocking)
  • 资源(图片、脚本)和 API 共用配额
  • 跨域会单独计算配额

2.3 优化手段

  • 升级到 HTTP/2/3(通常 CDN/网关侧开)
  • 分子域名:api1.example.comapi2.example.com(HTTP/1.1 时代的 sharding,H2 后反而有害)
  • 合并接口(GraphQL / BFF / batch endpoint)
  • 资源走 CDN 与 API 域名分离

三、原生并发原语

3.1 Promise.all

全部成功才 resolve;任一失败立即 reject,其他请求仍会继续跑(不会自动取消)。

const [user, posts, settings] = await Promise.all([
  fetch("/api/user").then(r => r.json()),
  fetch("/api/posts").then(r => r.json()),
  fetch("/api/settings").then(r => r.json()),
]);

⚠️ 失败时若想取消其余请求,需配合 AbortController(见后文)。

3.2 Promise.allSettled

等全部完成(无论成败),返回每项的 status + value/reason

const results = await Promise.allSettled([api1(), api2(), api3()]);
const ok = results.filter(r => r.status === "fulfilled").map(r => r.value);
const errs = results.filter(r => r.status === "rejected");

适用:仪表盘多个独立卡片,单个失败不该影响其他。

3.3 Promise.race

第一个完成(成败均可)即返回。

const data = await Promise.race([
  fetch("/api/data"),
  new Promise((_, rej) => setTimeout(() => rej(new Error("timeout")), 5000)),
]);

适用:手动超时、多源回退。

3.4 Promise.any

第一个成功即返回;全部失败抛 AggregateError

const data = await Promise.any([
  fetch("https://cdn1.example.com/data"),
  fetch("https://cdn2.example.com/data"),
  fetch("https://cdn3.example.com/data"),
]);

适用:多 CDN / 多备份接口。

3.5 选用速查

需求API
全部成功才继续Promise.all
全做完看结果Promise.allSettled
第一个出结果Promise.race
第一个成功Promise.any

四、AbortController 与请求取消

4.1 基础

const ac = new AbortController();
fetch("/api/data", { signal: ac.signal })
  .then(r => r.json())
  .catch(e => {
    if (e.name === "AbortError") return;
    throw e;
  });

ac.abort();   // 取消

4.2 React 中卸载取消

useEffect(() => {
  const ac = new AbortController();
  fetch(`/api/user/${id}`, { signal: ac.signal })
    .then(r => r.json())
    .then(setUser)
    .catch(e => { if (e.name !== "AbortError") setError(e); });
  return () => ac.abort();
}, [id]);

4.3 多个请求共享一个 signal

const ac = new AbortController();
await Promise.all([
  fetch("/a", { signal: ac.signal }),
  fetch("/b", { signal: ac.signal }),
]);
// ac.abort() 会一次性取消所有

4.4 AbortSignal.timeout(现代浏览器)

fetch(url, { signal: AbortSignal.timeout(5000) });

4.5 AbortSignal.any 组合多个 signal

const ac = new AbortController();
fetch(url, {
  signal: AbortSignal.any([ac.signal, AbortSignal.timeout(10_000)]),
});

任一触发即取消。

4.6 axios 的取消

const ac = new AbortController();
axios.get("/api", { signal: ac.signal });
ac.abort();

axios 也支持旧版 CancelToken,新代码用 AbortController

4.7 注意

  • abort() 只是通知,服务端可能已经处理完
  • 取消后 fetchAbortError,要单独处理避免误报错
  • 取消的请求不计入浏览器并发上限

五、并发控制:限流与队列

5.1 为什么要限流

// ❌ 一次发 1000 个请求
await Promise.all(items.map(i => upload(i)));
  • 浏览器排队反而拖慢
  • 服务端可能 429 / 拒绝
  • 内存压力(每个请求都持有 Promise 链)

5.2 简单并发池

async function pool<T, R>(
  items: T[],
  worker: (item: T) => Promise<R>,
  concurrency = 5,
): Promise<R[]> {
  const results: R[] = new Array(items.length);
  let next = 0;

  async function run() {
    while (true) {
      const i = next++;
      if (i >= items.length) return;
      results[i] = await worker(items[i]);
    }
  }

  await Promise.all(Array.from({ length: concurrency }, run));
  return results;
}

const data = await pool(urls, fetchOne, 6);

5.3 用 p-limit / p-queue(推荐)

import pLimit from "p-limit";

const limit = pLimit(6);
const results = await Promise.all(
  items.map(item => limit(() => fetchOne(item)))
);

p-queue 提供更多功能(优先级、间隔、超时):

import PQueue from "p-queue";

const queue = new PQueue({ concurrency: 4, interval: 1000, intervalCap: 10 });
// 并发 4 + 每秒最多 10 个

const results = await Promise.all(
  items.map(item => queue.add(() => fetchOne(item)))
);

5.4 流式处理(边产出边消费)

不等全部完成、用异步迭代:

async function* mapPool<T, R>(items: T[], worker: (i: T) => Promise<R>, n = 5) {
  const queue = items.slice();
  const inflight = new Set<Promise<{ idx: number; value: R }>>();
  let idx = 0;

  while (queue.length || inflight.size) {
    while (queue.length && inflight.size < n) {
      const myIdx = idx++;
      const item = queue.shift()!;
      const p = worker(item).then(value => ({ idx: myIdx, value }));
      inflight.add(p);
      p.finally(() => inflight.delete(p));
    }
    const { value } = await Promise.race(inflight);
    yield value;
  }
}

for await (const r of mapPool(urls, fetch, 5)) {
  console.log("done:", r);
}

5.5 优先级队列

const queue = new PQueue({ concurrency: 4 });
queue.add(() => critical(), { priority: 10 });
queue.add(() => normal(), { priority: 0 });
queue.add(() => background(), { priority: -10 });

六、请求去重与合并

6.1 同时发起多个相同请求 → 只发一次

const inflight = new Map<string, Promise<any>>();

function dedupedFetch(url: string, init?: RequestInit) {
  const key = `${init?.method ?? "GET"} ${url}`;
  if (inflight.has(key)) return inflight.get(key)!;

  const p = fetch(url, init)
    .then(r => r.json())
    .finally(() => inflight.delete(key));

  inflight.set(key, p);
  return p;
}

6.2 keying 策略

简单 GET:用 URL + query。 复杂请求:URL + method + body 哈希。

function makeKey(url: string, init?: RequestInit) {
  const m = init?.method ?? "GET";
  const b = init?.body ? JSON.stringify(init.body) : "";
  return `${m} ${url} ${b}`;
}

6.3 失败要不要复用

通常失败不要复用:第二次调用希望真的重试。在 .catch 之前先 delete:

const p = fetch(url).then(r => r.json());
inflight.set(key, p);
p.then(
  () => inflight.delete(key),
  () => inflight.delete(key),
);
return p;

6.4 SWR / React Query 自带

它们的 useSWR(key, fetcher) / useQuery({ queryKey }) 本身就是按 key 去重,相同 key 同时挂载 → 只发一次。


七、缓存策略

7.1 分层

内存 (in-memory) → 速度极快,刷新即丢
sessionStorage  → 同 tab 生命周期
localStorage    → 持久,5MB 上限
IndexedDB       → 大容量、异步
HTTP Cache      → 浏览器原生,受 Cache-Control 控制
Service Worker  → 离线策略,可拦截全部请求

7.2 简单 TTL 缓存

type Entry<T> = { value: T; expires: number };
const cache = new Map<string, Entry<any>>();

async function cached<T>(key: string, fetcher: () => Promise<T>, ttl = 60_000): Promise<T> {
  const e = cache.get(key);
  if (e && e.expires > Date.now()) return e.value;
  const v = await fetcher();
  cache.set(key, { value: v, expires: Date.now() + ttl });
  return v;
}

7.3 stale-while-revalidate

立即返回旧数据 + 后台刷新:

async function swr<T>(key: string, fetcher: () => Promise<T>, ttl = 60_000): Promise<T> {
  const e = cache.get(key);
  if (e) {
    if (e.expires < Date.now()) {
      // 后台静默刷新
      fetcher().then(v => cache.set(key, { value: v, expires: Date.now() + ttl }));
    }
    return e.value;
  }
  const v = await fetcher();
  cache.set(key, { value: v, expires: Date.now() + ttl });
  return v;
}

7.4 利用 HTTP 缓存

服务端响应头:

Cache-Control: public, max-age=60, stale-while-revalidate=600
ETag: "abc123"

fetch 默认遵守。条件请求由浏览器自动加 If-None-Match,命中返回 304。

7.5 Service Worker 缓存

// sw.js
self.addEventListener("fetch", e => {
  if (e.request.url.includes("/api/")) {
    e.respondWith(
      caches.open("api-v1").then(async cache => {
        const cached = await cache.match(e.request);
        const fresh = fetch(e.request).then(res => {
          cache.put(e.request, res.clone());
          return res;
        });
        return cached || fresh;
      })
    );
  }
});

7.6 失效策略

  • TTL:简单粗暴
  • 写后失效:mutation 成功后 cache.delete(key)
  • 标签失效revalidateTag('posts')(React Query / Next.js)
  • 版本号:URL 加 ?v=,新版本发布即缓存击穿

八、重试与退避

8.1 何时重试

错误重试?
网络错误(fetch 抛错)
408 / 429 / 502 / 503 / 504
5xx(其他)视情况
4xx(非 408/429)❌ 一般是请求本身有问题
已取消(AbortError)

8.2 指数退避 + 抖动

async function retry<T>(
  fn: () => Promise<T>,
  { retries = 3, baseDelay = 500, maxDelay = 10_000 } = {},
): Promise<T> {
  let lastErr: any;
  for (let i = 0; i <= retries; i++) {
    try {
      return await fn();
    } catch (e: any) {
      if (e.name === "AbortError" || !shouldRetry(e)) throw e;
      lastErr = e;
      if (i === retries) break;
      const delay = Math.min(maxDelay, baseDelay * 2 ** i);
      const jitter = delay * 0.5 * Math.random();
      await new Promise(r => setTimeout(r, delay + jitter));
    }
  }
  throw lastErr;
}

function shouldRetry(e: any) {
  if (!e.status) return true; // network
  return [408, 429, 502, 503, 504].includes(e.status);
}

必须加 jitter,否则失败时所有客户端同步退避,会在恢复瞬间又同时打爆服务。

8.3 尊重 Retry-After

const ra = response.headers.get("Retry-After");
const delay = ra ? parseInt(ra) * 1000 : computedDelay;

8.4 幂等性

  • GET / HEAD / PUT / DELETE 通常幂等,可放心重试
  • POST 不幂等:重试可能重复创建。带 Idempotency-Key 头:
fetch("/api/orders", {
  method: "POST",
  headers: { "Idempotency-Key": uuid() },
  body: JSON.stringify(order),
});

8.5 断路器(Circuit Breaker)

连续失败 N 次后短期内直接 fail-fast,避免雪崩:

class CircuitBreaker {
  private fails = 0;
  private openUntil = 0;

  constructor(private threshold = 5, private cooldown = 30_000) {}

  async exec<T>(fn: () => Promise<T>): Promise<T> {
    if (Date.now() < this.openUntil) throw new Error("circuit open");
    try {
      const r = await fn();
      this.fails = 0;
      return r;
    } catch (e) {
      if (++this.fails >= this.threshold) this.openUntil = Date.now() + this.cooldown;
      throw e;
    }
  }
}

九、超时控制

9.1 客户端超时

fetch(url, { signal: AbortSignal.timeout(5000) });

9.2 axios

axios.get(url, { timeout: 5000 });

9.3 注意

  • timeout 仅控制整个请求完成;不能控制"建连超时"和"读超时"分别设置(fetch 没暴露)
  • 大文件下载场景慎用整体超时,改用"无字节进度超时"

9.4 进度超时(长连接 / 大文件)

async function fetchWithIdleTimeout(url: string, idleMs = 10_000) {
  const ac = new AbortController();
  let timer: any;
  const reset = () => {
    clearTimeout(timer);
    timer = setTimeout(() => ac.abort(), idleMs);
  };

  const res = await fetch(url, { signal: ac.signal });
  reset();
  const reader = res.body!.getReader();
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    reset();
    // 处理 value
  }
  clearTimeout(timer);
}

十、竞态条件

10.1 经典 bug:搜索框

useEffect(() => {
  fetch(`/api/search?q=${query}`).then(r => r.json()).then(setResults);
}, [query]);

用户输入 "ab" → "abc" → "ab",三个请求并发,慢的覆盖快的,结果显示成"ab" 但用户已经输入"abc"。

10.2 解法 1:取消旧请求(首选)

useEffect(() => {
  const ac = new AbortController();
  fetch(`/api/search?q=${query}`, { signal: ac.signal })
    .then(r => r.json()).then(setResults)
    .catch(e => { if (e.name !== "AbortError") setError(e); });
  return () => ac.abort();
}, [query]);

10.3 解法 2:忽略过期响应

useEffect(() => {
  let cancelled = false;
  fetch(`/api/search?q=${query}`)
    .then(r => r.json())
    .then(d => { if (!cancelled) setResults(d); });
  return () => { cancelled = true; };
}, [query]);

10.4 解法 3:版本号

const reqId = useRef(0);

const search = (q: string) => {
  const my = ++reqId.current;
  fetch(`/api/search?q=${q}`).then(r => r.json()).then(d => {
    if (my === reqId.current) setResults(d);
  });
};

10.5 防抖与节流

import { debounce } from "lodash-es";

const debouncedSearch = useMemo(
  () => debounce((q: string) => actualSearch(q), 300),
  []
);
  • 防抖(debounce):停止输入 N ms 后才发
  • 节流(throttle):每 N ms 最多发一次

搜索建议适合 debounce;按钮防连击适合 throttle。


十一、批处理与 DataLoader

11.1 N+1 问题

// 一个列表渲染 100 条,每条独立拉详情
items.map(i => fetch(`/api/item/${i.id}`));

11.2 batch endpoint

fetch("/api/items/batch", {
  method: "POST",
  body: JSON.stringify({ ids: [1, 2, 3, ...] }),
});

11.3 DataLoader 模式

把多次同步发起的请求合并成一次批量请求:

class DataLoader<K, V> {
  private queue: { key: K; resolve: (v: V) => void; reject: (e: any) => void }[] = [];
  private scheduled = false;

  constructor(private batchFn: (keys: K[]) => Promise<V[]>) {}

  load(key: K): Promise<V> {
    return new Promise((resolve, reject) => {
      this.queue.push({ key, resolve, reject });
      if (!this.scheduled) {
        this.scheduled = true;
        queueMicrotask(() => this.flush());
      }
    });
  }

  private async flush() {
    const batch = this.queue;
    this.queue = [];
    this.scheduled = false;
    try {
      const results = await this.batchFn(batch.map(b => b.key));
      batch.forEach((b, i) => b.resolve(results[i]));
    } catch (e) {
      batch.forEach(b => b.reject(e));
    }
  }
}

const userLoader = new DataLoader<string, User>(ids =>
  fetch(`/api/users?ids=${ids.join(",")}`).then(r => r.json())
);

// 在不同组件并发调用
userLoader.load("1");
userLoader.load("2");
userLoader.load("3");
// 实际一个微任务后只发一次 GET /api/users?ids=1,2,3

可以直接用 dataloader 库。

11.4 GraphQL

天然合并请求,单次请求拿走整页数据。配合 Apollo / urql 还能自动 dedupe + cache。


十二、流式与分块响应

12.1 ReadableStream

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

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const text = decoder.decode(value, { stream: true });
  console.log(text);
}

12.2 SSE(Server-Sent Events)

const es = new EventSource("/api/events");
es.onmessage = e => console.log(e.data);
es.onerror = e => console.error(e);
es.close();

⚠️ EventSource 不支持自定义 header / POST。常用替代:用 fetch + 手解析 SSE:

async function* sseFetch(url: string, init?: RequestInit) {
  const res = await fetch(url, init);
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    let idx;
    while ((idx = buf.indexOf("\n\n")) !== -1) {
      const event = buf.slice(0, idx);
      buf = buf.slice(idx + 2);
      const data = event.split("\n").filter(l => l.startsWith("data:"))
                       .map(l => l.slice(5).trim()).join("\n");
      if (data) yield data;
    }
  }
}

for await (const chunk of sseFetch("/api/chat", { method: "POST", body: ... })) {
  appendToUI(chunk);
}

12.3 NDJSON 流

async function* ndjson(res: Response) {
  const reader = res.body!.getReader();
  const decoder = new TextDecoder();
  let buf = "";
  while (true) {
    const { done, value } = await reader.read();
    if (done) {
      if (buf.trim()) yield JSON.parse(buf);
      return;
    }
    buf += decoder.decode(value, { stream: true });
    let nl;
    while ((nl = buf.indexOf("\n")) !== -1) {
      const line = buf.slice(0, nl).trim();
      buf = buf.slice(nl + 1);
      if (line) yield JSON.parse(line);
    }
  }
}

12.4 背压

UI 渲染速度 < 流入速度时会卡顿。用 requestAnimationFrame / 队列消费:

const q: string[] = [];
let scheduled = false;

function flush() {
  scheduled = false;
  if (!q.length) return;
  appendToUI(q.splice(0).join(""));
}

for await (const chunk of stream) {
  q.push(chunk);
  if (!scheduled) {
    scheduled = true;
    requestAnimationFrame(flush);
  }
}

十三、上传并发与分片

13.1 多文件并发上传

import pLimit from "p-limit";

async function uploadAll(files: File[]) {
  const limit = pLimit(3);
  return Promise.all(files.map(f =>
    limit(() => uploadOne(f))
  ));
}

13.2 单大文件分片

async function uploadChunked(file: File, chunkSize = 5 * 1024 * 1024) {
  const total = Math.ceil(file.size / chunkSize);
  const limit = pLimit(4);

  await Promise.all(
    Array.from({ length: total }, (_, i) =>
      limit(async () => {
        const start = i * chunkSize;
        const blob = file.slice(start, start + chunkSize);
        const fd = new FormData();
        fd.append("chunk", blob);
        fd.append("index", String(i));
        fd.append("total", String(total));
        fd.append("file_id", file.name);
        await retry(() => fetch("/api/upload/chunk", { method: "POST", body: fd }));
      })
    )
  );

  await fetch("/api/upload/complete", {
    method: "POST",
    body: JSON.stringify({ file_id: file.name }),
  });
}

13.3 进度

function uploadWithProgress(file: File, onProgress: (p: number) => void) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.upload.onprogress = e => {
      if (e.lengthComputable) onProgress(e.loaded / e.total);
    };
    xhr.onload = () => xhr.status < 400 ? resolve(xhr.response) : reject();
    xhr.onerror = reject;
    xhr.open("POST", "/api/upload");
    xhr.send(file);
  });
}

⚠️ fetch 目前没有上传进度(download 有)。需要进度仍用 XHR 或 axios。

13.4 断点续传

服务端先返回已上传的分片:

const { uploaded } = await fetch(`/api/upload/status?id=${fileId}`).then(r => r.json());
const remaining = allChunks.filter(c => !uploaded.includes(c.index));
// 仅上传 remaining

十四、轮询、长连接、SSE、WebSocket

14.1 选型

场景推荐
偶尔检查更新(< 1/min)短轮询
需要实时通知 + 单向SSE
双向、低延迟WebSocket
AI 流式响应SSE / fetch streaming
协议层无法用 WS长轮询

14.2 智能轮询

可见性 + 退避 + 失败容错:

function smartPoll(fn: () => Promise<void>, baseInterval = 5000) {
  let interval = baseInterval;
  let stopped = false;

  async function loop() {
    while (!stopped) {
      if (document.visibilityState === "visible") {
        try {
          await fn();
          interval = baseInterval;
        } catch {
          interval = Math.min(interval * 2, 60_000);
        }
      }
      await new Promise(r => setTimeout(r, interval));
    }
  }

  loop();
  return () => { stopped = true; };
}

14.3 WebSocket 重连

class ReconnectingWS {
  private ws?: WebSocket;
  private retries = 0;
  private closed = false;

  constructor(private url: string, private onMsg: (m: any) => void) {
    this.connect();
  }

  private connect() {
    this.ws = new WebSocket(this.url);
    this.ws.onmessage = e => this.onMsg(JSON.parse(e.data));
    this.ws.onopen = () => { this.retries = 0; };
    this.ws.onclose = () => {
      if (this.closed) return;
      const delay = Math.min(30_000, 1000 * 2 ** this.retries++) * (0.5 + Math.random());
      setTimeout(() => this.connect(), delay);
    };
  }

  send(data: any) { this.ws?.send(JSON.stringify(data)); }
  close() { this.closed = true; this.ws?.close(); }
}

14.4 单连接复用

跨组件共享一个 WS,避免每个组件各开一个:用 zustand / 单例 + 订阅模型。


十五、与 React Query / SWR 集成

15.1 React Query

import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";

function User({ id }: { id: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ["user", id],
    queryFn: ({ signal }) => fetch(`/api/user/${id}`, { signal }).then(r => r.json()),
    staleTime: 60_000,
    retry: 3,
    retryDelay: i => Math.min(30_000, 1000 * 2 ** i),
  });
  // ...
}

天然内置:去重 / 缓存 / 重试 / 取消(signal)/ 后台刷新 / 焦点重新获取。

15.1.1 并发查询

const results = useQueries({
  queries: ids.map(id => ({
    queryKey: ["user", id],
    queryFn: () => fetchUser(id),
  })),
});

15.1.2 失效与乐观更新

const qc = useQueryClient();
const mut = useMutation({
  mutationFn: updatePost,
  onMutate: async (next) => {
    await qc.cancelQueries({ queryKey: ["post", next.id] });
    const prev = qc.getQueryData(["post", next.id]);
    qc.setQueryData(["post", next.id], next);   // 乐观更新
    return { prev };
  },
  onError: (_, next, ctx) => qc.setQueryData(["post", next.id], ctx?.prev),
  onSettled: (_, __, vars) => qc.invalidateQueries({ queryKey: ["post", vars.id] }),
});

15.2 SWR

import useSWR from "swr";

const { data, error, isLoading, mutate } = useSWR(
  `/api/user/${id}`,
  url => fetch(url).then(r => r.json()),
  { dedupingInterval: 2000, revalidateOnFocus: true },
);

15.3 选用建议

React QuerySWR
体量较大、功能多轻量
Mutation一等公民较弱
离线/断线支持完善基本
文档详尽简洁

数据访问复杂的应用 → React Query;快速搭建/简单需求 → SWR。


十六、性能监控

16.1 Performance API

const entries = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
const apiCalls = entries.filter(e => e.initiatorType === "fetch");

apiCalls.forEach(e => {
  console.log({
    url: e.name,
    duration: e.duration,
    ttfb: e.responseStart - e.requestStart,
    download: e.responseEnd - e.responseStart,
    blocked: e.requestStart - e.startTime,
  });
});

16.2 包装 fetch 自动上报

const origFetch = window.fetch;
window.fetch = async (...args) => {
  const t0 = performance.now();
  try {
    const res = await origFetch(...args);
    report({ url: args[0], status: res.status, dur: performance.now() - t0 });
    return res;
  } catch (e) {
    report({ url: args[0], error: String(e), dur: performance.now() - t0 });
    throw e;
  }
};

16.3 错误上报维度

  • URL / method
  • status / 错误类型
  • 持续时间(split:建连/TTFB/下载)
  • 重试次数
  • 用户网络(navigator.connection.effectiveType
  • 页面可见性
  • 用户代理 / 版本

16.4 用户体验指标

  • LCP / FID / CLS(核心 web vitals)
  • 首屏 API 数量与总耗时
  • 长任务(PerformanceLongTaskTiming
  • 慢请求 p95 / p99

十七、常见问题

17.1 大量请求让页面卡顿

  • 用并发池限制 6-8 个
  • 拆分到 requestIdleCallback 中处理
  • 把非首屏请求延后

17.2 切页面后请求还在跑

useEffect(() => {
  const ac = new AbortController();
  fetch("...", { signal: ac.signal });
  return () => ac.abort();
}, []);

17.3 Promise.all 一个失败全失败

改用 Promise.allSettled 或包裹 catch:

const safe = (p: Promise<any>) => p.catch(e => ({ error: e }));
const rs = await Promise.all(promises.map(safe));

17.4 跨域 + 携带 cookie

fetch(url, { credentials: "include" });
// 服务端必须 Access-Control-Allow-Credentials: true 且 Origin 不能为 *

17.5 OPTIONS 预检过多

  • 让请求成为"简单请求"(GET/POST、内容类型 form/text、无自定义 header)
  • 服务端响应 Access-Control-Max-Age: 86400 缓存预检

17.6 组件快速切换造成请求轰炸

  • debounce 输入
  • React Query 自带 staleTime 减少重复请求
  • 在路由切换前 cancel

17.7 同时上传多个大文件浏览器卡死

  • 限制并发到 2-3
  • 主线程不要做 hash / 压缩,扔到 Worker
  • 用 stream API 不要 readAsArrayBuffer 整文件入内存

17.8 fetch 没有上传进度

用 XHR 或 axios。或服务端实现"分片上传",每个分片 fetch 完成即视为进度。

17.9 SSE 连接被代理截断

  • 关闭代理 buffering:nginx proxy_buffering off;
  • 周期发送注释行 : keep-alive\n\n

17.10 内存泄漏

  • 取消所有未完成请求
  • 清理 EventSource / WebSocket
  • React 组件 unmount 后不要 setState(用 ac.abort + try/catch AbortError)

十八、完整示例:HTTP 客户端

一个生产级 fetch 封装,包含:dedupe + cache + retry + timeout + cancel + 拦截器 + 进度。

// http.ts
type Options = RequestInit & {
  timeout?: number;
  retries?: number;
  cache?: { ttl: number; key?: string };
  dedupe?: boolean;
  signal?: AbortSignal;
};

class HttpError extends Error {
  constructor(public status: number, public url: string, public body: any) {
    super(`HTTP ${status} ${url}`);
  }
}

const inflight = new Map<string, Promise<any>>();
const cache = new Map<string, { value: any; expires: number }>();

const reqInterceptors: ((url: string, init: Options) => [string, Options])[] = [];
const resInterceptors: ((res: any, ctx: { url: string }) => any)[] = [];

export const http = {
  useRequest(fn: typeof reqInterceptors[number]) { reqInterceptors.push(fn); },
  useResponse(fn: typeof resInterceptors[number]) { resInterceptors.push(fn); },

  async request<T>(url: string, init: Options = {}): Promise<T> {
    // 1. 拦截器(鉴权、签名等)
    for (const f of reqInterceptors) [url, init] = f(url, init);

    const key = init.cache?.key ?? `${init.method ?? "GET"} ${url} ${
      init.body ? JSON.stringify(init.body) : ""
    }`;

    // 2. 缓存命中
    if (init.cache) {
      const e = cache.get(key);
      if (e && e.expires > Date.now()) return e.value;
    }

    // 3. 去重
    if (init.dedupe !== false && inflight.has(key)) return inflight.get(key)!;

    // 4. 组合 signal(外部 + 超时)
    const signals: AbortSignal[] = [];
    if (init.signal) signals.push(init.signal);
    if (init.timeout) signals.push(AbortSignal.timeout(init.timeout));
    const signal = signals.length ? AbortSignal.any(signals) : undefined;

    const exec = async (): Promise<T> => {
      let lastErr: any;
      const retries = init.retries ?? 2;
      for (let i = 0; i <= retries; i++) {
        try {
          const res = await fetch(url, { ...init, signal });
          if (!res.ok) {
            const body = await res.text().catch(() => "");
            throw new HttpError(res.status, url, body);
          }
          const ct = res.headers.get("content-type") ?? "";
          let data: any = ct.includes("json") ? await res.json() : await res.text();
          for (const f of resInterceptors) data = f(data, { url });

          if (init.cache) cache.set(key, {
            value: data, expires: Date.now() + init.cache.ttl,
          });
          return data;
        } catch (e: any) {
          if (e.name === "AbortError") throw e;
          lastErr = e;
          if (i === retries || !shouldRetry(e)) break;
          const delay = Math.min(10_000, 500 * 2 ** i) * (0.5 + Math.random());
          await new Promise(r => setTimeout(r, delay));
        }
      }
      throw lastErr;
    };

    const p = exec().finally(() => inflight.delete(key));
    inflight.set(key, p);
    return p;
  },

  get<T>(url: string, opts?: Options) {
    return this.request<T>(url, { ...opts, method: "GET" });
  },
  post<T>(url: string, body?: any, opts?: Options) {
    return this.request<T>(url, {
      ...opts, method: "POST",
      headers: { "Content-Type": "application/json", ...opts?.headers },
      body: JSON.stringify(body),
    });
  },
  put<T>(url: string, body?: any, opts?: Options) {
    return this.request<T>(url, {
      ...opts, method: "PUT",
      headers: { "Content-Type": "application/json", ...opts?.headers },
      body: JSON.stringify(body),
    });
  },
  del<T>(url: string, opts?: Options) {
    return this.request<T>(url, { ...opts, method: "DELETE" });
  },
};

function shouldRetry(e: any) {
  if (e instanceof HttpError) return [408, 429, 502, 503, 504].includes(e.status);
  return true;
}

// 使用:
http.useRequest((url, init) => {
  init.headers = { ...init.headers, Authorization: `Bearer ${getToken()}` };
  return [url, init];
});

http.useResponse((data) => {
  if (data?.code !== 0) throw new Error(data?.message ?? "biz error");
  return data.data;
});

// 应用代码
const user = await http.get<User>("/api/user/1", { cache: { ttl: 60_000 } });
const list = await http.get<Item[]>("/api/items", { timeout: 5000, retries: 3 });

18.1 React Hook 包装

function useRequest<T>(url: string, opts?: Options) {
  const [state, setState] = useState<{ data?: T; loading: boolean; error?: Error }>({
    loading: true,
  });

  useEffect(() => {
    const ac = new AbortController();
    setState(s => ({ ...s, loading: true, error: undefined }));
    http.request<T>(url, { ...opts, signal: ac.signal })
      .then(data => setState({ data, loading: false }))
      .catch(error => {
        if (error.name === "AbortError") return;
        setState({ loading: false, error });
      });
    return () => ac.abort();
  }, [url]);

  return state;
}

附录:速查

A.1 决策表

需求
多请求全部成功Promise.all
多请求各看结果Promise.allSettled
取最快Promise.race
取首个成功Promise.any
取消请求AbortController
限流p-limit / p-queue
去重inflight Map
重试exponential backoff + jitter
缓存TTL Map / SWR
防竞态abort 旧请求 / 版本号
批量合并DataLoader / GraphQL
数据层React Query / SWR

A.2 推荐库

用途
HTTP 客户端axios / ky / wretch
限流p-limit / p-queue
重试p-retry / async-retry
数据获取@tanstack/react-query / swr
批合并dataloader
GraphQLapollo / urql
WebSocketsocket.io-client / native + 重连封装
进度上传axios / @uppy/core
节流防抖lodash-es / use-debounce

A.3 调试工具

  • Chrome DevTools → Network → 过滤 fetch/XHR
  • Network → Throttling 模拟弱网
  • Performance → 抓主线程长任务
  • React Query Devtools / SWR Devtools

A.4 黄金法则

  1. 不要无脑 Promise.all 一切:限并发 6-8
  2. 每个请求都要能取消:AbortController
  3. 每个请求都要超时:5-30s
  4. 状态变化场景必须防竞态:搜索、过滤、分页
  5. 重试必加 jitter:防雪崩
  6. 用 React Query / SWR:自己造轮子省下的时间不如用现成的