资讯动态

用 Fetch 彻底替代 XMLHttpRequest:7 天学习路线与封装实战

发布时间:2026/10/19 0:08:02 来源:尧图企业网站定制
如果你刚接触前端大概率已经在项目里见过或者写过这样一段代码new XMLHttpRequest()、onreadystatechange、readyState 4 status 200……每次看到这一连串状态判断我都会冒出一个想法都什么年代了还在用这套老古董写法。这篇文章要聊的就是怎么用 Fetch 彻底替换掉 XMLHttpRequest我会把 7 天的学习路线、底层原理、常见坑位和可以直接抄进项目的封装代码一次性讲透。这篇文章既适合刚入门前端、连 Promise 都还没搞懂的小白也适合已经会用 XHR 但想赶紧迁移到 Fetch 的开发者读完你至少能把日常 90% 的请求场景安全切换过去。1. 为什么 Fetch 值得学XMLHttpRequest 的短板和 Fetch 的对应解法1.1 XHR 写起来到底有多痛先别急着背 Fetch 的语法我们得先知道自己为什么要离开 XMLHttpRequest。很多人对 XHR 不满第一反应是“代码太丑”但丑只是表面真正的问题是它把简单事情搞复杂了。一个最简单的 GET 请求你要走完这样一套流程const xhr new XMLHttpRequest(); xhr.open(GET, /api/user); xhr.onreadystatechange function () { if (xhr.readyState 4) { if (xhr.status 200) { const data JSON.parse(xhr.responseText); console.log(data); } else { console.error(请求失败, xhr.status); } } }; xhr.send();这段代码里有几个特别容易出错的地方readyState有 0 到 4 一共五种状态你得自己记onreadystatechange会在状态变化的每个阶段被触发你得在回调里反复判断响应文本要自己JSON.parse。更崩溃的是如果两个请求之间有依赖关系比如先拿用户信息、再用用户 ID 拿订单列表代码就会一层套一层。我见过有人把三层 XHR 嵌套写在一个函数里调试的时候根本分不清哪个回调对应哪个请求只能在每个回调里打印日志定位那种体验真的很糟。1.2 Fetch 的对应改进到底改在哪Fetch 最大的变化是它把“发请求”这件事变成了一次 Promise。同样一个 GET 请求用 Fetch 写是这样const res await fetch(/api/user); if (!res.ok) { throw new Error(请求失败状态码 res.status); } const data await res.json(); console.log(data);你发现没有readyState没了onreadystatechange没了手动JSON.parse也没了。你只需要关心两个问题请求有没有成功res.ok响应内容怎么读res.json()。多个请求需要按顺序执行时只要写成多个await就行。配合try/catch整个数据获取过程的逻辑看起来特别干净跟读同步代码一样。我用一张表对比一下两者在各个维度上的差异方便你把“为什么要换”这件事记得更牢对比维度XMLHttpRequestFetch回调方式事件回调状态判断繁琐Promise天然支持 async/await请求状态需自行判断 readyState 各阶段网络层失败自动 reject业务状态看 response.ok响应解析手动 responseText JSON.parseres.json() / res.text() / res.blob() 等方法请求头设置setRequestHeader 逐个设置headers 对象统一传入超时控制原生 timeout 属性需要配合 AbortController上传进度原生 upload.onprogress无原生进度事件需要流读取模拟看到这里你可能想问既然 XHR 有超时有进度Fetch 啥都没有凭什么说它更好因为 Fetch 的设计目标不是做一个“功能大礼包”而是把请求的底层模型重新梳理了一遍。它基于标准化的 Request 和 Response 对象让前端可以和流Stream打交道未来演进空间比老接口大得多。至于超时、取消这类能力不是没有而是交给了更底层的 AbortController 来做后面我会专门演示。2. 7 天学习路线每天动手做什么这一节是标题里“7天”的落地版。我不打算给你一张写着“第一天看文档、第二天看视频”的划水计划而是按前端最常见的真实开发顺序把每天的任务拆成“学什么、练什么、验证什么”。你只要按这个节奏走一周后完全可以把项目里的 XHR 都换成 Fetch。2.1 Day 1先跑通第一个 GET 请求第一天只干一件事让浏览器真正发出一个请求。最省事的办法是打开浏览器控制台直接敲fetch(/api/test)看 Network 面板里是不是多了一条请求。但这需要你本地有一个能访问的接口如果没有后端同事我建议你写一个本地 JSON 文件或者在任意一个静态文件服务器上放一个data.json。实在找不到接口的话找一个支持回显数据的公开测试接口也行这一步的核心是“看到请求真的发出去”。第一天不用写复杂代码只需要理解三段内容fetch(url)返回一个 Promise这个 Promise 会在响应头到达时进入 resolved 状态真正读响应体还要再调用一次.then(res res.json())。为了加深印象你可以在控制台里做两个实验第一次只打印res看看它里面有什么字段第二次再打印res.json()的结果对比一下两次输出的差异。这个小实验能帮你建立“响应头”和“响应体”是两回事的直觉。2.2 Day 2GET 传参和 POST 提交第二天开始接触日常开发最常用的两种请求方式。GET 请求的参数不能放在 body 里而是拼在 URL 上比如/api/user?id1。如果参数比较多建议用 URLSearchParams 来拼接这样问号和 符号不会出错const params new URLSearchParams({ page: 1, size: 10 }); fetch(/api/list? params.toString());POST 请求则是在fetch(url, options)的第二个参数里配置method和body。这里最容易踩的坑是body 必须是字符串不能直接塞一个 JavaScript 对象。你得先JSON.stringify(data)还要在 headers 里设置Content-Type: application/json否则后端拿到的一堆[object Object]。我建议你在 Day 2 专门体验一次“故意不设置 Content-Type”的请求然后去 Network 面板看请求头你就会真正记住这个细节。2.3 Day 3读懂响应体第三天别急着写更多请求先把“响应”这件事弄扎实。重点理解response对象上的这些内容status是状态码ok是状态码是否在 200-299 之间的布尔值headers是响应头json()、text()、blob()是读取响应体的不同方法。我给你的练习题是请求一个接口分别打印它的status、ok、headers.get(content-type)再调用一次res.text()看看原始内容。如果你找到一个 404 的接口试一下会发现一个反直觉的现象Fetch 遇到 404 并不会报错它依然会正常返回一个响应对象只是res.ok变成了 false。这个知识 Day 3 记不住没关系第 4 节踩坑里我还会再展开一次。2.4 Day 4用 async/await 把代码拉平到了第四天你已经能写出.then(res res.json())这种链式代码了。但链式代码一旦夹杂业务判断还是会显得乱。这一天专门练习用async和await重构之前的代码把两段等价的写法放在一起对比// 链式写法 fetch(/api/user).then(res res.json()).then(data console.log(data)); // async/await 写法 async function loadUser() { const res await fetch(/api/user); const data await res.json(); console.log(data); }你会发现await就像一个“暂停键”它让异步代码看起来像同步代码一样往下走。记得用try/catch把整段包起来否则网络错误会变成一个未捕获的 Promise 异常。这一天你还要做一个小练习写一个函数先请求用户信息再用拿到的 ID 请求第二条数据如果全程只用await不写.then你会发现代码的可读性提升非常明显。2.5 Day 5用 Fetch 做一个完整小功能前四天都是在练碎片第五天必须拼起来。我推荐做一个“留言板”或者“待办事项列表”因为这两个场景天然包含列表加载、新增、删除三种请求。界面不用复杂一个输入框加一个列表就够了。功能要求是页面加载后用 GET 请求拉取已有数据并渲染提交内容后用 POST 新增一条成功后重新拉列表每条数据后面放一个删除按钮点击后发送 DELETE 请求这个练习的意义在于你会第一次体会到“请求成功后刷新页面”的完整闭环。你会发现很多问题只有在真实交互里才会暴露比如新增成功后如果你不重新拉取列表用户看到的页面就不会变化又比如并发请求时先返回的数据后到页面数据被旧值覆盖。这一天的成果本质上就是你全栈前端开发里数据层的最小原型。2.6 Day 6把公共逻辑抽出来到了第六天你应该已经发现自己写的每个页面都在重复做几件事拼接 URL 前缀、加请求头、判断res.ok、统一弹错误提示。这时候就轮到“封装请求函数”了。我是强烈建议把所有项目里的 fetch 调用都收拢到一个request.js文件里的这样后续改接口前缀、改鉴权头、加超时控制都只需要动一个文件。你可以先做一个最简版本导出一个request(url, options)函数内部统一拼 baseURL、统一处理 JSON、统一抛错。我后面第 5 节会给出完整实现今天你只需要理解“为什么需要这层封装”就够了。你可以把 Day 5 的留言板代码全部改成调用request能明显感觉到页面代码变短。2.7 Day 7处理上传和收尾沉淀最后一天解决两个问题文件上传怎么做内容怎么沉淀。文件上传用 FormData 是最方便的直接把 input 文件塞进去不用手动设置 Content-Type浏览器会自动带上 boundaryconst formData new FormData(); formData.append(file, fileInput.files[0]); await fetch(/api/upload, { method: POST, body: formData });然后花半小时做知识梳理把七天里遇到的所有报错、所有踩坑点整理成一份笔记。我始终认为学习前端请求相关的东西最有价值的产出不是你写了几百行代码而是你对“请求失败可能发生哪些环节”了如指掌。到这一步你已经完成了从 0 到 1 的速成。3. Fetch 的底层逻辑Promise、Request 和 Response3.1 一次 fetch 请求走过的完整链路很多小白用 Fetch 学得云里雾里原因是只记住了“三步走”但完全不知道这三步为什么是这三步。我打个比方fetch(url)就像在餐厅点单。你大喊一声服务员发起请求服务员给你一张小票这张小票就是 Promise。注意小票不等于菜它只是“将来可以取菜”的凭证。过了一段时间后厨把菜做好了服务员通知你“可以来端菜了”这个动作就是 Promise 的 resolve而这个时候你手里拿到的response对象相当于“餐盘已经放到取餐口了”。可是餐盘放在取餐口菜还在盘子里你得伸手把菜端到自己桌上这个“伸手端菜”的动作恰恰就是await res.json()或await res.text()。为什么不在fetch()返回的时候直接把数据给你因为响应体是流式到达的浏览器不确定数据什么时候全部传完所以它只能先给你一个“取餐凭证”等你准备好读取了再去消费这个流。理解了这条链路你就能明白为什么json()也是一个异步操作为什么它不等于JSON.parse(res.text())那么简单。3.2 Promise 状态和 fetch 的 reject 条件Fetch 返回的 Promise 只有少数几种情况会变成 rejected你一定要记住这几个边界否则会把错误处理写错。第一种是网络层就失败了比如断网、DNS 解析失败、连接被重置第二种是请求被 CORS 策略拦截浏览器直接不把响应交给你的代码第三种是你主动用 AbortController 取消了请求。除此之外哪怕服务器返回 500、404这个 Promise 也都是 resolved 状态只是response.ok为 false状态码会体现在response.status上。这就带出一个非常重要的写代码习惯不要只写catch你得在每个响应后面先判断res.ok。我见过太多新人以为“走到 catch 里就万事大吉”结果接口 500 的时候程序还继续往下跑后面读到一堆残缺数据找 bug 找半天。严谨的做法是封装一层统一的响应校验凡是!res.ok就主动 throw把问题抛给上层逻辑处理。3.3 Response 对象的常用属性和方法我把 Response 对象上最常用的东西整理成一张表你不需要全部背下来先混个脸熟后面用到再回来看属性/方法作用使用注意res.statusHTTP 状态码例如 200、404、500res.ok状态码是否在 200-299最常用的成功判断res.statusText状态码对应的文本一般不依赖它做业务res.headers响应头对象可调用 get() 取某个头部res.url请求最终地址重定向后能看到真实地址res.json()把 body 解析为对象body 只能消费一次res.text()把 body 解析为字符串调试时建议优先使用res.blob()把 body 解析为二进制数据适合下载文件、图片res.arrayBuffer()把 body 解析为 ArrayBuffer适合处理视频、音频等res.clone()复制一份 response可以缓解“body 只能读一次”的问题这里最值得提醒的就是json()和text()这些方法会消费 body 流。什么意思就是你只能读取一次。如果先调了res.json()再去调res.text()会得到一个错。排查这种问题的方法很简单先用res.text()把原始字符串打印出来看一眼确认确实是 JSON再换回res.json()或者用res.clone()保留一份副本再去消费。很多前端新手在这个问题上卡一晚上就是因为没看懂 body 是流的实质。4. 踩坑实录Fetch 最容易被坑的五个细节4.1 POST 请求后端说收不到参数但 Network 面板里明明有 body这是我带人时遇到频率最高的问题。前端跑得好好的request payload 里也有数据后端同事却一直说“收到的是 undefined 或者空对象”。排查链路是这样的先看 Network 面板里请求头的 Content-Type再对比后端框架期望的类型。如果你发的是 JSON 字符串但 Content-Type 是text/plain;charsetUTF-8后端不会帮你自动解析如果你用的是application/x-www-form-urlencoded后端又可能是按 JSON 解析的。所以前端这边必须和后端约定好发送 JSON 就务必带上Content-Type: application/json并确保 body 是JSON.stringify之后的字符串。我自己的排查小技巧是在后端接口里临时加一行日志把收到的原始 body 打印出来。如果看到一串 JSON 字符串说明前端发送没问题如果看到[object Object]那一定是没JSON.stringify如果直接是空那就检查 body 拼写是不是传错变量了。这种“先确认原始报文”的思路比看得见摸不着的“参数丢失”要高效得多。4.2 遇到 404 和 500 时fetch 不会自动报错这个坑我在 Day 3 提过实战里依然很多人翻车。表现是接口明明 404 了代码里却没有走catch而是继续往下执行页面展示出一堆错误的 UI 状态。原因并不玄乎Fetch 的 Promise 只在网络层失败时 rejectHTTP 状态码在它眼里只是“服务器给我了一个响应”至于是 200 还是 500那都是业务问题。解决思路是做一个强制抛错的中转层。最简洁的写法是这样async function request(url, options) { const res await fetch(url, options); if (!res.ok) { const message 请求失败${res.status} ${res.statusText}; throw new Error(message); } return res.json(); }这样就把“HTTP 错误也会变成异常”这层逻辑集中处理了。你业务代码里只需要写一个 try/catch所有请求层面的问题都能统一兜住。4.3 跨域请求明明配置了浏览器还是报 CORS 错误前端遇到 CORS 报错时第一反应往往是去改mode: cors。但你要明白CORS 不是前端单方面就能解决的。真正决定请求能不能被浏览器放行的是后端响应头里的Access-Control-Allow-Origin。如果后端没有返回这个头浏览器就会拦截响应你的代码里看到的错误信息只会是“Failed to fetch”。有一种做法是设置mode: no-cors我不建议业务代码里用。因为no-cors会让请求变成 opaque 模式你拿不到任何响应数据这完全违背了前端请求的初衷。解决跨域的正确路线有两类一类是让后端在网关层配好 CORS 白名单和允许的请求方法另一类是前端走本地开发代理把/api前缀的请求转发到后端服务器这样浏览器看到的只是同源请求。开发环境用代理是最省事的线上环境一定要靠后端配置千万别指望mode参数能救你。4.4 fetch 默认不会带 Cookie鉴权信息容易悄悄丢失某个项目里前端登录成功了Cookie 也种在浏览器里了但后续的 fetch 请求后端就是识别不了用户。排查会发现请求头里压根没有 Cookie。原因是 Fetch 在默认情况下不会发送同源 Cookie你必须手动在请求里声明fetch(/api/user, { credentials: include });credentials有三个取值same-origin表示同源时携带 Cookie这是默认行为include表示无论跨域还是同源都携带omit表示完全不携带。如果你在跨域场景下需要带 Cookie除了前端设置credentials: include后端还要把Access-Control-Allow-Credentials设为 true并且指定明确的跨域来源而非*。我把这个放在封装函数里作为默认配置可以避免每个页面重复去踩。4.5 没有超时机制接口卡住时页面会一直白等XMLHttpRequest 有xhr.timeout属性Fetch 却没有内置超时。刚刚迁移到 Fetch 的团队最容易漏掉的问题就是请求发出去后后端一直不响应前端就一直转圈既不报错也不继续。解决思路是利用 AbortController 和定时器手动切断请求。function fetchWithTimeout(url, options {}, timeout 10000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }).finally(() { clearTimeout(timer); }); }这段代码会在超时后主动中止请求进而让 Promise 进入 rejected 状态你的catch就能捕获到错误。要注意的是 AbortController 在较老浏览器里可能不受支持所以如果你要兼容老环境需要一个兼容方案后面第 6 节我会聊到。5. 动手封装一个能直接用的 request 函数5.1 为什么必须封装一层我在多个项目里看到过一种现象刚迁到 Fetch 的新人每个页面都直接写fetch(url)代码似乎挺干净的可一旦后端接口前缀换了他需要打开十几个文件去改 URL一旦登录过期需要统一跳转登录页每个页面还得自己写一遍判断。这说明没有封装层的 Fetch 只是“把 XHR 换了个写法”并没有真正解决工程化问题。封装的核心目标不是炫技而是把“请求这件事里所有可变的、可复用的逻辑”集中到一个地方。我在带新人时有一个要求页面上不允许出现裸fetch()调用所有请求都必须经过项目的request函数。这样做的收益是肉眼可见的统一加 loading、统一处理错误、统一带 token、统一拼接 baseURL这些都是一个入口就能搞定的。所以本节不是给你一个“高级工具函数”而是给你一个“省事保险方案”。5.2 request 函数完整实现下面我给你一份可以直接复制到项目里改改就用的实现。我在代码里加了比较详细的注释把每一块逻辑的原因都标清楚// request.js const BASE_URL /api; // 统一接口前缀改成你的实际地址 async function request(path, options {}) { // 1. 统一拼接 baseURL避免每个页面拼错 const url BASE_URL path; // 2. 默认开启携带同源 Cookie接口鉴权多半靠它 if (!options.credentials) { options.credentials same-origin; } // 3. 统一添加内容类型发 JSON 时省得每次重复写 if (options.body typeof options.body ! string !(options.body instanceof FormData)) { options.body JSON.stringify(options.body); options.headers { Content-Type: application/json, ...options.headers, }; } // 4. 如果有 token自动带上 Authorization 头 const token localStorage.getItem(token); if (token) { options.headers { ...options.headers, Authorization: Bearer token, }; } // 5. 超时控制默认 10 秒 const controller new AbortController(); const timer setTimeout(() controller.abort(), options.timeout || 10000); const signal options.signal || controller.signal; try { const res await fetch(url, { ...options, signal }); // 6. 统一判断 HTTP 状态不是 2xx 一律抛异常 if (!res.ok) { const errorBody await res.text(); throw new Error(请求失败 ${res.status}: ${errorBody}); } // 7. 自动尝试解析 JSON const contentType res.headers.get(content-type) || ; if (contentType.includes(application/json)) { return res.json(); } return res.text(); } finally { clearTimeout(timer); } } // 常用的请求方法快捷导出 export const http { get: (path, options) request(path, { ...options, method: GET }), post: (path, body, options) request(path, { ...options, method: POST, body }), put: (path, body, options) request(path, { ...options, method: PUT, body }), delete: (path, options) request(path, { ...options, method: DELETE }), };有了这个封装页面代码立刻瘦身。以登录为例import { http } from ./request; async function login(username, password) { const user await http.post(/login, { username, password }); console.log(登录成功, user); return user; }我不再关心Content-Type、不再关心 token、不再关心res.ok这些全部在 request 里处理好了。这种写法项目维护起来特别舒适尤其是接口数量上到几十个以后你会感谢当初封装这层。5.3 集成到项目后的进阶思路上面这份实现能满足 80% 的需求但真实项目还有几件事值得做。第一是响应拦截器你可以定义一个全局函数在后端返回 401 时统一跳转登录页在后端返回业务错误码时统一提示用户。第二是请求拦截器比如每次请求前都去换取最新的 token或者校验用户是否已登录未登录直接拦截。第三是取消请求的语义化如果页面在请求返回前就被卸载可以在useEffect的清理函数里调用controller.abort()防止组件卸载后还在setState导致的内存泄漏警告。你可以把 request 函数理解成流水线的入口所有请求都从这条流水线过以后想加什么能力只需要在流水线上加一道工序。我团队里的通用 request 已经迭代了好几版从最初只有十几行到后来支持了多文件上传、请求重试、轮询接口等能力但底层骨架始终都是上面这份代码的逻辑。6. 什么时候还得用 XMLHttpRequest兼容与边界6.1 功能检测而不是浏览器检测既然标题说“彻底告别 XMLHttpRequest”我也得把话说客观在某些老环境里你依然绕不开 XHR。最典型的就是旧版浏览器没有原生 Fetch。做兼容的正确姿势不是写navigator.userAgent去判断浏览器品牌而是做功能检测if (window.fetch) { // 可以用 Fetch fetch(/api/v1/data).then(...); } else { // 老环境回退到 XHR 或者引入垫片 }你可以自己写一个极简垫片用 XHR 实现一个类似 fetch 的函数返回 Promise 结构。核心逻辑是把xhr.onreadystatechange里的成功和失败分别映射为 Promise 的 resolve 和 reject再包装一下响应对象。不过我更推荐直接引入成熟的 fetch polyfill 库它们已经把各种边界情况处理得很完善了。但引入 polyfill 不等于你可以不学 Fetch因为 polyfill 只是“让老环境能跑”开发时你依然用新语法。6.2 上传进度仍然是 XHR 的舒适区另一个场景是上传大文件需要显示进度条。XHR 有原生的upload.onprogress事件你能拿到已经上传的字节数Fetch 目前没有提供同等级的进度 API你要么通过读取请求体的流来模拟要么自己记录分片上传的进度。如果你只是上传一个几十 KB 的图标这点差异无所谓但如果你做的是视频文件、备份文件这类动辄几百 MB 的上传模块上传进度的重要性会直接拉满。这个场景下老项目继续用 XHR 是合理的不需要为了“潮流”硬切 Fetch。6.3 新项目优先选 Fetch老项目别强行重构最后一条边界是我踩过坑之后总结的老项目里如果已经有一套稳定运行的 XHR 封装并且没有明显痛点不要为了用 Fetch 而用 Fetch。我曾经接手过一个跑了好几年的后台系统里面所有请求都走一个自定义 XHR 封装还能处理队列、缓存和轮询。如果贸然全部改成 Fetch不仅要适配那几处进度条逻辑还要重新验证鉴权、超时、错误提示等一整条链路成本高且风险大。合理做法是新模块优先 Fetch老模块保持稳定等某一天那块模块真需要重构时再顺带切换。反过来新项目我几乎找不到理由还在用 XHR。现在主流浏览器早就原生支持 Fetch团队里新人学习成本也更低Promise 天然契合现代前端框架的数据流。选择一个方案重点不是看它是不是“最新”而是看它能不能让你的代码更可控、更容易维护。从这个角度看Fetch 就是当下前端请求的新默认选项。最后再分享一个小体会我带过不少新人发现真正让他们卡住的往往不是 fetch 的语法而是“响应体只能读一次”和“HTTP 状态码不会自动 reject”这两个隐藏在表面下的特性。如果你能把第 3 节的流水线模型和第 4 节那几个坑真正理解透再上手任何请求封装都会顺手很多。技术选型这件事没必要被“告别 XX”之类的口号绑架但在 2026 年的今天新代码里还在大面积写new XMLHttpRequest()确实有点说不过去了。这篇内容没有固定的模板你只需要照着 Day 1 到 Day 7 的节奏推进一周后回来再看这段话应该会有完全不一样的感受。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑