小白前端速成:7天搞懂JS Fetch,彻底告别XMLHttpRequest

还在写$.ajax?我的天,2024年都快过完了,你那是 archaeology(考古)知道吗?上次我看到有人新项目里引入 jQuery 就为了发个请求,我当场就emo了——兄弟,咱浏览器早就内置了 Fetch,免费、原生、不用打包,它不香吗?

但说实话,Fetch 这玩意儿刚上手的时候确实挺反人类的。说好的"现代 API",结果你照抄文档写个 fetch('/api/user'),然后 console.log 一看,undefined。再改,报 CORS 错误。再改,报 404。再改,得,直接 “Failed to fetch”,连404都不如。这时候你就开始怀疑人生:我特么是不是不适合干前端?

别慌,谁不是这么过来的。我当年第一次用 Fetch,在工位上 debugging 了三个小时,最后发现是后端返回的是 text 但我当成了 JSON 来 parse,当场想把键盘吃了。所以今天这篇,都是血泪史,带你从"发个请求都报错"进化到"封装 API 如丝般顺滑"。坐稳了,咱们开始。

还在用 jQuery?你那是情怀还是懒啊

先说清楚,我不是针对 jQuery,它当年确实是神,没有它就没有现代前端。但问题是,现在都是 2024 年了,ES6 都发布快十年了,浏览器都支持原生 Fetch 了,你还为了发个 HTTP 请求引入一整个 jQuery 库,这就好比你为了吃口米饭买了整个农场——土豪也不是这么当的啊。

XMLHttpRequest 更不用说了,那 API 设计得就像是故意要整你。你想发个简单 GET 请求?先 new XMLHttpRequest(),然后 open(),然后 setRequestHeader(),然后 onload,然后 send()。我数了数,最少六行代码,中间还得处理各种状态码,0 是未初始化,1 是 open 调用了,2 是 send 了,3 是 receiving,4 才是 done。我特么就想要个数据,你跟我整这么多状态是要考研啊?

Fetch 的最大优势就三个字:原生、简洁、基于 Promise。不用引库,不用 polyfill(大部分情况下),浏览器自带,直接就能用。而且返回的是 Promise,可以 async await,代码看起来就跟同步一样舒服。但别高兴太早,简洁的代价就是坑多,我们一个个来填。

Fetch 到底是啥?别被名字忽悠了

很多人以为 Fetch 就是个函数,调用一下就完事了。Naive。Fetch 其实是个 Web API,定义在 Window 接口上(或者 Worker 全局作用域),它返回的是一个 Promise,这个 Promise resolve 的时候给你一个 Response 对象。

但这里有个超级大坑,也是新手最常踩的:Fetch 的 Promise 只有在网络故障(offline、DNS 查询失败、连接被拒绝等)的时候才会 reject。如果服务器返回 404、500、403,Promise 依然会 resolve,只是 Response.ok 会变成 false。这跟 axios 完全不一样,axios 会把 4xx 和 5xx 自动当成错误 reject 掉。

所以刚学 Fetch 的时候,你肯定写过这种代码:

// 错误的示范,新手大概率会这么写
fetch('/api/data')
  .then(data => console.log(data))
  .catch(err => console.error(err));

看起来挺对是吧?错得离谱。首先 data 不是真的数据,是个 Response 对象。其次如果服务器返回 500,这个代码根本不会进 catch,你拿到的 data 里装着 500 错误页面,你还以为请求成功了。

正确的姿势至少得是这样:

// 稍微靠谱点的写法
fetch('/api/data')
  .then(response => {
    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }
    return response.json(); // 注意这里也是个 Promise
  })
  .then(data => console.log('真的数据来了:', data))
  .catch(err => console.error('真出错了:', err));

看到没,多了个 response.ok 的判断,而且 response.json() 返回的还是 Promise,得链式调用。这就是为什么很多人觉得 Fetch 难用——它把 HTTP 的底层细节都暴露给你了,你得自己处理状态码,自己转换数据格式,自己处理错误。自由是自由了,但也费劲啊。

最简单的 GET 请求,真的一行吗

那天我在群里看到有人吹牛:“Fetch 发 GET 请求只要一行代码!” 然后他写了:

fetch('/api/list').then(r => r.json()).then(console.log);

技术上说,这确实能跑,但生产环境你敢这么写,明天运维就能冲到你工位上真人PK。这一行代码里埋了多少雷?没错误处理、没超时控制、没 loading 状态、没考虑 204 No Content 的情况,万一返回的不是 JSON 而是纯文本,直接报错给你看。

咱们来个真正能用的 GET 请求封装,起码要带点基础的错误处理:

// 基础版 GET 请求,能应付简单场景
async function getData(url) {
  try {
    const response = await fetch(url);
    
    // 这里必须检查状态码,fetch 不会自动抛错
    if (!response.ok) {
      throw new Error(`请求失败: ${response.status} ${response.statusText}`);
    }
    
    // 判断返回类型,别盲目 json()
    const contentType = response.headers.get('content-type');
    if (contentType && contentType.includes('application/json')) {
      return await response.json();
    } else {
      return await response.text();
    }
  } catch (error) {
    console.error('网络请求出错了:', error);
    // 这里可以统一处理网络错误,比如提示用户检查网络
    throw error;
  }
}

// 使用方式
getData('https://jsonplaceholder.typicode.com/posts/1')
  .then(data => console.log('拿到数据:', data))
  .catch(err => console.error('GG:', err));

看到注释里说的了吧,别看到接口就 response.json(),有些接口返回 text/plain,或者 204 No Content,你调 json() 会直接抛 SyntaxError,那种"Unexpected end of JSON input"的错误,排查起来能要了你半条命。

另外 GET 请求带参数怎么办?别手工拼字符串了,用 URLSearchParams:

// 带查询参数的 GET 请求
async function getWithParams(url, params) {
  // 自动处理参数编码,告别手动拼接 ?a=1&b=2
  const queryString = new URLSearchParams(params).toString();
  const fullUrl = queryString ? `${url}?${queryString}` : url;
  
  console.log('实际请求的 URL:', fullUrl);
  
  const response = await fetch(fullUrl, {
    method: 'GET',
    headers: {
      'Accept': 'application/json',
    }
  });
  
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }
  
  return response.json();
}

// 使用示例
getWithParams('https://api.example.com/search', {
  keyword: '前端开发',
  page: 1,
  pageSize: 10
});

这样写最大的好处是自动 URL 编码,你要是手动拼字符串,遇到中文或者特殊字符,百分百出问题。上次我就看到一个同事手写 ?name=张三,结果传到后端变成乱码,debug 了两小时发现没 encodeURIComponent,当场自闭。

POST 请求的坑,JSON 格式老是 400

POST 请求是新手坟场,重灾区。你是不是经常遇到这种情况:明明代码写的是 POST,后端非说没收到参数;或者后端返回 400 Bad Request,提示"JSON 解析错误"。

大概率是你没搞对 Headers 和 Body 的格式。Fetch 默认的 Content-Type 是… 等等,Fetch 默认根本没有 Content-Type!如果你直接传个对象给 body,它会自动调用 toString(),变成 [object Object],后端收到这个人都傻了。

正确的 POST 请求长这样:

// 标准的 JSON POST 请求
async function postJSON(url, data) {
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',  // 这行不写必翻车
      'Accept': 'application/json',
    },
    body: JSON.stringify(data)  // 必须序列化成字符串
  });
  
  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`请求失败 ${response.status}: ${errorText}`);
  }
  
  return response.json();
}

// 使用示例
postJSON('https://jsonplaceholder.typicode.com/posts', {
  title: '我又踩坑了',
  body: 'Fetch 的 Content-Type 真难搞',
  userId: 1
}).then(data => console.log('创建成功:', data));

看见 JSON.stringify 了吗?必须手动转字符串。而且 Content-Type 必须显式设置成 application/json,不然后端(特别是 Spring Boot 或者 Express)不知道你要传 JSON,可能当成表单数据处理,那就对不上号了。

要是传表单数据怎么办?比如文件上传或者传统的 form post:

// 表单数据提交(multipart/form-data)
async function postForm(url, formData) {
  // 注意:这时候不要手动设置 Content-Type!
  // 浏览器会自动设置,并且加上 boundary 字符串
  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Accept': 'application/json'
      // 千万别写 'Content-Type': 'multipart/form-data',不然 boundary 没了
    },
    body: formData  // 直接传 FormData 对象
  });
  
  return response.json();
}

// 使用示例
const formData = new FormData();
formData.append('username', '张三');
formData.append('avatar', fileInput.files[0]);  // 文件上传

postForm('https://api.example.com/upload', formData);

这里有个反直觉的点:当你使用 FormData 的时候,绝对不能手动设置 Content-Type。浏览器会自动帮你设置成 multipart/form-data; boundary=----WebKitFormBoundary... 这种带随机 boundary 的格式。如果你手贱自己设置了,boundary 就没了,后端解析文件上传的时候直接懵圈,收到的文件大小都是 0 字节。这种 bug 我查了一下午,最后发现是这行代码的问题,当时想抽自己。

Headers 那些事儿,写错真的翻车

Headers 是 HTTP 请求的灵魂,Fetch API 对 Headers 的处理还挺灵活的,你可以传对象,也可以传 Headers 实例。但我建议统一用对象,简单明了。

但有些 Headers 是浏览器禁止设置的,比如 RefererUser-AgentCookie 这些,你设置了也没用,浏览器会忽略。这是为了安全考虑,防止前端伪造请求来源。

常见的 Headers 配置 combo 我帮你整理好了:

// 常用的请求头配置
const commonHeaders = {
  'Content-Type': 'application/json',
  'Accept': 'application/json, text/plain, */*',
  'Accept-Language': 'zh-CN,zh;q=0.9',
  // 自定义 token,这是最常见的
  'Authorization': `Bearer ${localStorage.getItem('token') || ''}`
};

// 封装一个带默认 headers 的请求
async function requestWithAuth(url, options = {}) {
  const defaultOptions = {
    headers: {
      ...commonHeaders,
      ...options.headers  // 允许覆盖
    }
  };
  
  // 特别处理:如果是 FormData,删除 Content-Type,让浏览器自动设置
  if (options.body instanceof FormData) {
    delete defaultOptions.headers['Content-Type'];
  }
  
  const response = await fetch(url, { ...defaultOptions, ...options });
  
  // 统一处理 401 未授权
  if (response.status === 401) {
    localStorage.removeItem('token');
    window.location.href = '/login';  // 跳登录页
    throw new Error('登录已过期,请重新登录');
  }
  
  return response;
}

看见最后那个 401 处理了吗?这才是真实项目该有的样子。token 过期了自动跳转登录页,别让用户一直卡在"数据加载中"的转圈圈页面。

async await 和 then,别再混着写了

我发现很多新手有个坏习惯,就是混着用 Promise 的链式调用和 async await,写出来的代码长得跟意大利面条似的:

// 这种写法看着就头疼,别这么搞
fetch('/api/data')
  .then(async (response) => {
    const data = await response.json();
    const processed = await processData(data);
    return processed;
  })
  .then(result => {
    return fetch('/api/save', {
      method: 'POST',
      body: JSON.stringify(result)
    });
  })
  .then(response => response.json())
  .catch(err => console.error(err));

这代码虽然能跑,但可读性极差,debug 的时候你想加个断点都难。我的建议是:一个项目里统一风格,要么全用 async await,要么全用 then 链式。现在主流是 async await,代码看起来像是同步的,逻辑清晰多了。

但是!async await 也有坑,就是错误处理。你写一堆 await,只要中间有一个抛错,后面的都不执行了。所以要用 try-catch 包起来:

// 清晰多了对吧
async function loadUserData() {
  try {
    // 获取用户基本信息
    const userRes = await fetch('/api/user');
    if (!userRes.ok) throw new Error('获取用户失败');
    const user = await userRes.json();
    
    // 获取用户的订单列表
    const ordersRes = await fetch(`/api/user/${user.id}/orders`);
    if (!ordersRes.ok) throw new Error('获取订单失败');
    const orders = await ordersRes.json();
    
    // 获取第一个订单的详情
    if (orders.length > 0) {
      const detailRes = await fetch(`/api/orders/${orders[0].id}`);
      if (!detailRes.ok) throw new Error('获取订单详情失败');
      const detail = await detailRes.json();
      
      return { user, orders, firstOrderDetail: detail };
    }
    
    return { user, orders };
  } catch (error) {
    console.error('加载数据出错:', error);
    // 这里可以显示错误提示 UI
    showErrorToast(error.message);
    return null;
  }
}

看到这种层级结构了吗?一眼就能看出数据依赖关系:先拿用户信息,再拿订单,再拿详情。如果用 then 链式,可能得嵌套三层,到时候括号都对不齐。

但有时候你需要并行请求,这时候 await 就得配合 Promise.all:

// 并行请求,同时发三个接口,等全部返回
async function loadDashboardData() {
  try {
    const [userData, statsData, noticeData] = await Promise.all([
      fetch('/api/user').then(r => r.json()),
      fetch('/api/stats').then(r => r.json()),
      fetch('/api/notices').then(r => r.json())
    ]);
    
    return { userData, statsData, noticeData };
  } catch (error) {
    // Promise.all 是"一失败全失败",任何一个 reject 都会进这里
    console.error('并行请求失败:', error);
    throw error;
  }
}

注意 Promise.all 的风险:只要有一个请求失败了,整个就崩了。如果你希望"坏一个不影响其他的",得用 Promise.allSettled:

// 并行但不互相影响,失败的返回 null
const results = await Promise.allSettled([
  fetch('/api/a').then(r => r.json()),
  fetch('/api/b').then(r => r.json()),
  fetch('/api/c').then(r => r.json())
]);

const [a, b, c] = results.map(result => 
  result.status === 'fulfilled' ? result.value : null
);

这样就算 b 接口挂了,a 和 c 的数据照样能用,页面不会直接白屏。

真实项目里没人直接用 fetch,都是怎么封装的

说句实话,真实生产环境里,99% 的公司不会直接用原生 Fetch,都会封装一层。为啥?因为你要处理的事情太多了:loading 状态、错误提示、token 刷新、请求取消、重试机制… 这些如果用原生 Fetch 每个请求都写一遍,代码量能膨胀十倍。

下面我给你看一个基础但实用的封装版本,适合中小项目:

// 创建一个请求类,带拦截器功能
class HttpClient {
  constructor(baseURL = '') {
    this.baseURL = baseURL;
    this.interceptors = {
      request: [],
      response: []
    };
  }
  
  // 添加请求拦截器
  addRequestInterceptor(onFulfilled, onRejected) {
    this.interceptors.request.push({ onFulfilled, onRejected });
  }
  
  // 添加响应拦截器
  addResponseInterceptor(onFulfilled, onRejected) {
    this.interceptors.response.push({ onFulfilled, onRejected });
  }
  
  async request(url, options = {}) {
    // 拼接完整 URL
    const fullUrl = url.startsWith('http') ? url : `${this.baseURL}${url}`;
    
    // 默认配置
    const defaultOptions = {
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json'
      }
    };
    
    // 合并配置,注意 headers 要特殊合并
    const finalOptions = {
      ...defaultOptions,
      ...options,
      headers: {
        ...defaultOptions.headers,
        ...options.headers
      }
    };
    
    // 执行请求拦截器
    try {
      for (const interceptor of this.interceptors.request) {
        if (interceptor.onFulfilled) {
          finalOptions.headers = await interceptor.onFulfilled(finalOptions.headers);
        }
      }
    } catch (error) {
      if (interceptor.onRejected) {
        interceptor.onRejected(error);
      }
      throw error;
    }
    
    // 发起请求
    let response;
    try {
      response = await fetch(fullUrl, finalOptions);
    } catch (networkError) {
      // 网络错误(断网、DNS 失败等)
      throw new Error('网络连接失败,请检查网络设置');
    }
    
    // 执行响应拦截器
    for (const interceptor of this.interceptors.response) {
      try {
        response = await interceptor.onFulfilled(response);
      } catch (error) {
        if (interceptor.onRejected) {
          interceptor.onRejected(error);
        }
        throw error;
      }
    }
    
    return response;
  }
  
  // 便捷的 GET 方法
  get(url, params) {
    const queryString = params ? `?${new URLSearchParams(params)}` : '';
    return this.request(`${url}${queryString}`, { method: 'GET' });
  }
  
  // 便捷的 POST 方法
  post(url, data) {
    return this.request(url, {
      method: 'POST',
      body: JSON.stringify(data)
    });
  }
}

// 使用示例:创建一个实例并配置
const http = new HttpClient('https://api.example.com');

// 请求拦截器:自动加 token
http.addRequestInterceptor(async (headers) => {
  const token = localStorage.getItem('token');
  if (token) {
    headers['Authorization'] = `Bearer ${token}`;
  }
  return headers;
});

// 响应拦截器:统一处理错误
http.addResponseInterceptor(
  async (response) => {
    // 还是老问题,fetch 不会自动抛 HTTP 错误
    if (!response.ok) {
      const errorData = await response.json().catch(() => ({}));
      throw new Error(errorData.message || `请求失败: ${response.status}`);
    }
    return response.json();
  },
  (error) => {
    // 这里可以统一上报错误到监控系统,比如 Sentry
    console.error('接口错误:', error);
    throw error;
  }
);

// 现在用起来就爽多了
http.get('/user/profile')
  .then(data => console.log(data))
  .catch(err => console.error(err));

http.post('/user/update', { name: '李四', age: 25 })
  .then(() => alert('更新成功'))
  .catch(err => alert(err.message));

这个封装虽然代码量看起来不少,但用的时候爽啊。每个请求自动带 token,自动处理 JSON 转换,自动抛错,你业务代码里只需要关注业务逻辑就行。

但我得提醒你,这只是基础版,真实的大厂项目封装会更复杂,可能要处理:

  • token 过期自动刷新(无感刷新)
  • 请求取消(AbortController)
  • 防重复提交(同一个按钮连点五次发五个请求)
  • 请求缓存(短时间内重复请求直接读缓存)
  • 超时控制(fetch 默认没有超时)

咱们后面一个个说。

跨域问题真的全是后端的锅吗

说到跨域(CORS),前端新人最怕这个。报错信息通常是:

Access to fetch at ‘https://api.other.com/data’ from origin ‘http://localhost:3000’ has been blocked by CORS policy…

这时候你第一反应肯定是:艹,后端又没配 CORS。但真的是这样吗?

CORS 是浏览器的安全策略,全称 Cross-Origin Resource Sharing。简单说就是浏览器问后端:“这个前端域名我能不能访问你?” 后端说"能",才能正常请求;后端说"不能"或者没说话,浏览器就拦截了。

前端能做什么?说实话,生产环境跨域问题确实主要靠后端解决,后端要在响应头里加:

Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization

但开发环境你可以自己解决,不用求后端。最土的办法是配开发服务器代理。如果你用 Webpack、Vite 或者 Create React App,都可以配 proxy:

Vite 的 vite.config.js:

export default {
  server: {
    proxy: {
      '/api': {
        target: 'https://api.backend.com',
        changeOrigin: true,
        // 重写路径,去掉 /api 前缀
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  }
}

这样你代码里写 fetch('/api/data'),实际上会转发到 https://api.backend.com/data,浏览器看到的是同域请求,就不会报 CORS 了。

还有一种邪道方法,仅在开发时可用,就是禁用浏览器安全策略(不推荐,但确实能救命)。Chrome 启动参数加 --disable-web-security --user-data-dir=/temp,但这等于裸奔,仅限本地调试用。

另外前端要注意,简单请求和复杂请求的预检(Preflight) 区别。如果你请求方法是 GET、HEAD、POST 之一,且 Content-Type 是 application/x-www-form-urlencoded、multipart/form-data 或 text/plain,那是简单请求,直接发。

但如果你加了自定义 Header(比如 Authorization),或者 Content-Type 是 application/json,那就是复杂请求,浏览器会先自动发一个 OPTIONS 预检请求,问后端"我能不能这么干"。这时候如果后端没处理 OPTIONS 请求,就会报 CORS 错误。所以看到控制台里一次请求发了两次(先 OPTIONS 后 POST),别慌,是正常的。

网络失败、超时、非200状态码,怎么优雅处理

前面说了,Fetch 不会自动 reject HTTP 错误状态码(404、500等),这需要你自己处理。但还有更麻烦的:网络故障和超时。

Fetch 默认没有超时机制!你的请求可以挂那儿一辈子,直到浏览器自己放弃(通常是几分钟)。这在移动端简直是灾难,用户网不好的时候,页面能转圈转五分钟。

实现超时要用 Promise.race:

// 带超时的 fetch 封装
async function fetchWithTimeout(url, options = {}, timeout = 10000) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), timeout);
  
  try {
    const response = await fetch(url, {
      ...options,
      signal: controller.signal
    });
    clearTimeout(id);
    return response;
  } catch (error) {
    clearTimeout(id);
    if (error.name === 'AbortError') {
      throw new Error('请求超时,请检查网络或稍后重试');
    }
    throw error;
  }
}

// 使用
fetchWithTimeout('https://slow.api.com/data', {}, 5000)
  .then(response => console.log('在5秒内返回了'))
  .catch(err => console.error('超时或失败:', err));

AbortController 是个好东西,它不仅能用来超时,还能用来主动取消请求。比如用户进入一个页面发了个请求,然后马上点了返回或者切换了 tab,这时候你应该取消之前的请求,避免数据错乱或者内存泄漏。

let currentController = null;

function loadData() {
  // 取消之前的请求
  if (currentController) {
    currentController.abort();
  }
  
  currentController = new AbortController();
  
  fetch('/api/big-data', {
    signal: currentController.signal
  })
    .then(response => response.json())
    .then(data => {
      console.log('数据加载完成:', data);
    })
    .catch(err => {
      if (err.name === 'AbortError') {
        console.log('请求被取消了(可能是用户切换页面了)');
        return;
      }
      console.error('加载失败:', err);
    });
}

// 用户点击返回按钮时
function onUserLeave() {
  if (currentController) {
    currentController.abort();
  }
}

关于错误处理,建议你做分级:

// 错误处理 helper
function handleFetchError(error) {
  // 网络错误
  if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
    return { type: 'NETWORK', message: '网络连接失败,请检查网络' };
  }
  
  // 超时
  if (error.message.includes('timeout')) {
    return { type: 'TIMEOUT', message: '请求超时,服务器响应太慢' };
  }
  
  // HTTP 错误
  if (error.message.includes('HTTP')) {
    const statusCode = parseInt(error.message.match(/\d+/)[0]);
    
    if (statusCode === 401) {
      return { type: 'UNAUTHORIZED', message: '登录已过期' };
    }
    if (statusCode === 403) {
      return { type: 'FORBIDDEN', message: '没有权限访问' };
    }
    if (statusCode === 404) {
      return { type: 'NOT_FOUND', message: '请求的资源不存在' };
    }
    if (statusCode >= 500) {
      return { type: 'SERVER', message: '服务器内部错误' };
    }
    
    return { type: 'HTTP', message: `请求失败 (${statusCode})` };
  }
  
  // 其他
  return { type: 'UNKNOWN', message: error.message };
}

然后在 UI 层根据错误类型显示不同的提示,网络错误给重试按钮,401 直接跳转登录,5xx 错误提示"服务器开小差了"。

别人封装的 fetch 工具为啥那么稳

看看大厂或者开源库封装的请求库,稳定性确实高。他们通常都做了这几件事:

1. 自动重试机制
网络抖动的时候,自动重试 2-3 次,比直接报错用户体验好得多:

async function fetchWithRetry(url, options, maxRetries = 3, delay = 1000) {
  let lastError;
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      const response = await fetch(url, options);
      if (response.ok) return response;
      
      // 如果是 5xx 错误,值得重试;4xx 错误重试也没用
      if (response.status < 500) {
        throw new Error(`Client Error: ${response.status}`);
      }
      
      lastError = new Error(`Server Error: ${response.status}`);
    } catch (error) {
      lastError = error;
      console.log(`${i + 1} 次请求失败,${delay}ms 后重试...`);
    }
    
    // 等待一段时间再重试,指数退避
    if (i < maxRetries - 1) {
      await new Promise(resolve => setTimeout(resolve, delay * Math.pow(2, i)));
    }
  }
  
  throw lastError;
}

2. 请求去重(防抖)
用户疯狂点击提交按钮,只发最后一次请求,或者取消前面的:

class RequestManager {
  constructor() {
    this.pendingRequests = new Map();
  }
  
  async request(url, options) {
    const key = `${url}-${JSON.stringify(options)}`;
    
    // 如果已经有相同请求在进行中,返回那个 Promise
    if (this.pendingRequests.has(key)) {
      console.log('拦截重复请求:', url);
      return this.pendingRequests.get(key);
    }
    
    const promise = fetch(url, options)
      .finally(() => {
        // 完成后从 pending 中移除
        this.pendingRequests.delete(key);
      });
    
    this.pendingRequests.set(key, promise);
    return promise;
  }
}

3. 缓存机制
GET 请求结果缓存一段时间,避免重复请求:

class CacheFetch {
  constructor() {
    this.cache = new Map();
  }
  
  async get(url, options = {}, ttl = 60000) {
    const key = url;
    const cached = this.cache.get(key);
    
    if (cached && Date.now() - cached.time < ttl) {
      console.log('命中缓存:', url);
      return cached.data;
    }
    
    const response = await fetch(url, options);
    const data = await response.json();
    
    this.cache.set(key, {
      data,
      time: Date.now()
    });
    
    return data;
  }
  
  clear() {
    this.cache.clear();
  }
}

这些骚操作单独看都不难,但组合在一起就是一个稳定的请求层。如果你不想自己写,直接用 axios 吧,这些功能它都内置了,这就是为什么很多人最终还是选择 axios 而不是原生 Fetch。

开发时疯狂刷新,怎么避免重复请求刷爆接口

开发阶段你肯定会遇到这种情况:写了个 useEffect 发请求,然后疯狂修改代码保存,页面自动刷新,接口被反复调用,后端同学来找你:“你的 IP 被封了,请求太频繁”。

或者用户手贱,盯着提交按钮狂点,硬生生给发了十几个创建订单的请求,结果用户账户里多了十几个相同的订单,客服电话被打爆。

解决方案前面提了一嘴,这里给完整的实现:

// 使用 AbortController 管理请求生命周期
class RequestGuard {
  constructor() {
    this.controllers = new Map();
  }
  
  // 执行请求,如果相同的 key 已经在请求中,先取消之前的
  async fetch(key, url, options = {}) {
    // 取消之前的同 key 请求
    this.cancel(key);
    
    const controller = new AbortController();
    this.controllers.set(key, controller);
    
    try {
      const response = await fetch(url, {
        ...options,
        signal: controller.signal
      });
      
      this.controllers.delete(key);
      return response;
    } catch (error) {
      this.controllers.delete(key);
      throw error;
    }
  }
  
  cancel(key) {
    if (this.controllers.has(key)) {
      console.log(`取消之前的请求: ${key}`);
      this.controllers.get(key).abort();
      this.controllers.delete(key);
    }
  }
  
  cancelAll() {
    for (const [key, controller] of this.controllers) {
      controller.abort();
    }
    this.controllers.clear();
  }
}

// 在 React 组件中的使用示例
const requestGuard = new RequestGuard();

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    const loadUser = async () => {
      try {
        const response = await requestGuard.fetch(
          `user-${userId}`,  // 唯一 key
          `/api/users/${userId}`
        );
        const data = await response.json();
        setUser(data);
      } catch (err) {
        if (err.name !== 'AbortError') {
          console.error(err);
        }
      }
    };
    
    loadUser();
    
    // 组件卸载时取消请求
    return () => {
      requestGuard.cancel(`user-${userId}`);
    };
  }, [userId]);
  
  return <div>{user?.name}</div>;
}

Button 防连点也一个道理:

function SubmitButton() {
  const [submitting, setSubmitting] = useState(false);
  
  const handleSubmit = async () => {
    if (submitting) {
      console.log('已经在提交了,别点了');
      return;
    }
    
    setSubmitting(true);
    try {
      await fetch('/api/order', {
        method: 'POST',
        body: JSON.stringify({ items: cart.items })
      });
      alert('提交成功');
    } catch (error) {
      alert('提交失败: ' + error.message);
    } finally {
      setSubmitting(false);
    }
  };
  
  return (
    <button onClick={handleSubmit} disabled={submitting}>
      {submitting ? '提交中...' : '立即提交'}
    </button>
  );
}

记住:永远要相信用户会做出你想不到的骚操作,他们认为点得越快提交得越快,你得帮他们节制一下。

Mock 数据没到位?用拦截加延迟模拟真实体验

后端接口还没写好,但你前端要并行开发,这时候得 Mock 数据。别直接写死 if (isMock) return fakeData,太 low 了。

我们可以用 Service Worker 拦截请求,或者更简单点,封装一层 Mock 中间件:

// Mock 管理器
class MockManager {
  constructor() {
    this.mocks = new Map();
    this.enable = process.env.NODE_ENV === 'development';
  }
  
  // 注册 Mock
  register(pattern, handler, delay = 500) {
    this.mocks.set(pattern, { handler, delay });
  }
  
  async intercept(url, options) {
    if (!this.enable) return null;  // 生产环境不拦截
    
    for (const [pattern, config] of this.mocks) {
      if (url.includes(pattern)) {
        console.log(`[Mock] 拦截请求: ${url}`);
        
        // 模拟网络延迟,别太快返回,不真实
        await new Promise(resolve => setTimeout(resolve, config.delay));
        
        // 模拟随机失败(10% 概率),测试错误处理
        if (Math.random() < 0.1) {
          throw new Error('Mock 随机失败');
        }
        
        const mockResponse = config.handler(url, options);
        return new Response(JSON.stringify(mockResponse), {
          status: 200,
          headers: { 'Content-Type': 'application/json' }
        });
      }
    }
    
    return null;  // 没有匹配的 Mock,继续真实请求
  }
}

// 初始化并注册一些 Mock
const mock = new MockManager();

mock.register('/api/user/profile', () => ({
  id: 1,
  name: 'Mock用户',
  avatar: 'https://placekitten.com/100/100',
  level: 'VIP8',
  balance: 999999
}), 800);

mock.register('/api/products', () => [
  { id: 1, name: 'Mock商品1', price: 99 },
  { id: 2, name: 'Mock商品2', price: 199 }
], 1200);

// 修改封装层,插入 Mock 拦截
async function smartFetch(url, options) {
  // 先尝试 Mock
  const mockResponse = await mock.intercept(url, options);
  if (mockResponse) return mockResponse;
  
  // 真实请求
  return fetch(url, options);
}

这样写的好处是:

  1. 有真实延迟体验,能看到 loading 状态
  2. 可以测试错误处理逻辑(随机失败)
  3. 后端接口好了直接关掉 Mock 就行,业务代码不用改
  4. 能模拟慢网环境(调大 delay)

如果你用现代前端框架(React/Vue/Angular),可以用 MSW(Mock Service Worker)这个库,比这更专业,能拦截网络层,连 Network 面板里都能看到请求,只是返回的是 Mock 数据。

调试三板斧,Network 面板看不明白时的绝招

遇到接口问题,新手就只会 console.log,其实 Chrome DevTools 里有很多神器。

第一招:右键请求直接 Copy as Fetch
在 Network 面板里,右键一个请求,选 Copy -> Copy as fetch(cURL),它会自动生成这段请求的 Fetch 代码,包括所有 Headers 和 Body。你要重发这个请求测试,直接粘贴到 Console 里就能跑,不用重新操作 UI。

第二招:用 debugger 断点看执行流程
别只会 console.log,在关键地方加 debugger;,代码执行到这里会自动断住,你能看到完整的调用栈、变量值。配合 async await 的时候特别有用,能看到 Promise 什么时候 resolve。

async function debugFetch() {
  console.log('开始请求');
  debugger;  // 断点1
  
  const res = await fetch('/api/test');
  debugger;  // 断点2,能看到 res 对象
  
  const data = await res.json();
  debugger;  // 断点3,能看到解析后的数据
  
  console.log(data);
}

第三招:查看 Timing 分析慢请求
Network 面板里选中一个请求,看 Timing 标签,能看到:

  • Queueing:排队时间(浏览器对同域名并发限制通常是6个)
  • DNS Lookup:DNS 查询时间
  • SSL:HTTPS 握手时间
  • Waiting (TTFB):首字节时间,这个大了说明后端处理慢
  • Content Download:下载时间,这个大了说明返回数据太大

如果你看到 TTFB 要 2 秒,Content Download 只要 10ms,那肯定是后端 SQL 写烂了,去找后端撕逼的时候有证据了。

还有个隐藏技巧:在 Console 里用 $r 查看最近选中的元素,配合 Network 面板选中某个请求,然后在 Console 输入 $r,居然能看到那个请求对象的各种细节,虽然我也不知道这有什么用,但看起来很酷。

“Failed to fetch” 到底啥情况,90% 是这几个原因

看到控制台红彤彤的 “Failed to fetch”,别慌,按这个 checklist 排查:

1. URL 写错了(最常见)
检查一下你是不是写成了 fetch('/api/data') 但当前页面是 file:// 协议打开的(直接双击 HTML 文件)。Fetch 不能跨协议请求,必须用 HTTP 协议打开页面。建议上 VS Code 的 Live Server 插件。

2. CORS 问题
虽然浏览器 CORS 报错通常会详细说明被 CORS policy 拦截,但有时候就给你个 failed to fetch。看看 Network 面板里有没有预检请求(OPTIONS)返回 403,或者响应头里确实没有 Access-Control-Allow-Origin。

3. SSL 证书问题
开发环境用自签名 HTTPS 证书时,浏览器会拦截"不安全"的请求。你先手动在浏览器里打开那个 API 地址,点击"继续前往(不安全)",然后再回页面请求,可能就通了。

4. AdBlock 拦截
是的,某些广告拦截插件会误判你的 API 路径,比如包含 adtrackanalytics 这些词。如果你发现请求莫名其妙失败,试试无痕模式(通常没插件),如果无痕模式能跑,那就是插件问题。

5. 请求头过大
如果你塞了太多自定义 Header,或者 Cookie 特别大,可能超出浏览器限制,直接拒绝发送。

6. 网络真的断了
开个新标签页试试能不能打开百度,别笑,真的有人对着断网 debug 两小时。

排查顺序:先开 Network 面板看请求发出去了吗(Status 那一列是 (failed) 还是具体状态码),如果根本没发出去,大概率是 CORS 或者 URL 问题;如果发出去了但红字,看后端的响应。

兼容性真那么差吗,什么时候必须上 axios

网上有人说 Fetch 兼容性不行,不支持 IE。但大哥,IE 都死三年了,Win11 都不自带 IE 了,你还考虑它干嘛?现在 Fetch 的支持率是 97%+,主流现代浏览器(Chrome 42+、Firefox 39+、Safari 10.1+、Edge 14+)都支持。

但 Fetch 确实有些特性旧版浏览器不支持,比如:

  • AbortController(Chrome 66+, Firefox 57+)
  • Request/Response 对象的某些方法
  • 流式处理(Streams)

如果你要支持特别老的设备(比如政府项目要求 IE11),可以用 whatwg-fetch polyfill,就几 KB,补上基本功能。

但就算在现代浏览器里,Fetch 和 axios 各有优劣:

Fetch 的优势:

  • 原生支持,不用打包,体积为 0
  • 基于 Promise,支持 async await
  • 支持 Streams,可以处理大文件下载进度

Fetch 的劣势:

  • 功能简陋,得自己封装很多东西
  • 默认不带超时
  • 默认不 reject HTTP 错误状态码
  • 不支持请求/响应拦截器(得自己实现)

axios 的优势:

  • 功能齐全,拦截器、自动 JSON 转换、超时控制、取消请求、自动防御 XSRF 都内置了
  • 支持浏览器和 Node.js 环境同构(SSR 项目必备)
  • 错误处理更友好
  • 请求进度监控(onUploadProgress/onDownloadProgress)

所以我的建议是:小项目、学习目的、对体积敏感(比如 Chrome 插件)用 Fetch;中大型项目、团队项目、需要复杂请求管理的上 axios,别重复造轮子。

那个大坑:fetch 默认不 reject HTTP 错误状态码

我再强调一遍这个坑,因为踩过的人都说内存深刻。看代码:

// 你以为这样写能捕获 404?
try {
  const res = await fetch('/api/not-found');
  console.log('请求成功', res);
} catch (err) {
  console.error('请求失败', err);
}

如果服务器返回 404,这段代码会输出"请求成功",因为 fetch 的 Promise 成功 resolve 了,只是 response.ok 是 false。你得手动检查:

const res = await fetch('/api/not-found');
if (!res.ok) {
  throw new Error(`HTTP ${res.status}`);
}

对比 axios:

try {
  const res = await axios.get('/api/not-found');
} catch (err) {
  // axios 会自动把 4xx 5xx 扔进这里,省心多了
  console.error(err.response.status);
}

这就是为什么很多人用着用着就换回 axios 了,fetch 太原始了,像个毛坯房,啥都要自己装修。

但如果你喜欢折腾,可以这样封装一个严格的 fetch:

async function strictFetch(url, options) {
  const response = await fetch(url, options);
  
  if (!response.ok) {
    const error = new Error(`HTTP ${response.status}: ${response.statusText}`);
    error.status = response.status;
    error.response = response;
    
    // 尝试解析错误信息
    try {
      error.data = await response.json();
    } catch {
      error.data = await response.text();
    }
    
    throw error;
  }
  
  return response;
}

用 strictFetch 代替 fetch,行为就和 axios 类似了。

顺手写个带 loading、重试、缓存的小型请求管理器

来,咱们综合运用前面学的,写一个 20 行左右(好吧可能稍微多几行)的实用请求管理器,放在 utils/request.js 里直接能用:

// 小而美的请求管理器
class SmartFetch {
  constructor(options = {}) {
    this.baseURL = options.baseURL || '';
    this.timeout = options.timeout || 10000;
    this.retries = options.retries || 0;
    this.cache = new Map();
  }
  
  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;
    const cacheKey = `${url}-${JSON.stringify(options)}`;
    
    // 检查缓存
    if (options.cacheTTL) {
      const cached = this.cache.get(cacheKey);
      if (cached && Date.now() - cached.time < options.cacheTTL) {
        return cached.data;
      }
    }
    
    // 重试逻辑
    let lastError;
    for (let attempt = 0; attempt <= this.retries; attempt++) {
      try {
        const result = await this._fetchWithTimeout(url, options);
        
        // 存入缓存
        if (options.cacheTTL) {
          this.cache.set(cacheKey, { data: result, time: Date.now() });
        }
        
        return result;
      } catch (err) {
        lastError = err;
        if (attempt < this.retries) {
          await this._delay(1000 * Math.pow(2, attempt)); // 指数退避
        }
      }
    }
    throw lastError;
  }
  
  _fetchWithTimeout(url, options) {
    return new Promise((resolve, reject) => {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => {
        controller.abort();
        reject(new Error('Request timeout'));
      }, this.timeout);
      
      fetch(url, { ...options, signal: controller.signal })
        .then(async res => {
          clearTimeout(timeoutId);
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          return res.json();
        })
        .then(resolve)
        .catch(reject);
    });
  }
  
  _delay(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
  
  clearCache() {
    this.cache.clear();
  }
}

// 导出单例
export const http = new SmartFetch({
  baseURL: '/api',
  timeout: 8000,
  retries: 2  // 失败自动重试 2 次
});

// 使用示例:
// http.request('/user', { cacheTTL: 60000 })  // 缓存 1 分钟
// http.request('/order', { method: 'POST', body: JSON.stringify(data) })

没有依赖,原生 JS 就能跑,支持超时、重试、缓存,代码量也不大,适合中小型项目。

别人问你 fetch 和 axios 有啥区别,这样回答显得专业

面试官问:“Fetch 和 axios 有什么区别?”

菜鸟回答:“Fetch 是原生的,axios 是第三方库,axios 更好用。”

高手回答:

"从API设计层面看,Fetch 是底层 API,基于 Promise 和 Streams,提供了更底层的控制能力,比如我们可以直接操作 ReadableStream 来实现下载进度监控;而 axios 是高层封装,提供了拦截器、自动转换、XSRF 防护等企业级特性。

从错误处理机制看,Fetch 遵循浏览器底层语义,只有网络失败才会 reject,HTTP 错误状态(4xx/5xx)需要手动检查 response.ok;而 axios 基于拦截器实现了更友好的错误处理,会自动将非 2xx 状态转为 rejected Promise。

从运行时环境看,Fetch 是浏览器原生 API,在 Node.js 早期版本需要引入 node-fetch 等 polyfill,而 axios 同时支持浏览器和 Node.js,在 SSR 场景下能实现同构。

从体积和生态看,Fetch 零依赖,适合对包体积敏感的场景;axios 生态更完善,社区中间件丰富,适合中大型项目。我们团队在项目初期使用过原生 Fetch 配合自建拦截器,后来在微前端架构下统一迁移到了 axios,主要是为了利用其请求取消和批量处理的能力。"

你看,这样一说,面试官就知道你不仅会用,还深入理解底层原理,offer 稳了。

实战彩蛋:用 fetch 写个天气查询小工具

光说不练假把式,咱们来完整撸一个天气查询应用,调 OpenWeatherMap 或者和风天气的 API。这里用 JSONPlaceholder 模拟天气数据,你换成真实 API key 就能跑:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Fetch 天气查询</title>
  <style>
    body { font-family: -apple-system, sans-serif; max-width: 600px; margin: 50px auto; padding: 20px; }
    input { width: 70%; padding: 10px; font-size: 16px; }
    button { width: 28%; padding: 10px; background: #007bff; color: white; border: none; cursor: pointer; }
    button:disabled { background: #ccc; }
    .weather-card { margin-top: 20px; padding: 20px; background: #f0f8ff; border-radius: 8px; display: none; }
    .loading { text-align: center; color: #666; margin-top: 20px; display: none; }
    .error { color: #dc3545; margin-top: 10px; }
  </style>
</head>
<body>
  <h2>🌤️ Fetch 天气查询 Demo</h2>
  <div>
    <input type="text" id="cityInput" placeholder="输入城市名(如:北京)" value="Beijing">
    <button onclick="searchWeather()">查询天气</button>
  </div>
  
  <div class="loading" id="loading">加载中...</div>
  <div class="error" id="error"></div>
  
  <div class="weather-card" id="weatherCard">
    <h3 id="cityName"></h3>
    <p>温度: <span id="temp"></span>°C</p>
    <p>天气: <span id="condition"></span></p>
    <p>湿度: <span id="humidity"></span>%</p>
  </div>

  <script>
    // 模拟天气 API(实际使用时换成真实 API)
    async function fetchWeather(city) {
      // 模拟网络延迟
      await new Promise(resolve => setTimeout(resolve, 800));
      
      // 模拟数据,真实项目中应该是:
      // const API_KEY = 'your_api_key';
      // const url = `https://api.openweathermap.org/data/2.5/weather?q=${city}&appid=${API_KEY}&units=metric`;
      // return fetch(url);
      
      const mockData = {
        Beijing: { temp: 22, condition: '晴天', humidity: 45 },
        Shanghai: { temp: 25, condition: '多云', humidity: 60 },
        Guangzhou: { temp: 30, condition: '雷阵雨', humidity: 80 }
      };
      
      const data = mockData[city] || { temp: 20, condition: '未知', humidity: 50 };
      return { ok: true, json: async () => data };
    }
    
    // 带 loading 和错误处理的请求函数
    async function getWeather(city) {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 5000);
      
      try {
        const response = await fetch(`https://api.weather.example.com/v1/current?city=${encodeURIComponent(city)}`, {
          signal: controller.signal,
          headers: { 'Accept': 'application/json' }
        });
        
        clearTimeout(timeoutId);
        
        if (!response.ok) {
          if (response.status === 404) throw new Error('城市未找到');
          throw new Error(`服务错误: ${response.status}`);
        }
        
        return await response.json();
      } catch (error) {
        if (error.name === 'AbortError') throw new Error('请求超时');
        throw error;
      }
    }
    
    async function searchWeather() {
      const city = document.getElementById('cityInput').value.trim();
      const loading = document.getElementById('loading');
      const error = document.getElementById('error');
      const card = document.getElementById('weatherCard');
      const btn = document.querySelector('button');
      
      if (!city) {
        error.textContent = '请输入城市名';
        return;
      }
      
      // UI 状态更新
      loading.style.display = 'block';
      error.textContent = '';
      card.style.display = 'none';
      btn.disabled = true;
      
      try {
        // 这里用模拟数据,真实环境用 getWeather(city)
        const mockResponse = await fetchWeather(city);
        const data = await mockResponse.json();
        
        // 更新 UI
        document.getElementById('cityName').textContent = city;
        document.getElementById('temp').textContent = data.temp;
        document.getElementById('condition').textContent = data.condition;
        document.getElementById('humidity').textContent = data.humidity;
        card.style.display = 'block';
        
      } catch (err) {
        error.textContent = '查询失败: ' + err.message;
      } finally {
        loading.style.display = 'none';
        btn.disabled = false;
      }
    }
    
    // 回车键触发查询
    document.getElementById('cityInput').addEventListener('keypress', (e) => {
      if (e.key === 'Enter') searchWeather();
    });
  </script>
</body>
</html>

把这个保存成 HTML 文件双击打开就能跑。代码里包含了:

  • 输入处理和 URL 编码(encodeURIComponent)
  • Loading 状态管理(防止重复提交)
  • 超时控制(AbortController)
  • 错误处理(try-catch-finally)
  • 响应数据验证

换成真实天气 API(比如和风天气、OpenWeatherMap 或者国家气象局的接口)改个 URL 就能上线,10 分钟搞定真的有。


写到这儿手指头都酸了,Fetch 这玩意儿说简单也简单,说坑也是真的坑。从当年 jQuery 的 $.ajax 到如今的 fetch,前端请求库走过了漫长的路。虽然现在很多人直接用 axios,但理解 Fetch 的底层原理对你调试网络问题、写 Chrome 插件、或者做极致体积优化的场景都很有帮助。

总之,下次同事再说"Fetch 太难用了",你就把这篇丢给他——然后记得让他请吃午饭,毕竟这么多坑都提前帮你踩平了,这交情得值顿饭吧?

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐