首页 / 资讯中心 / 文章详情

深入解析Fetch API:从设计原理到生产级封装实践

深入解析Fetch API:从设计原理到生产级封装实践 ★ FEATURED ARTICLE
1. 为什么 Fetch API 值得你花时间彻底搞懂前端圈子里有个挺有意思的现象很多人天天用fetch发请求但真被问到“它和 XMLHttpRequest 到底差在哪”“为什么我拿不到 404 的错误”“怎么取消一个请求”的时候能答利索的没几个。我自己带过几个新人几乎每个人都在fetch上踩过坑——有人以为fetch会自动 reject 非 2xx 状态码结果线上接口报 500 的时候页面直接白屏有人不知道response.json()返回的是 Promise写了个const data response.json()然后对着undefined调试了一下午。这篇东西就是把我这些年用fetch的经验、踩过的坑、以及那些文档里不会明说的细节一次性讲透。Fetch API 是 ES6 之后浏览器原生提供的网络请求接口它基于 Promise 设计用来替代老旧的 XMLHttpRequest。它能做什么简单说浏览器里所有需要跟服务端交换数据的场景——加载列表、提交表单、上传文件、流式读取——它都能干。适合谁看只要你在写前端不管你是刚入门还是已经工作两三年这篇都值得过一遍因为里面很多细节是真正到了生产环境才会暴露出来的。我写这篇不是要给你复述 MDN 文档而是想让你看完之后能直接上手写出一套健壮的请求封装知道每个参数为什么这么设出了问题该往哪个方向排查。下面我会从设计思路、核心细节、完整实操、问题排查几个维度展开尽量把“为什么”讲清楚而不是只告诉你“怎么写”。2. Fetch 的整体设计与选型思路拆解2.1 从 XMLHttpRequest 到 Fetch 的演进逻辑要理解fetch为什么长这样得先知道它要解决什么问题。早期的 XMLHttpRequest简称 XHR是事件驱动的写起来大概是这样先new一个对象然后open再挂onreadystatechange或onload回调最后send。这套写法在简单场景下还能忍但一旦涉及多个请求的串行、并行、错误处理回调就会层层嵌套也就是大家常说的“回调地狱”。fetch的核心变化是把整个请求过程抽象成一个 Promise。Promise 的好处是可以用.then链式调用也可以用async/await写成同步风格的代码。这带来的不只是写法上的简洁更重要的是错误处理路径统一了——你可以用try/catch一次性捕获整个异步流程里的异常而不是在每个回调里单独判断。另一个关键设计是fetch把请求和响应拆成了两个对象Request和Response。这两个对象都遵循 Web 标准里的 Body 混入Body mixin意味着它们共享一套读取 body 的方法比如.json()、.text()、.blob()、.arrayBuffer()。这种设计让流式处理成为可能——你可以一边接收响应一边处理数据而不必等整个响应体下载完。2.2 为什么 Fetch 基于 Promise 而不是回调这个问题其实值得展开说。Promise 相比回调最大的优势是状态不可逆和值可传递。一个 Promise 一旦 resolve 或 reject状态就固定了不会出现回调被调用多次的诡异情况。而且 Promise 的返回值可以继续.then形成一条清晰的数据管道。但 Promise 也带来一个容易误解的点fetch()返回的 Promise只在网络层面出错时才 reject比如断网、DNS 解析失败、请求被阻止。而 HTTP 层面的错误——404、500、502——都会让 Promise 正常 resolve只是response.ok为false。这个设计当初被很多人吐槽但站在标准制定者的角度它其实是有道理的HTTP 状态码是应用层的语义网络请求本身是成功的服务端确实返回了一个响应只是这个响应表示“没找到”或“服务器错误”。是否把 404 当成异常应该由业务代码决定而不是由底层 API 替你决定。理解这一点非常关键因为它直接决定了你封装请求时的错误处理策略。我见过太多人写fetch(url).then(res res.json())然后以为 404 会进 catch结果拿到一个内容为{error: not found}的 JSON页面逻辑全乱套。2.3 Fetch 与 Axios 的选型对比实际项目里很多人会在fetch和 Axios 之间纠结。我把两者的核心差异整理成一张表方便你对照自己的场景做选择。对比维度Fetch APIAxios来源浏览器原生无需安装第三方库需要引入错误处理非 2xx 不 reject需手动判断非 2xx 自动 reject请求取消需要 AbortController内置 CancelToken超时控制需要 AbortController setTimeout内置 timeout 配置请求/响应拦截需要自己封装内置拦截器自动 JSON 转换需要手动调.json()自动转换上传进度需要 ReadableStream兼容性一般内置 onUploadProgress包体积0浏览器自带约 30KBgzip 后更小兼容性现代浏览器IE 完全不支持支持到 IE11选型建议很直接如果你的项目需要兼容老浏览器或者需要上传进度、拦截器这些开箱即用的功能Axios 更省事。如果你追求零依赖、想要更贴近标准、或者在做一些需要精细控制请求流的场景比如流式读取、Service Worker 里发请求fetch是更好的选择。我个人的习惯是中小型项目直接用fetch加一层薄封装大型项目看团队习惯但即便用 Axios理解fetch的底层机制也能帮你更好地排查问题。3. Fetch 核心细节与实操要点全解析3.1 请求配置对象的每个字段到底怎么用fetch的第二个参数是一个配置对象字段不少但常用的就那么几个。我按使用频率从高到低说。method指定请求方法默认是GET。这里有个细节GET和HEAD请求不能带 body如果你传了会直接报错。POST、PUT、PATCH、DELETE可以带 body。headers用来设置请求头。它可以是普通对象也可以是Headers实例。这里有个坑请求头的值必须是字符串如果你传了数字浏览器会自动转成字符串但某些自定义头如果值不是字符串可能会被忽略。另外设置Content-Type的时候要注意如果你用FormData传文件千万不要手动设Content-Type因为浏览器需要自动生成带boundary的 multipart 头你手动设了反而会导致服务端解析失败。body是请求体支持的类型有字符串、FormData、Blob、ArrayBuffer、URLSearchParams、ReadableStream。如果你传的是普通对象浏览器不会自动序列化会直接报错。所以发 JSON 的时候必须自己JSON.stringify同时设Content-Type: application/json。mode控制跨域行为常用值有cors、no-cors、same-origin。默认是cors。no-cors模式下你只能发简单请求而且响应是 opaque 的读不到任何内容基本只在 Service Worker 里缓存资源时用。credentials控制是否携带 cookie值有omit、same-origin、include。默认是same-origin也就是同源请求才带 cookie。跨域要带 cookie 必须设成include同时服务端也要设置Access-Control-Allow-Credentials: true而且Access-Control-Allow-Origin不能是*必须是具体域名。cache控制缓存策略值有default、no-store、reload、no-cache、force-cache、only-if-cached。这个字段在调试的时候特别有用比如你想强制拿最新数据可以设no-store。signal用来关联AbortController实现请求取消这个后面单独讲。3.2 Response 对象里那些容易忽略的属性拿到response之后大部分人只关心.json()但其实 Response 对象上还有不少有用的信息。response.ok是个布尔值等价于status在 200 到 299 之间。这是判断请求是否成功最直接的方式。response.status是 HTTP 状态码response.statusText是状态文本。注意statusText在 HTTP/2 里可能为空所以不要依赖它做逻辑判断。response.headers是一个Headers对象读取的时候用.get(content-type)注意 header 名是大小写不敏感的。有个限制在跨域请求里你只能读取 CORS 安全列表里的响应头比如Content-Type、Cache-Control、Last-Modified等。如果你想读自定义头服务端必须通过Access-Control-Expose-Headers显式暴露。response.url是最终请求的 URL经过重定向之后的值。response.redirected表示是否发生过重定向。这两个在排查重定向问题时很有用。response.type表示响应类型常见值有basic同源、cors跨域、opaqueno-cors、error。opaque类型的响应读不到任何内容status也是 0。response.body是一个ReadableStream这是流式处理的关键。你可以用getReader()拿到 reader然后逐块读取数据。这在处理大文件下载或者 SSEServer-Sent Events的时候特别有用。3.3 Body 读取方法的区别与选择Response 对象提供了五个读取 body 的方法它们都会返回 Promise而且只能调用一次。一旦 body 被读取哪怕只读了一部分再调用其他方法就会报TypeError: body stream already read。.json()把 body 解析成 JSON 对象。如果 body 不是合法 JSON会 reject 一个SyntaxError。这是最常用的方法。.text()把 body 解析成字符串。适合处理纯文本、HTML、XML 等。.blob()把 body 解析成Blob对象适合处理图片、视频等二进制文件。.arrayBuffer()把 body 解析成ArrayBuffer适合处理需要底层操作的二进制数据。.formData()把 body 解析成FormData适合处理表单提交的响应。如果你需要多次读取 body可以先clone()一份。response.clone()会创建一个副本两个副本可以独立读取。但注意clone()必须在 body 被读取之前调用而且会占用额外的内存大响应体慎用。3.4 请求取消与超时控制的正确姿势fetch本身没有超时配置也没有取消方法这两个功能都要靠AbortController实现。AbortController的用法很直接创建一个 controller把它的signal传给fetch然后在需要取消的时候调用controller.abort()。这时候fetch返回的 Promise 会 reject 一个AbortError。超时控制的常见写法是用setTimeout配合abortfunction fetchWithTimeout(url, options {}, timeout 5000) { const controller new AbortController(); const timer setTimeout(() controller.abort(), timeout); return fetch(url, { ...options, signal: controller.signal }) .finally(() clearTimeout(timer)); }这里有个细节abort()之后fetch的 Promise 会 reject但如果你在finally里清理定时器要注意finally是在 Promise settle 之后执行的所以定时器一定会被清掉。另外AbortError和网络错误要区分对待因为用户主动取消不应该被当成错误上报。还有一个进阶用法AbortSignal.timeout(5000)是较新的 API可以直接生成一个超时后自动 abort 的 signal省去了手动管理定时器。不过兼容性还不算特别广生产环境用之前记得查一下目标浏览器支持情况。4. 从零封装一个生产级 Fetch 请求库4.1 基础封装统一错误处理与 JSON 解析裸用fetch最大的问题是错误处理分散每个调用点都要判断response.ok都要手动.json()。所以第一步是封装一个基础请求函数把这两件事统一掉。async function request(url, options {}) { const config { headers: { Content-Type: application/json, ...options.headers, }, ...options, }; // 如果 body 是普通对象自动序列化 if (config.body typeof config.body object !(config.body instanceof FormData)) { config.body JSON.stringify(config.body); } const response await fetch(url, config); // 非 2xx 统一抛错 if (!response.ok) { const error new Error(HTTP ${response.status}: ${response.statusText}); error.status response.status; error.response response; throw error; } // 根据 content-type 决定解析方式 const contentType response.headers.get(content-type) || ; if (contentType.includes(application/json)) { return response.json(); } return response.text(); }这段代码有几个设计决策值得说明。第一默认Content-Type设为application/json因为现在绝大多数接口都是 JSON 交互。第二自动序列化 body但排除FormData因为FormData需要浏览器自动处理。第三错误对象上挂了status和response方便上层根据状态码做不同处理。第四根据响应头的content-type决定解析方式避免对非 JSON 响应调.json()导致报错。4.2 进阶封装拦截器与请求重试基础封装解决了统一处理的问题但实际项目里还需要拦截器比如统一加 token和重试比如网络抖动时自动重试。拦截器的实现思路是在请求发出前和响应返回后插入钩子const interceptors { request: [], response: [], }; async function requestWithInterceptors(url, options) { let config { url, ...options }; // 请求拦截器 for (const interceptor of interceptors.request) { config await interceptor(config); } let response await fetch(config.url, config); // 响应拦截器 for (const interceptor of interceptors.response) { response await interceptor(response); } return response; }重试的逻辑要小心不是所有请求都适合重试。GET请求天然幂等重试安全POST请求可能产生副作用重试要谨慎。而且重试要加退避策略不能立即重试否则可能加剧服务端压力。async function fetchWithRetry(url, options {}, retries 3, delay 1000) { for (let i 0; i retries; i) { try { return await request(url, options); } catch (error) { // 只对网络错误和 5xx 重试 const shouldRetry !error.status || error.status 500; if (!shouldRetry || i retries - 1) throw error; await new Promise(resolve setTimeout(resolve, delay * Math.pow(2, i))); } } }这里的退避策略用的是指数退避第一次等 1 秒第二次 2 秒第三次 4 秒。这样既能给服务端恢复的时间又不会让用户等太久。4.3 文件上传与进度监控的实现fetch上传进度一直是个痛点因为标准里没有直接提供上传进度事件。不过较新的浏览器支持通过ReadableStream包装请求体来实现进度监控但兼容性和实现复杂度都不太友好。更实际的做法是如果上传进度是硬需求用XMLHttpRequest或者 Axios如果只是普通文件上传fetch配合FormData完全够用。async function uploadFile(url, file, onProgress) { const formData new FormData(); formData.append(file, file); // 注意不要手动设 Content-Type const response await fetch(url, { method: POST, body: formData, }); return response.json(); }如果确实需要进度可以用XMLHttpRequest包一层对外暴露 Promise 接口这样既保留了fetch风格的调用又能拿到进度事件。这种混合方案在实际项目里很常见不用觉得“用了 fetch 就不能用 XHR”。4.4 流式读取与大文件下载fetch相比 XHR 的一个显著优势是支持流式读取。response.body是一个ReadableStream你可以逐块读取数据边读边处理。async function downloadWithProgress(url, onChunk) { const response await fetch(url); const contentLength response.headers.get(content-length); const total contentLength ? parseInt(contentLength, 10) : 0; let loaded 0; const reader response.body.getReader(); const chunks []; while (true) { const { done, value } await reader.read(); if (done) break; chunks.push(value); loaded value.length; if (onChunk) onChunk(loaded, total); } // 合并所有 chunk const allChunks new Uint8Array(loaded); let position 0; for (const chunk of chunks) { allChunks.set(chunk, position); position chunk.length; } return allChunks; }这段代码的关键点是reader.read()返回的value是Uint8Array需要自己合并。content-length响应头可能不存在比如分块传输所以要做兜底。流式读取的另一个用途是处理 SSE服务端持续推送数据客户端逐条解析这在实时通知、日志监控等场景很有用。5. 常见问题与排查技巧实录5.1 跨域问题的完整排查路径跨域是fetch最常见的报错来源但浏览器的报错信息往往很模糊只说“CORS policy blocked”不告诉你具体哪里不对。我整理了一套排查路径。第一步看请求有没有发出去。打开 Network 面板如果请求显示为(blocked: origin)或者根本没出现说明是预检请求OPTIONS失败了。预检失败通常是服务端没有正确响应 OPTIONS 请求或者Access-Control-Allow-Headers没有包含你自定义的头。第二步看响应头。如果请求发出去了但响应被拦截检查Access-Control-Allow-Origin是否匹配当前源。注意这个头不能是*如果带了 credentials。第三步看 credentials 配置。如果前端设了credentials: include服务端必须设Access-Control-Allow-Credentials: true且Access-Control-Allow-Origin必须是具体域名。第四步看自定义头。如果你发了Authorization之类的头服务端必须在Access-Control-Allow-Headers里列出来。报错关键词可能原因排查方向No Access-Control-Allow-Origin服务端没设 CORS 头检查服务端配置Credentials flag is true带 cookie 但 Allow-Origin 是 *改成具体域名Request header field X is not allowed自定义头未在 Allow-Headers 中服务端补充Method PUT is not allowed预检未允许该方法检查 Allow-MethodsPreflight response is not successfulOPTIONS 请求返回非 2xx服务端处理 OPTIONS5.2 “body stream already read” 的成因与解决这个报错的意思是 body 已经被读过了。常见场景有两个一是你在拦截器里读了response.json()然后在业务代码里又读了一次二是你在错误处理里读了 body 做日志然后又把 response 抛给上层。解决办法是在读取之前先clone()const response await fetch(url); const clonedResponse response.clone(); // 一份用于日志 const logData await clonedResponse.text(); console.log(Response body:, logData); // 一份用于业务 const data await response.json();但要注意clone()会占用额外内存大响应体慎用。更好的做法是在拦截器里只读必要的信息比如状态码、部分头不要读整个 body。5.3 请求被浏览器缓存导致的“数据不更新”有时候你改了服务端数据刷新页面发现还是旧的这很可能是浏览器缓存了 GET 请求。fetch默认遵循 HTTP 缓存策略如果服务端返回了Cache-Control: max-age3600那一个小时内都会用缓存。解决办法有几个一是服务端设置合适的缓存头二是请求时加时间戳参数?t${Date.now()}三是fetch配置里设cache: no-store。我一般推荐第一种因为缓存本身是好事不该因为调试方便就全局禁用。调试的时候临时用第二种或第三种就行。5.4 常见问题速查表问题现象根本原因解决方案404 不进入 catchfetch 只在网络错误时 reject手动判断 response.ok拿不到自定义响应头CORS 未暴露服务端设 Access-Control-Expose-HeadersPOST 请求 body 丢失没设 Content-Type 或没序列化设 application/json 并 JSON.stringify上传文件服务端解析失败手动设了 Content-Type删掉手动设置让浏览器自动生成abort 后报错未捕获AbortError 未被处理catch 里判断 error.name AbortError跨域 cookie 不生效credentials 配置或服务端头不对前端 include服务端 Allow-Credentials大文件下载内存暴涨一次性读取整个 body改用流式读取逐块处理5.5 几个我踩过的坑和独家心得第一个坑fetch在页面卸载时发出的请求会被浏览器取消。如果你在beforeunload里发请求上报数据很可能发不出去。解决办法是用navigator.sendBeacon它专门用于这种场景浏览器会保证请求发出。第二个坑fetch的keepalive选项。它可以让请求在页面卸载后继续但有大小限制64KB而且不能和流式 body 一起用。适合发送小的埋点数据。第三个坑response.json()在响应体为空时会报错。有些接口返回 204 No Content这时候调.json()会抛SyntaxError。稳妥的做法是先判断response.status 204或者content-length 0直接返回 null。第四个坑AbortController的 signal 可以复用但一旦 abort 就不能再用了。如果你要发多个请求共用一个 controllerabort 会取消所有关联的请求。这个特性可以用来实现“页面切换时取消所有进行中的请求”。第五个坑fetch在 Service Worker 里的行为和页面里略有不同比如credentials的默认值可能不一样。写 PWA 的时候要特别注意。6. 把 Fetch 用好的几个关键认知6.1 理解 Fetch 的“底层”定位fetch不是一个开箱即用的请求库它是一个底层 API。这意味着它把很多决策权交给了开发者错误怎么处理、超时怎么控制、重试怎么做、拦截器怎么实现都需要你自己设计。这既是负担也是自由。负担在于你要写更多代码自由在于你可以完全按照项目需求定制不被第三方库的设计束缚。我的建议是不要试图把fetch封装成一个“万能请求库”那样只会越封越复杂。更好的做法是根据项目实际需求封装一层薄薄的、刚好够用的工具函数保持简单可维护。6.2 错误处理的分层策略一个健壮的请求层应该有清晰的错误分层。我一般分三层网络层错误断网、DNS 失败、超时、HTTP 层错误4xx、5xx、业务层错误响应体里的 code 字段表示业务失败。网络层错误由fetch的 reject 捕获HTTP 层错误由response.ok判断业务层错误由业务代码根据响应体内容判断。三层错误应该有不同的处理策略网络层错误可以提示“网络异常请检查网络”HTTP 层 401 可以跳登录403 可以提示无权限5xx 可以提示“服务繁忙”业务层错误则根据具体 code 做对应处理。6.3 性能与体验的平衡fetch的性能优化有几个方向。一是合理利用缓存GET 请求该缓存就缓存减少重复请求。二是并发控制同时发太多请求会拖慢页面可以用Promise.all配合分批或者用队列控制并发数。三是请求去重同一个接口在短时间内被多次调用时可以复用同一个 Promise避免重复请求。体验方面加载状态、错误提示、重试按钮这些都要考虑。fetch本身不提供这些但配合 UI 框架的状态管理可以做出很流畅的交互。6.4 兼容性与降级方案fetch在现代浏览器里支持很好但如果你需要兼容 IE 或者很老的移动端浏览器就需要 polyfill。常见的方案是引入whatwg-fetch它用 XHR 模拟了fetch的接口。但要注意polyfill 无法完全模拟所有特性比如流式读取、AbortController在旧浏览器上可能不支持需要额外的 polyfill。我的经验是如果目标用户里有超过 5% 还在用不支持fetch的浏览器就老老实实用 Axios如果只是极少数可以用 polyfill 加降级提示。不要为了技术先进性牺牲用户体验。6.5 安全相关的注意事项用fetch的时候有几个安全点要注意。一是不要在前端代码里硬编码敏感 token虽然这是常识但还是经常看到。二是注意 CSRF 防护带 cookie 的请求要配合 CSRF token。三是注意响应内容的处理不要直接把服务端返回的 HTML 插入 DOM防止 XSS。四是跨域请求要严格校验来源不要盲目信任第三方接口返回的数据。这些不是fetch特有的问题但用fetch的时候因为配置灵活更容易不小心引入安全漏洞。比如mode: no-cors虽然能发请求但响应不可读如果用来做数据上报要确保不会泄露敏感信息。7. 写在最后的一些个人体会fetch这个 API 我用了很多年从最开始觉得它比 XHR 简洁到后来被它的各种“坑”折腾再到现在能比较从容地处理各种场景中间走了不少弯路。回头看最大的体会是不要把它当成一个黑盒要理解它背后的设计哲学。它为什么不在 404 时 reject为什么 body 只能读一次为什么没有超时配置这些设计都有其道理理解了这些道理你就能预判它的行为而不是每次遇到问题都去搜答案。另一个体会是封装要克制。我见过太多项目把fetch封装得无比复杂拦截器、重试、缓存、并发控制全塞进去结果新人接手根本看不懂出了问题也不知道从哪查。好的封装应该是透明的让调用者清楚知道发生了什么而不是隐藏所有细节。最后分享一个小技巧调试fetch的时候可以在 Network 面板里右键请求选择“Copy as fetch”浏览器会生成一段可以直接在控制台运行的fetch代码包含所有请求头和 body。这在复现问题时特别有用省得你手动拼参数。这个功能我几乎每天都在用强烈推荐。
阅读完成 · 觉得有帮助?
咨询建站