从零实现 Fetch 拦截器:彻底告别 Axios 也能拥有请求拦截能力

深入讲解如何用原生 Fetch API 从零构建请求/响应拦截器,涵盖中间件链、自动重试、请求去重、Token 刷新等生产级功能,附完整可运行代码与性能对比。

前端开发 2026-06-09 18 分钟

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;
});

⚠️ 警告: 上面的代码中,AbortControllersignal 联动逻辑在实际生产中需要更精细的处理——如果用户传入的外部 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 的格式化和校验

📚 相关文章