目录
- 一、为什么需要关心并发请求
- 二、浏览器并发限制
- 三、原生并发原语
- 四、AbortController 与请求取消
- 五、并发控制:限流与队列
- 六、请求去重与合并
- 七、缓存策略
- 八、重试与退避
- 九、超时控制
- 十、竞态条件
- 十一、批处理与 DataLoader
- 十二、流式与分块响应
- 十三、上传并发与分片
- 十四、轮询、长连接、SSE、WebSocket
- 十五、与 React Query / SWR 集成
- 十六、性能监控
- 十七、常见问题
- 十八、完整示例:HTTP 客户端
一、为什么需要关心并发请求
前端"同时"发出多个请求时常见问题:
- 页面卡顿:上百个并发挤占主线程 + 网络
- 服务端被打垮:未限速时一次刷新打几十个 API
- 数据错乱:先发的请求后到,覆盖了新数据(race condition)
- 重复请求:组件多次挂载/快速切换都各发一遍
- 资源泄漏:组件卸载后请求仍在跑、回调访问已 unmounted state
- 失败传染:一个请求错误连带整个页面挂掉
并发请求工程的目标:让该并行的并行,该串行的串行,该取消的取消,该缓存的缓存。
二、浏览器并发限制
2.1 同源连接数
| 协议 | 浏览器(典型) | 上限 |
|---|---|---|
| HTTP/1.1 同域 | Chrome / Firefox / Safari | 6 |
| 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.com、api2.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()只是通知,服务端可能已经处理完- 取消后
fetch抛AbortError,要单独处理避免误报错 - 取消的请求不计入浏览器并发上限
五、并发控制:限流与队列
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 Query | SWR | |
|---|---|---|
| 体量 | 较大、功能多 | 轻量 |
| 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 |
| GraphQL | apollo / urql |
| WebSocket | socket.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 黄金法则
- 不要无脑 Promise.all 一切:限并发 6-8
- 每个请求都要能取消:AbortController
- 每个请求都要超时:5-30s
- 状态变化场景必须防竞态:搜索、过滤、分页
- 重试必加 jitter:防雪崩
- 用 React Query / SWR:自己造轮子省下的时间不如用现成的