资讯动态

Vue项目中从axios到fetch的迁移实践与封装指南

发布时间:2026/10/14 16:35:04 来源:尧图企业网站定制
最近在组件化开发里一直在做前后端数据联调发现不少同事还在用axios一把梭明明浏览器原生就提供了一个足够好用的fetch接口。倒不是说axios不好只是对于很多中后台项目来说fetch的轻量和原生特性完全够用而且省掉一个依赖就少一份维护成本。这篇笔记就把我在Vue项目里从axios切到fetch的完整心得、封装思路和踩过的坑一次性整理出来。我把这套东西沉淀成了自己的工作流基础用法怎么用、统一封装怎么做、大文件上传这类特殊场景怎么处理、以及我最容易栽跟头的几个边界情况。无论你是刚接触接口调用的新手还是准备给项目减负的资深开发者这篇应该都能给你一些参考。1. 为什么我决定在Vue项目里放弃axios改用fetch先交代一下背景。我之前在做一个某内部管理系统前后端完全分离接口都是标准的RESTful风格没有太复杂的鉴权逻辑也不需要文件上传进度条这类额外功能。最初用的确实是axios后来一个偶然的机会把几个页面改用fetch重构了一下发现代码量降了不少可读性也更好于是干脆整个项目都迁了过去。1.1 fetch的天然优势轻量和原生很多人一说fetch就觉得它太底层、不好用其实恰恰相反。axios本质上也是在XMLHttpRequest之上包了一层Promise而fetch是浏览器直接提供的原生API基于Promise设计天然就是异步的。这意味着你不需要额外引入任何库直接在浏览器控制台敲一行就能验证fetch(/api/user/list) .then(res res.json()) .then(data console.log(data))就这么简单。一个GET请求两行代码就拿到了JSON数据。传统的XMLHttpRequest写同样的事情要实例化对象、监听onreadystatechange、判断readyState和status逻辑绕来绕去代码量至少翻一倍。而axios虽然帮你封装好了但换来的是包体积和使用成本。用fetch之后Vue项目的构建体积能砍掉一部分依赖首屏加载会略微快一点。这部分对用户体验的提升不是决定性的但能给后续的维护省下不少心——至少你不必再纠结axios的拦截器配置是不是写得太玄学了。1.2 什么时候fetch不一定是最优解当然我必须诚实地把fetch的短板也列出来否则就是误导新手。如果你遇到下面这几种情况继续用axios可能是更稳妥的选择项目里已经有大量基于axios拦截器的代码迁移成本远大于收益需要XMLHttpRequest特有的能力比如上传文件时的实时进度上报fetch原生不提供这个能力需要支持非常老旧的浏览器fetch在IE上完全不可用需要额外polyfill所以我的建议是新项目、中小型项目、接口规范清晰的内部系统直接上fetch老项目、重交互、强依赖请求进度和拦截器生态的维持axios不动。1.3 选型对比fetch 与 axios 的直观差异下面这张表是我在做迁移决策时自己整理的比较直观地展示了fetch和axios在这几个关键维度的差异对比维度fetchaxios包体积0浏览器原生约 14KB压缩后请求取消AbortController内置 CancelToken拦截器无原生支持需自行封装原生支持请求/响应拦截器上传进度不支持需另想方案支持 onUploadProgress超时控制需结合 AbortController 实现内置 timeout 配置错误处理只有网络错误会 rejectHTTP状态码需手动判断非2xx状态码自动进入 reject默认请求头无 Content-Type需手动设置自动序列化 JSON 并设置请求头看完这张表你会发现fetch的优势和劣势都集中在同一个根源上——它足够原生所以给了你完全的掌控力但同时也把很多本该由封装层做的事情还给了你。只要自己动手补上超时、错误码判断这些基础能力fetch用起来完全不输axios。2. fetch基础用法拆解从GET到POST再到文件上传理论说完了直接上手。这一部分我会把fetch在接口联调中最常用的几种姿势完整过一遍每一步都会说明为什么这么做而不是简单贴代码。2.1 GET请求必须掌握的Response处理链路先看一个完整的GET请求应该怎么写注意这里我已经把日常项目里需要的参数都带上了async function getUserList(params) { // 拼接查询参数 const queryString new URLSearchParams(params).toString() const response await fetch(/api/user/list?${queryString}, { method: GET, headers: { Content-Type: application/json, }, }) // 第一步检查网络层状态 if (!response.ok) { throw new Error(HTTP error! status: ${response.status}) } // 第二步解析响应体 const data await response.json() return data }这里最值得新手注意的就是response对象的三层结构。很多人第一次用fetch拿到response之后直接response.body取数据结果拿到一个ReadableStream一脸懵。实际上fetch返回的response首先是一个描述整个响应信息的对象你要先决定怎么读取它的主体内容response.json()解析JSON格式的响应体response.text()解析纯文本response.blob()解析二进制文件图片、PDF等response.formData()解析表单数据另一个新手常踩的坑是response.ok。fetch只有在网络层面出错比如断网、DNS解析失败、跨域被拦截时才会让Promise走reject而只要服务器返回了HTTP响应——哪怕是一个500错误——Promise都会正常resolve。所以检查response.ok或者response.status是必须的步骤不检查的话你会拿到一个200的接口很顺利、500的接口出错信息却要到控制台里翻半天的局面。2.2 POST请求JSON格式与表单格式的差异正常情况下项目里绝大多数写操作都是POST。这里有两个大方向提交JSON和提交表单。它们在请求头的设置上有明显区别也是我认为最容易掉坑的地方。提交JSON数据async function createUser(userData) { const response await fetch(/api/user/create, { method: POST, headers: { Content-Type: application/json, }, body: JSON.stringify(userData), }) const data await response.json() return data }注意看细节body需要经过JSON.stringify序列化成字符串同时headers里的Content-Type必须显式设置为application/json。我之前见过一位同事写POST请求时忘了设置请求头后端接口怎么都解析不到数据排查了半天发现在GET请求的代码基础上直接加了body没有同步改headers。提交表单格式则是另一种情况async function submitForm(formData) { const response await fetch(/api/form/submit, { method: POST, body: formData, // 直接传 FormData 对象 }) const data await response.json() return data }这里有个很重要的知识当你把FormData对象直接传给body时浏览器会自动设置Content-Type为multipart/form-data并且自动生成正确的boundary分隔符。如果你在这个场景下手动设置了Content-Type反而会导致后端解析失败因为手动设置的往往缺少boundary参数。所以我的习惯是用FormData时完全不写Content-Type交给浏览器处理。源码层面补充一下fetch对body的处理是有一个Body混入类的它内部会根据传入的body类型自动决定如何序列化。字符串会被当作纯文本URLSearchParams会被当作application/x-www-form-urlencodedFormData会被当作multipart/form-data。理清这层逻辑你就知道什么时候该手动设置Content-Type、什么时候不该设置。2.3 DELETE和PUT别忽略消息体日常项目里DELETE和PUT用得不那么频繁但写法上跟POST是一脉相承的。DELETE操作有的后端设计为不需要请求体有的则需要你在body里传一个ID数组。我一般这样处理async function deleteUsers(ids) { const response await fetch(/api/user/batchDelete, { method: DELETE, headers: { Content-Type: application/json, }, body: JSON.stringify({ ids }), }) const data await response.json() return data }一个值得注意的小细节是有些浏览器或者HTTP代理会拦截带body的GET请求所以带请求体的操作我建议统一用DELETE、POST或PUT不要混用GET带body这种写法兼容性上更稳。3. Vue工程里的fetch封装从零构建一个干净的请求模块在日常项目里如果你不封装每个组件都直接写fetch代码很快就会变成一团乱麻错误处理到处重复、加载状态各管各的、鉴权token散落各页。所以这一节是本文的核心我直接给你一套我试过多次、在真实项目里跑得很稳的封装方案。3.1 搭建统一的request工具函数先建一个utils/request.js文件封装一个统一的基础方法。这里为了配合Vue我会直接导出基于Promise的API方便在async/await风格里使用。// utils/request.js // 基础路径可依据环境变量区分 const BASE_URL /api // 默认超时时间单位毫秒 const DEFAULT_TIMEOUT 10000 /** * 统一请求方法 * param {string} url 接口路径不带baseURL * param {object} options 透传给fetch的请求配置 */ async function request(url, options {}) { // 拼接完整URL const fullURL ${BASE_URL}${url} // 自动携带token这里从本地存储读取 const token localStorage.getItem(token) // 组装headers注意保留调用方传入的headers const headers { Content-Type: application/json, ...(token ? { Authorization: Bearer ${token} } : {}), ...(options.headers || {}), } // 创建超时控制器 const controller new AbortController() const { signal } controller const timeoutId setTimeout(() controller.abort(), DEFAULT_TIMEOUT) try { const response await fetch(fullURL, { ...options, headers, signal, }) // 统一处理HTTP错误状态 if (!response.ok) { // 尝试解析错误信息解析不了就用状态码兜底 let errorMessage 请求失败状态码${response.status} try { const errorData await response.json() errorMessage errorData.message || errorMessage } catch (e) { // 忽略JSON解析错误 } throw new Error(errorMessage) } // 正常情况直接返回解析后的JSON return await response.json() } catch (err) { // 区分超时和普通错误 if (err.name AbortError) { throw new Error(请求超时请稍后重试) } throw err } finally { // 清除定时器避免泄漏 clearTimeout(timeoutId) } } export const http { get(url, params {}) { const queryString new URLSearchParams(params).toString() return request(${url}?${queryString}) }, post(url, data {}) { return request(url, { method: POST, body: JSON.stringify(data), }) }, put(url, data {}) { return request(url, { method: PUT, body: JSON.stringify(data), }) }, delete(url, data {}) { return request(url, { method: DELETE, body: JSON.stringify(data), }) }, }这段封装解决了我实际开发里遇到的几个最痛的问题第一个是超时控制。fetch原生不带超时功能你不处理的话一个接口卡住会让用户界面一直转圈。我用的方案是AbortController加setTimeout到了时间就主动中断请求。这个组合是fetch官方推荐的思路实测在Chrome和现代浏览器里都稳定生效。**第二个是错误信息统一。**我手动抛出Error对象并把后端返回的message字段带出来这样组件里catch到错误之后可以直接把err.message展示给用户而不需要每个组件都重新解析一遍错误结构。**第三个是token统一注入。**从本地存储读取登录凭证、自动加到请求头这个逻辑只写一次以后页面里调接口完全不用关心鉴权的问题。3.2 在Vue组件里怎么消费这个请求模块封装好了之后组件里的代码会显得特别清爽。我以Vue 3的组合式API为例展示一个列表加载的完整逻辑template div div v-ifloading加载中.../div div v-else-iferror{{ error }}/div ul v-else li v-foritem in list :keyitem.id{{ item.name }}/li /ul /div /template script setup import { ref, onMounted } from vue import { http } from /utils/request const list ref([]) const loading ref(false) const error ref() async function loadUserList() { loading.value true error.value try { const data await http.get(/user/list, { page: 1, size: 10 }) list.value data.list || [] } catch (err) { error.value err.message || 加载失败 } finally { loading.value false } } onMounted(loadUserList) /script这个模式基本涵盖了我日常开发百分之八十的场景进页面先加载、加载中给反馈、出错给提示、成渲染列表。loading、error、list三个状态分开管理逻辑清晰维护起来不费劲。3.3 为什么我的封装里没有加拦截器如果你习惯了axios可能第一反应是问你的封装怎么没有请求拦截器和响应拦截器我的答案是拦截器本质上是把所有请求共有的逻辑抽出来执行。当这个共有逻辑只有加token和统一错误处理两件事时用简单的函数封装就够了。拦截器是axios为了应对千奇百怪的业务需求而设计的扩展点但它也带来了心智负担——你永远要记得请求会经过哪些层、响应会被哪一层改写。我的request函数里把token注入、错误处理、超时全部放在了一起数据流清晰可见没有魔法。等到有一天真的需要在请求发出前做全局日志上报、或者在响应回来后统一改写数据结构我再去引入拦截器概念从单函数内部拆出中间件也是很容易的。在项目初期克制比炫技重要。4. 在Vue项目里处理图片上传和下载fetch的进阶场景基础请求搞定了再往前一步就是很多人觉得fetch做不到的领域——文件上传和下载。这一节我详细说明怎么绕过它的限制。4.1 文件上传FormData与进度感知方案前面说过fetch原生不支持上传进度事件但这不代表你不能做进度条。有两个思路用XMLHttpRequest专门处理上传fetch处理其他所有请求用第三方库包一层例如axios但这就回到了老路我个人推荐第一种因为它保留了大量代码的fetch实现只对上传这一个特殊场景做定向处理。下面是我项目中用的混合方案// utils/upload.js /** * 文件上传带进度回调 * param {string} url 上传地址 * param {File} file 文件对象 * param {function} onProgress 进度回调参数为百分比(0-100) */ function uploadFile(url, file, onProgress) { return new Promise((resolve, reject) { const formData new FormData() formData.append(file, file) const xhr new XMLHttpRequest() xhr.open(POST, url) // 从这里拿到token注入请求头 const token localStorage.getItem(token) if (token) { xhr.setRequestHeader(Authorization, Bearer ${token}) } // 监听上传进度 xhr.upload.onprogress (event) { if (event.lengthComputable onProgress) { const percent Math.round((event.loaded / event.total) * 100) onProgress(percent) } } xhr.onload () { if (xhr.status 200 xhr.status 300) { try { resolve(JSON.parse(xhr.responseText)) } catch (e) { resolve(xhr.responseText) } } else { reject(new Error(上传失败状态码${xhr.status})) } } xhr.onerror () { reject(new Error(网络错误上传失败)) } xhr.send(formData) }) }然后在Vue组件里结合ref做一个进度条效果script setup import { ref } from vue import { uploadFile } from /utils/upload const progress ref(0) async function handleUpload(event) { const file event.target.files[0] if (!file) return progress.value 0 try { const result await uploadFile(/api/file/upload, file, (percent) { progress.value percent }) console.log(上传成功, result) } catch (err) { console.error(上传失败, err.message) } } /script这里有个细节值得说一下xhr.upload.onprogress里的event.lengthComputable属性它表示本次上传的总大小是否可计算。如果为false说明本次传输的数据量是未知的例如流式上传这时候强行算百分比可能得出Infinity或者NaN。加一层判断是严谨的做法。4.2 文件下载从响应流里取文件名下载的场景通常有两种一种是直接访问接口地址浏览器自动下载另一种是fetch拿到二进制流然后由前端触发下载。第一种不多说第二种在实际开发里更常见因为你往往需要带鉴权token。async function downloadFile(url, filename) { const token localStorage.getItem(token) const response await fetch(url, { headers: { Authorization: Bearer ${token}, }, }) if (!response.ok) { throw new Error(下载失败) } // 关键一步读取为blob const blob await response.blob() // 生成临时URL并触发下载 const downloadUrl URL.createObjectURL(blob) const link document.createElement(a) link.href downloadUrl link.download filename || download document.body.appendChild(link) link.click() // 清理资源 document.body.removeChild(link) URL.revokeObjectURL(downloadUrl) }这里最大的问题是文件名。如果后端在响应头里通过Content-Disposition告诉了你文件名你可以这样提取const contentDisposition response.headers.get(Content-Disposition) // 形如: attachment; filenameexample.pdf const match contentDisposition contentDisposition.match(/filename?([^])?/) const filename match ? match[1] : download.pdf有一点要提醒URL.createObjectURL创建的临时URL在使用完毕之后一定要调用revokeObjectURL释放否则会一直占用内存。这在下载行为频繁的应用里特别重要我见过因为忘记释放导致页面逐渐变卡的真实案例。5. fetch与Vue生命周期结合避免接口竞态的几个实用方案在Vue里用fetch除了掌握API本身还得处理好一个前端开发者必须面对的经典问题接口竞态。比如用户在A页面发起请求之后快速切换到B页面A页面的回调仍然在某个时刻执行了可能会导致更新已卸载组件的状态警告或者在Vue 3中直接造成内存泄漏。5.1 请求取消的正确姿势AbortController在Vue中的落地fetch原生支持取消请求这正是它相比老旧XMLHttpRequest的一个重要优势。我在封装请求模块时已经用了AbortController做超时它在组件卸载场景同样好用。以Vue 3组合式API为例配合onUnmounted钩子script setup import { ref, onMounted, onUnmounted } from vue const list ref([]) const controller new AbortController() async function loadData() { try { const response await fetch(/api/list, { signal: controller.signal, }) const data await response.json() list.value data } catch (err) { // 如果是手动取消导致的错误静默处理 if (err.name AbortError) { console.log(请求已取消) return } console.error(加载失败, err) } } onMounted(loadData) onUnmounted(() { controller.abort() }) /script这样当组件卸载时请求会被主动中断回调不再执行既避免了多余的网络流量也防止了状态更新导致的警告。5.2 用请求序号解决快速切换场景下的数据错乱如果你不想用AbortController或者你虽然取消了请求、但仍然担心某些情况下回调还是会执行那还有一个简单的办法维护一个请求序号每次发新请求时递增回调返回后比对序号是否一致不一致就丢弃这次响应。script setup import { ref, onMounted } from vue const list ref([]) let requestId 0 async function loadData() { const myRequestId requestId const data await http.get(/api/list) // 只有在请求序号仍是最新的情况下才更新状态 if (myRequestId requestId) { list.value data } } /script这个思路在很多场景里比取消请求更稳因为你不依赖AbortError的抛出时机也不会因为取消事件的竞态而导致意外行为。我在页面里同时有多个下拉框联动、筛选条件频繁变化的时候就用这种方式保证数据不乱。5.3 封装一个实用的useFetch组合式函数如果你发现上面的模式在多个组件里重复出现那不如直接封装成一个组合式函数。下面是我目前在用的简化版本// composables/useFetch.js import { ref, onUnmounted } from vue export function useFetch(url, options {}) { const data ref(null) const loading ref(false) const error ref(null) const controller new AbortController() async function exec(fetchURL url) { loading.value true error.value null try { const response await fetch(fetchURL, { ...options, signal: controller.signal, }) if (!response.ok) { throw new Error(HTTP error! status: ${response.status}) } data.value await response.json() } catch (err) { if (err.name ! AbortError) { error.value err.message || 请求失败 } } finally { loading.value false } } onUnmounted(() { controller.abort() }) return { data, loading, error, exec } }然后在组件里用的时候只需要这样script setup import { useFetch } from /composables/useFetch const { data, loading, error, exec } useFetch(/api/dashboard) exec() /script这是组合式API带给我最大的红利把数据和它的生命周期绑定在一起整体变成一个可复用的小能力。每次新开一个页面我只需要调用一次useFetch再也不用重复写加载状态管理和取消逻辑。6. 实测中的常见坑fetch在真实项目里的四个意外最后这部分我分享几个自己在实际项目中踩过的比较典型的坑。这些问题在官方文档里通常不会特别强调但一旦遇到排查起来确实折腾人。6.1 坑点一默认不带Cookie会话失效莫名其妙如果你用fetch请求一个需要登录态的接口而且后端用的是基于Cookie的会话机制那你很可能遇到明明浏览器有登录状态接口却返回401的问题。原因在于fetch的默认credentials配置是same-origin意思是只在同源请求时携带Cookie。当你的前端和API不在同一个域或者通过反向代理跨域时Cookie默认不会带上。解决方案有两种// 方案一每个请求都手动带上麻烦但直接 fetch(/api/user/info, { credentials: include, }) // 方案二在统一封装里加上推荐 const response await fetch(fullURL, { ...options, credentials: include, headers, })这里有个重要的技术细节include模式下如果后端没有设置正确的Access-Control-Allow-Credentials响应头浏览器会直接拦截响应你还是拿不到数据。跨域配置上需要前后端配合这个坑往往是联调阶段最容易扯皮的地方。6.2 坑点二非2xx状态码不抛错导致错误处理静默失败这是老生常谈但依然值得强调的问题。fetch的设计哲学是Promise只在你无法和服务器通信时reject只要服务器返回了响应哪怕是个404Promise也是resolve的。我见过很多新手写的代码fetch(/api/data) .then(res res.json()) .then(data { // 这里以为拿到的一定是正常数据 console.log(data) })如果后端返回404或500这一段也能正常执行但data里往往是错误信息。问题在于前端无从判断这是正常数据还是错误数据。所以统一封装里的if (!response.ok)判断是从裸fetch走向工程化的第一道坎。务必加上。6.3 坑点三JSON解析失败导致二次异常另一个很容易被忽视的问题是不是所有接口都返回JSON。有时候你请求一个正常接口但后端网关返回了一段HTML错误页比如超时页面、反向代理错误页这时候response.json()就会解析失败并抛出一个SyntaxError。我的处理是在统一封装里加一层保护先判断Content-Type是不是JSON或者直接包一层try...catchasync function parseResponse(response) { const contentType response.headers.get(Content-Type) || if (contentType.includes(application/json)) { return await response.json() } return await response.text() }这样就算后端抽风返回了一个HTML页面前端也不会白屏至少能拿到文本内容便于排查问题。6.4 坑点四并发请求时加载状态互相覆盖最后分享一个Vue状态管理层面的坑。我在一个项目里同时发了三个请求来加载三个区域的数据本意是并行加速结果发现加载状态被后返回的请求覆盖了。问题根源是我用了一个布尔值loading来控制三个区域共用的一个加载组件而前两个请求都还没返回时第三个请求先返回了把loading置为false界面上看着像加载完了实际上另外两个区域还是空的。解决办法有两种一是每个区域维护自己的加载状态二是用一个计数器let pendingCount 0 function startLoading() { pendingCount loading.value true } function endLoading() { pendingCount Math.max(0, pendingCount - 1) if (pendingCount 0) { loading.value false } }这个计数器思路本质上就是所有请求都结束才算结束用起来很顺手。在有多个并行请求的页面里这个细节直接决定了加载状态的准确性。我自己的体会是fetch确实没有axios那么到手即用它的简洁背后是需要你补充一些工程化能力的。但也正是这种简洁让我对整个请求链路有了更清晰的掌控感。如果你正打算在Vue项目里尝试fetch建议先从简单的GET请求开始跑通之后再逐步把超时、取消、统一错误处理加进去。等你把这一套封装沉淀下来你会和我一样越来越觉得原生fetch其实挺好用的。

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

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

免费获取报价 →
↑