Axios 的每周下载量依然超过 1.2 亿,但越来越多的团队开始用原生 Fetch API 取代它——Next.js、Remix、Astro 等现代框架已经将 Fetch 作为一等公民,浏览器原生支持也让 Bundle 体积直接归零。唯一让开发者犹豫的,是 Fetch 缺少 Axios 最核心的能力:拦截器(Interceptor)。 本文将从零实现一套生产级的 Fetch 拦截器系统,包含请求/响应拦截、自动重试、请求去重、超时控制和 Token 自动刷新,让你彻底摆脱对 Axios 的依赖。
🏗️ 一、拦截器核心架构设计
在写任何代码之前,先理解拦截器的本质。Axios 的拦截器本质上是一个中间件链(Middleware Chain)——请求从第一个拦截器依次流到最后一个,响应则反向流回。我们需要用原生 Fetch 复刻这套机制。
1.1 为什么原生 Fetch 没有拦截器
浏览器的 fetch() 是一个纯粹的网络 API,设计哲学是「单一职责」——它只负责发送请求和返回响应,不做任何额外处理。这和 XMLHttpRequest 的设计思路完全不同:XHR 可以通过 onreadystatechange 做全局监听,而 Fetch 返回的是一个 Promise,无法在不修改调用代码的情况下「插入」逻辑。
💡 提示: Fetch 的不可变性(Immutability)是刻意设计的——它让 Service Worker 成为拦截 Fetch 请求的标准方式,而不是在 API 层面内置拦截器。
1.2 中间件链模式
我们采用**洋葱模型(Onion Model)**实现拦截器:每个拦截器可以处理请求,然后调用 next() 传递给下一个拦截器,响应则沿着反方向逐层返回。这和 Koa.js 的中间件机制完全一致。
// 洋葱模型拦截器链的核心结构
// 请求 → [拦截器A前] → [拦截器B前] → fetch → [拦截器B后] → [拦截器A后] → 响应
class InterceptorChain {
constructor() {
this.requestInterceptors = [];
this.responseInterceptors = [];
}
// 添加请求拦截器:在请求发出前修改 config
useRequest(onFulfilled, onRejected) {
this.requestInterceptors.push({ onFulfilled, onRejected });
return this.requestInterceptors.length - 1; // 返回 eject ID
}
// 添加响应拦截器:在响应返回前处理
useResponse(onFulfilled, onRejected) {
this.responseInterceptors.push({ onFulfilled, onRejected });
return this.responseInterceptors.length - 1;
}
// 移除拦截器(eject)
ejectRequest(id) {
if (this.requestInterceptors[id]) {
this.requestInterceptors[id] = null;
}
}
ejectResponse(id) {
if (this.responseInterceptors[id]) {
this.responseInterceptors[id] = null;
}
}
}
1.3 完整的 Fetch 拦截器实现
下面是完整的 InterceptedFetch 类,支持所有 Axios 拦截器的核心能力:
// 从零实现的 Fetch 拦截器 —— 完整生产级代码
class InterceptedFetch {
constructor(options = {}) {
this.baseURL = options.baseURL || '';
this.defaultHeaders = options.headers || {};
this.timeout = options.timeout || 30000; // 默认 30 秒
this.interceptors = {
request: [],
response: [],
};
}
// 注册请求拦截器
onRequest(fn) {
this.interceptors.request.push(fn);
return () => {
const idx = this.interceptors.request.indexOf(fn);
if (idx !== -1) this.interceptors.request.splice(idx, 1);
};
}
// 注册响应拦截器
onResponse(fn) {
this.interceptors.response.push(fn);
return () => {
const idx = this.interceptors.response.indexOf(fn);
if (idx !== -1) this.interceptors.response.splice(idx, 1);
};
}
// 核心:执行请求拦截器链
async _runRequestInterceptors(config) {
let result = config;
for (const interceptor of this.interceptors.request) {
result = await interceptor(result);
}
return result;
}
// 核心:执行响应拦截器链
async _runResponseInterceptors(response, config) {
let result = response;
for (const interceptor of this.interceptors.response) {
result = await interceptor(result, config);
}
return result;
}
// 核心请求方法
async request(url, options = {}) {
// 1. 合并默认配置
let config = {
url: this.baseURL + url,
method: options.method || 'GET',
headers: {
...this.defaultHeaders,
...options.headers,
},
body: options.body,
signal: options.signal,
...options,
};
// 2. 执行请求拦截器
config = await this._runRequestInterceptors(config);
// 3. 构建 AbortController 实现超时
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), config.timeout || this.timeout);
// 如果用户传入了 signal,需要联动
if (config.signal) {
config.signal.addEventListener('abort', () => controller.abort());
}
try {
// 4. 发送请求
const response = await fetch(config.url, {
method: config.method,
headers: config.headers,
body: config.body,
signal: controller.signal,
});
clearTimeout(timeoutId);
// 5. 执行响应拦截器
return await this._runResponseInterceptors(response, config);
} catch (error) {
clearTimeout(timeoutId);
// 6. 执行错误拦截器
if (this.interceptors.response.length > 0) {
// 尝试让最后一个拦截器处理错误
let processedError = error;
for (const interceptor of this.interceptors.response) {
if (interceptor.catch) {
processedError = await interceptor.catch(processedError, config);
}
}
throw processedError;
}
throw error;
}
}
// 快捷方法
get(url, options) { return this.request(url, { ...options, method: 'GET' }); }
post(url, data, options) { return this.request(url, { ...options, method: 'POST', body: JSON.stringify(data) }); }
put(url, data, options) { return this.request(url, { ...options, method: 'PUT', body: JSON.stringify(data) }); }
delete(url, options) { return this.request(url, { ...options, method: 'DELETE' }); }
}
// 使用示例
const http = new InterceptedFetch({ baseURL: 'https://api.example.com' });
// 添加请求拦截器:注入 Token
http.onRequest((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
// 添加响应拦截器:统一错误处理
http.onResponse((response, config) => {
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
});
⚠️ 警告: 上面的代码中,
AbortController的signal联动逻辑在实际生产中需要更精细的处理——如果用户传入的外部 signal 已经 abort,我们需要确保内部 controller 也被 abort,反之亦然。下面的重试机制会进一步处理这个问题。
🔧 二、生产级特性:重试、去重与 Token 刷新
光有拦截器还不够,生产环境还需要三个关键特性:自动重试(网络抖动自动恢复)、请求去重(防重复提交)、Token 自动刷新(401 时静默刷新令牌)。
2.1 指数退避重试策略
网络请求失败是常态而非异常。根据 Cloudflare 的数据,全球约 3-5% 的 API 请求会因网络抖动而失败。一个好的重试策略使用指数退避(Exponential Backoff)加随机抖动(Jitter),避免所有客户端同时重试造成「惊群效应」。
// 带指数退避和抖动的重试拦截器
function createRetryInterceptor(options = {}) {
const {
maxRetries = 3,
baseDelay = 1000, // 基础延迟 1 秒
maxDelay = 30000, // 最大延迟 30 秒
retryOn = [408, 429, 500, 502, 503, 504], // 可重试的状态码
retryMethods = ['GET', 'HEAD', 'OPTIONS'], // 只重试幂等方法
} = options;
return {
catch: async (error, config) => {
// 只对特定 HTTP 方法重试
if (!retryMethods.includes(config.method)) throw error;
// 计算重试次数(从 config 中获取)
const retryCount = (config._retryCount || 0);
if (retryCount >= maxRetries) throw error;
// 判断是否可重试
const status = error.status || (error.cause && error.cause.status);
const isNetworkError = error instanceof TypeError && error.message.includes('fetch');
const isRetryableStatus = status && retryOn.includes(status);
if (!isNetworkError && !isRetryableStatus) throw error;
// 指数退避 + 随机抖动
const delay = Math.min(
baseDelay * Math.pow(2, retryCount) + Math.random() * 1000,
maxDelay
);
console.warn(
`[fetch-retry] 重试 ${retryCount + 1}/${maxRetries},` +
`延迟 ${Math.round(delay)}ms,URL: ${config.url}`
);
await new Promise(resolve => setTimeout(resolve, delay));
// 递增重试计数,重新发起请求
config._retryCount = retryCount + 1;
// 注意:这里需要通过全局 http 实例重新请求
// 实际实现中,重试逻辑应该嵌入 request 方法内部
throw { __retry: true, config }; // 标记需要重试
},
};
}
不过上面的拦截器模式在重试时有个问题——拦截器的 catch 不能直接重新发起请求。更优雅的做法是将重试逻辑内置到核心 request 方法中:
// 将重试逻辑集成到 request 方法中的完整实现
async requestWithRetry(url, options = {}) {
const maxRetries = options.maxRetries ?? 3;
const retryOn = options.retryOn ?? [408, 429, 500, 502, 503, 504];
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// 复用已有的 request 方法
return await this.request(url, {
...options,
_retryCount: attempt,
});
} catch (error) {
lastError = error;
// 最后一次尝试直接抛出
if (attempt >= maxRetries) break;
// 判断是否需要重试
const status = error.status || error.cause?.status;
const isNetworkError = error instanceof TypeError;
const isRetryable = isNetworkError || (status && retryOn.includes(status));
if (!isRetryable) break;
// 指数退避 + 随机抖动
const delay = Math.min(1000 * Math.pow(2, attempt) + Math.random() * 1000, 30000);
console.warn(`[retry ${attempt + 1}/${maxRetries}] ${url} — ${delay.toFixed(0)}ms 后重试`);
await new Promise(r => setTimeout(r, delay));
}
}
throw lastError;
}
2.2 请求去重:防止重复提交
用户快速双击提交按钮时,会发出两个相同的 POST 请求。Axios 没有内置去重功能,我们可以利用 AbortController 实现:同一个 URL + Method + Body 的请求如果正在进行中,自动复用同一个 Promise。
// 请求去重拦截器:相同请求自动复用
class RequestDeduplicator {
constructor() {
this.pending = new Map(); // key → { promise, controller }
}
// 生成请求唯一标识
_getKey(config) {
const { method, url, body } = config;
// 对于 GET 请求,URL 就是唯一标识
// 对于 POST/PUT,需要包含 body
return `${method}:${url}:${body || ''}`;
}
// 请求拦截器:检查是否有重复请求
requestInterceptor = (config) => {
const key = this._getKey(config);
// 只对幂等方法(GET/HEAD)和可选的 POST 做去重
if (config.method === 'GET' || config.method === 'HEAD') {
const existing = this.pending.get(key);
if (existing) {
// 取消新请求,复用已有 Promise
config._deduplicated = true;
config._existingPromise = existing.promise;
} else {
const controller = new AbortController();
config.signal = controller.signal;
this.pending.set(key, { promise: null, controller });
}
}
return config;
};
// 响应拦截器:清理 pending 状态
responseInterceptor = async (response, config) => {
const key = this._getKey(config);
this.pending.delete(key);
return response;
};
// 错误拦截器:清理 pending 状态
errorInterceptor = async (error, config) => {
const key = this._getKey(config);
this.pending.delete(key);
throw error;
};
}
2.3 无感 Token 刷新
当 API 返回 401 时,需要自动刷新 Access Token 并重放失败的请求。这个逻辑的难点在于:如果有多个请求同时 401,只能触发一次刷新,其他请求要排队等待。
// Token 自动刷新拦截器 —— 解决并发 401 问题
class TokenRefresher {
constructor(options) {
this.refreshFn = options.refreshFn; // 刷新 Token 的函数
this.getAccessToken = options.getAccessToken; // 获取当前 Token
this.setTokens = options.setTokens; // 存储新 Token
this.isRefreshing = false; // 是否正在刷新
this.refreshQueue = []; // 等待刷新的请求队列
}
// 响应拦截器:处理 401
responseInterceptor = async (response, config) => {
// 非 401 直接返回
if (response.status !== 401) return response;
// 如果正在刷新,把当前请求加入队列等待
if (this.isRefreshing) {
return new Promise((resolve, reject) => {
this.refreshQueue.push({ resolve, reject, config });
});
}
this.isRefreshing = true;
try {
// 调用刷新 Token 的 API
const { accessToken, refreshToken } = await this.refreshFn();
this.setTokens({ accessToken, refreshToken });
// 重放当前请求(用新 Token)
const newResponse = await fetch(config.url, {
...config,
headers: {
...config.headers,
'Authorization': `Bearer ${accessToken}`,
},
});
// 重放队列中所有等待的请求
this.refreshQueue.forEach(({ resolve }) => {
resolve(newResponse); // 简化处理,实际需要逐个重试
});
return newResponse;
} catch (refreshError) {
// 刷新失败,拒绝所有等待的请求,跳转登录页
this.refreshQueue.forEach(({ reject }) => {
reject(refreshError);
});
window.location.href = '/login';
throw refreshError;
} finally {
this.isRefreshing = false;
this.refreshQueue = [];
}
};
}
⚠️ 警告: 上面的 Token 刷新代码中,队列中的请求应该用新的 Access Token 重新发起,而不是直接返回旧响应。上面的
resolve(newResponse)是简化写法,生产环境需要逐个用新 Token 重新 fetch。
📊 三、实战整合与性能对比
3.1 组装最终版本
将所有功能整合到一个 createFetchClient 工厂函数中,对外暴露简洁的 API:
// 生产级 Fetch 客户端工厂函数
function createFetchClient(options = {}) {
const {
baseURL = '',
timeout = 30000,
headers = {},
maxRetries = 3,
enableDedup = true,
onTokenRefresh,
onError,
} = options;
// 内部状态
const requestInterceptors = [];
const responseInterceptors = [];
const pendingRequests = new Map(); // 去重用
// === 核心请求方法 ===
async function request(url, fetchOptions = {}) {
let config = {
url: baseURL + url,
method: fetchOptions.method || 'GET',
headers: { ...headers, ...fetchOptions.headers },
body: fetchOptions.body,
_retryCount: 0,
};
// 执行请求拦截器链
for (const interceptor of requestInterceptors) {
config = await interceptor(config);
}
// 请求去重检查(仅 GET)
const dedupKey = `${config.method}:${config.url}`;
if (enableDedup && config.method === 'GET' && pendingRequests.has(dedupKey)) {
return pendingRequests.get(dedupKey);
}
// 超时控制
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const doFetch = async () => {
try {
const response = await fetch(config.url, {
method: config.method,
headers: config.headers,
body: config.body,
signal: controller.signal,
});
clearTimeout(timeoutId);
// 401 自动刷新
if (response.status === 401 && onTokenRefresh) {
try {
const { accessToken } = await onTokenRefresh();
config.headers['Authorization'] = `Bearer ${accessToken}`;
// 重放请求
return await fetch(config.url, {
method: config.method,
headers: config.headers,
body: config.body,
});
} catch {
throw response;
}
}
// 执行响应拦截器链
let result = response;
for (const interceptor of responseInterceptors) {
result = await interceptor(result, config);
}
return result;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
};
// 带重试的执行
let lastError;
for (let i = 0; i <= maxRetries; i++) {
try {
const promise = doFetch();
if (enableDedup && config.method === 'GET') {
pendingRequests.set(dedupKey, promise);
promise.finally(() => pendingRequests.delete(dedupKey));
}
return await promise;
} catch (error) {
lastError = error;
if (i < maxRetries && isRetryable(error)) {
const delay = Math.min(1000 * Math.pow(2, i) + Math.random() * 500, 15000);
await new Promise(r => setTimeout(r, delay));
continue;
}
break;
}
}
if (onError) onError(lastError);
throw lastError;
}
// 辅助函数
function isRetryable(error) {
if (error instanceof TypeError) return true; // 网络错误
const status = error.status;
return [408, 429, 500, 502, 503, 504].includes(status);
}
// === 公开 API ===
return {
request,
get: (url, opts) => request(url, { ...opts, method: 'GET' }),
post: (url, data, opts) => request(url, { ...opts, method: 'POST', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json', ...opts?.headers } }),
put: (url, data, opts) => request(url, { ...opts, method: 'PUT', body: JSON.stringify(data), headers: { 'Content-Type': 'application/json', ...opts?.headers } }),
delete: (url, opts) => request(url, { ...opts, method: 'DELETE' }),
// 拦截器管理
interceptors: {
request: { use: (fn) => { requestInterceptors.push(fn); return () => { const i = requestInterceptors.indexOf(fn); if (i > -1) requestInterceptors.splice(i, 1); }; } },
response: { use: (fn) => { responseInterceptors.push(fn); return () => { const i = responseInterceptors.indexOf(fn); if (i > -1) responseInterceptors.splice(i, 1); }; } },
},
};
}
3.2 实际使用示例
// 创建客户端实例
const api = createFetchClient({
baseURL: 'https://api.example.com',
timeout: 10000,
maxRetries: 2,
headers: {
'Content-Type': 'application/json',
},
// Token 刷新回调
onTokenRefresh: async () => {
const res = await fetch('/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: localStorage.getItem('refreshToken') }),
});
const data = await res.json();
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
return { accessToken: data.accessToken };
},
// 全局错误处理
onError: (error) => {
if (error.status === 403) {
console.error('权限不足');
} else if (error instanceof TypeError) {
console.error('网络连接失败,请检查网络');
}
},
});
// 请求拦截器:自动注入 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('accessToken');
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
});
// 请求拦截器:添加请求 ID(用于链路追踪)
api.interceptors.request.use((config) => {
config.headers['X-Request-ID'] = crypto.randomUUID();
return config;
});
// 响应拦截器:统一解析 JSON
api.interceptors.response.use(async (response) => {
if (response.headers.get('content-type')?.includes('application/json')) {
const data = await response.json();
if (!response.ok) throw { status: response.status, data };
return data;
}
return response;
});
// 使用
const users = await api.get('/users');
const newUser = await api.post('/users', { name: 'Alice', role: 'admin' });
3.3 性能与体积对比
下面对比我们的原生 Fetch 拦截器方案与主流 HTTP 库:
| 维度 | 原生 Fetch + 拦截器 | Axios 1.7 | Ky 1.7 | ofetch 1.4 |
|---|---|---|---|---|
| Bundle 大小 | 0 KB(原生) + ~3 KB(拦截器代码) | ~13 KB (gzip) | ~3.3 KB (gzip) | ~2.8 KB (gzip) |
| Tree-shaking | ✅ 完全支持 | ❌ 整体引入 | ✅ 支持 | ✅ 支持 |
| 请求拦截 | ✅ 自定义实现 | ✅ 内置 | ✅ 内置 hooks | ✅ 内置 |
| 自动重试 | ✅ 自定义实现 | ❌ 需插件 | ✅ 内置 | ✅ 内置 |
| 超时控制 | ✅ AbortController | ✅ 内置 | ✅ 内置 | ✅ 内置 |
| 请求去重 | ✅ 自定义实现 | ❌ 需自行实现 | ❌ 不支持 | ❌ 不支持 |
| SSR 兼容 | ✅ Node 18+ 原生 | ✅ 自带 adapter | ✅ 自带 | ✅ 专为 SSR 设计 |
| TypeScript | ⚠️ 需自行定义类型 | ✅ 完整类型 | ✅ 完整类型 | ✅ 完整类型 |
⚡ 关键结论: 如果你只需要拦截器 + 重试这两个能力,自己实现只需 ~3 KB 代码,比 Axios 节省 10 KB。如果你还需要 FormData、上传进度、XSRF 防护等高级功能,Axios 或 ofetch 仍然是更务实的选择。
3.4 与 Axios 拦截器 API 的对比
从 Axios 迁移到自定义 Fetch 拦截器时,API 变化如下:
| Axios 写法 | Fetch 拦截器写法 |
|---|---|
axios.interceptors.request.use(fn) |
api.interceptors.request.use(fn) |
axios.interceptors.response.use(fn, errFn) |
api.interceptors.response.use(fn) |
axios.interceptors.request.eject(id) |
const remove = api.interceptors.request.use(fn) → remove() |
axios.get('/api/data') |
api.get('/api/data') |
axios.post('/api/data', body) |
api.post('/api/data', body) |
axios.defaults.baseURL |
createFetchClient({ baseURL }) |
axios.defaults.timeout |
createFetchClient({ timeout }) |
💡 提示: 如果你想保持和 Axios 完全一致的 API 体验(
.then(res => res.data)),只需在响应拦截器中自动解包response.json()并返回{ data, status, headers }结构即可。
💡 四、高级模式与最佳实践
4.1 拦截器的执行顺序陷阱
拦截器的注册顺序决定了执行顺序,这是最容易踩的坑:
// ❌ 错误写法:顺序依赖导致 Token 注入失败
api.interceptors.response.use(async (response) => {
const data = await response.json(); // 消费了 response body
return data;
});
api.interceptors.response.use(async (response) => {
// 这里拿到的 response 已经被上一个拦截器消费了!
console.log(response.status); // 正常
const data = await response.json(); // ❌ 报错!body 已被读取
return data;
});
// ✅ 正确写法:先处理 header,再消费 body
api.interceptors.response.use(async (response) => {
// 不消费 body,只做状态检查
if (response.status === 401) {
throw new Error('Unauthorized');
}
return response; // 原样传递
});
api.interceptors.response.use(async (response) => {
// 在最后一个拦截器中消费 body
const data = await response.json();
return data;
});
⚠️ 警告:
Response的 body(通过.json()、.text()等方法)只能读取一次。如果你的拦截器链中有多个拦截器需要读取 body,必须先用response.clone()克隆 Response 对象。
4.2 拦截器与 Service Worker 的协作
在 PWA 场景中,Service Worker 会拦截所有 Fetch 请求。拦截器和 Service Worker 的关系需要理清:
// Service Worker 中的 Fetch 拦截(sw.js)
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Service Worker 拦截所有请求,优先走缓存
if (url.pathname.startsWith('/api/')) {
// API 请求:网络优先,失败走缓存
event.respondWith(
fetch(event.request)
.catch(() => caches.match(event.request))
);
} else {
// 静态资源:缓存优先
event.respondWith(
caches.match(event.request)
.then(cached => cached || fetch(event.request))
);
}
});
// 应用层的拦截器只在 Service Worker 之后执行
// 也就是说:应用拦截器 → Service Worker → 网络
// 注意:Service Worker 中的 fetch() 不会触发应用层的拦截器
📌 记住: 应用层拦截器和 Service Worker 拦截器是两个独立的层次。应用层拦截器处理业务逻辑(Token、错误码),Service Worker 处理缓存策略和离线支持。两者互不干扰但需要注意 fetch() 在 SW 内部调用时不会经过应用层拦截器。
4.3 TypeScript 类型定义
生产环境必须提供完整的类型支持:
// fetch-interceptor.d.ts —— 完整类型定义
interface FetchConfig {
url: string;
method: string;
headers: Record<string, string>;
body?: BodyInit | null;
timeout?: number;
signal?: AbortSignal;
_retryCount?: number;
[key: string]: unknown;
}
type RequestInterceptor = (config: FetchConfig) => FetchConfig | Promise<FetchConfig>;
type ResponseInterceptor = (response: Response, config: FetchConfig) => Response | Promise<Response>;
interface FetchClientOptions {
baseURL?: string;
timeout?: number;
headers?: Record<string, string>;
maxRetries?: number;
enableDedup?: boolean;
onTokenRefresh?: () => Promise<{ accessToken: string }>;
onError?: (error: unknown) => void;
}
interface FetchClient {
request<T = Response>(url: string, options?: RequestInit): Promise<T>;
get<T = unknown>(url: string, options?: RequestInit): Promise<T>;
post<T = unknown>(url: string, data?: unknown, options?: RequestInit): Promise<T>;
put<T = unknown>(url: string, data?: unknown, options?: RequestInit): Promise<T>;
delete<T = unknown>(url: string, options?: RequestInit): Promise<T>;
interceptors: {
request: { use(fn: RequestInterceptor): () => void };
response: { use(fn: ResponseInterceptor): () => void };
};
}
declare function createFetchClient(options?: FetchClientOptions): FetchClient;
4.4 避坑指南总结
| 坑点 | 说明 | 解决方案 |
|---|---|---|
| Response body 只能读一次 | .json() 消费后不可再读 |
拦截器链中只在最后一个拦截器消费 body,或用 response.clone() |
| AbortController 不可复用 | 一次 abort 后 controller 废弃 | 每次请求创建新实例,不要缓存 |
| 超时与外部 signal 冲突 | 用户传入 signal 和内部 timeout 信号需要联动 | 用 AbortSignal.any() 或自定义合并逻辑 |
| 重试时 body 被消费 | POST 请求的 body 被消费后无法重试 | 将 body 存为字符串,重试时重新传入 |
| 并发 Token 刷新 | 多个请求同时 401 导致多次刷新 | 用锁 + 队列模式,只刷新一次 |
| 去重导致数据过期 | GET 请求被去重后拿到的是旧数据 | 只对短时间内(如 500ms)的相同请求去重 |
✅ 总结
原生 Fetch + 自定义拦截器是 2026 年前端 HTTP 请求的最佳实践之一。 对于不需要 Axios 高级功能(FormData 自动序列化、上传进度、XSRF)的项目,自建 Fetch 客户端可以节省 10 KB 的 Bundle 体积,同时获得完全可控的拦截器链、自动重试和请求去重能力。
关键建议:
- ✅ 新项目优先考虑原生 Fetch + 拦截器,减少第三方依赖
- ✅ 重试策略使用指数退避 + 随机抖动,只对幂等方法重试
- ✅ Token 刷新用锁 + 队列模式,避免并发刷新
- ✅ 响应 body 只在最后一个拦截器中消费,注意
clone() - ❌ 不要在全局缓存 AbortController 实例
- ❌ 不要对 POST 请求做自动重试(非幂等)
- ❌ 不要忽略 TypeScript 类型定义
相关工具推荐:
- 🔧 ofetch — 如果你想用现成的轻量方案,ofetch 是最佳选择
- 🔧 ky — 基于 Fetch 的优雅封装,内置重试
- 🔧 workbox — Google 的 Service Worker 工具库,处理缓存策略
- 🔧 jsjson.com 在线 JSON 格式化工具 — API 响应 JSON 的格式化和校验