1. 从“接口请求”到“fetch选型”的思考
做 Vue 开发的同学,第一次接触接口调用,十有八九是从axios开始的——当时我也是如此。某天接手一个代码量不大、依赖极简的项目,想用原生能力解决通信问题,才真正俯下身子把fetch翻来覆去用了遍。这篇文章就当是整理给自己的学习笔记,也分享给那些意识到“其实浏览器早就有自带请求方案”的朋友。
一句话先讲清楚:fetch 是浏览器原生提供的接口请求能力,基于 Promise 设计,用起来比 XMLHttpRequest 清晰得多,而 Vue 项目里同样能够无缝封装和复用。
它能解决什么问题?主要体现在三方面:一是项目不需要额外安装依赖,包体积小,线上加载更快;二是 API 设计接近现代异步写法,与async/await配合得很好,代码简洁度远胜传统XHR;三是可以与axios并存,也可以在前后端分离项目中独立承担通信层职责。
适读人群挺明确:Vue 刚入门的开发者、从jQuery.ajax或axios转过来的熟手,以及想把手头项目依赖瘦身的工程化爱好者。老规矩,先说清几个核心概念,再把我实践过的封装写法、踩坑记录和排查思路逐条放出来,直接可以抄作业。
2. 核心概念铺垫:从 Promise 到 fetch 的最小必要认知
2.1 为什么 fetch 能替代 XHR
XMLHttpRequest是古早时代的产物,语法冗长,事件回调多,一套请求写下来经常出现嵌套地狱。我当时做某后台管理系统时,三个接口串行依赖,用XHR写的回调层层缩进,调试起来头大。fetch 走的是 Promise 链,把“发请求”和“处理结果”拆成清晰的两个阶段,配合async/await阅读体验接近同步代码。
// XHR 写法 const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/user'); xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { console.log(JSON.parse(xhr.responseText)); } }; xhr.send(); // fetch 写法 const response = await fetch('/api/user'); const data = await response.json(); console.log(data);这个对比不需要过多解释,代码量本身说明问题了。
2.2 fetch 的两个阶段模型
理解 fetch,必须把它想成两个阶段。
第一阶段是“拿到响应头”。只要服务器有响应,无论状态码是 200、404 还是 500,fetch返回的 Promise 都会 resolve,拿到一个Response对象。第二个阶段是“读取响应体”。通过response.json()、response.text()、response.blob()等方法把数据取出来。
这带来一个新手最容易踩的坑:fetch 在 HTTP 404/500 时并不会进入 reject,而是照常 resolve。必须手动检查response.ok或response.status才能判断业务是否成功。这个特性我在第 4 部分封装时会重点处理。
2.3 Response 对象常用属性
我对Response对象的使用频率排序大概是:ok(请求是否成功)、status(状态码)、statusText(状态文本)、headers(响应头)、url(最终地址)。headers用得少,但做一些鉴权判断或有条件展示时会派上用场。
const response = await fetch('/api/list'); console.log(response.ok); // true / false console.log(response.status); // 200 / 404 / 500 ... console.log(response.statusText); console.log(response.headers.get('Content-Type'));真正取数据时,还要注意响应体只能被读取一次。response.json()之后再调用response.text()会报错,因为流已经被消费掉了。这个限制曾经让我排查了半小时才定位。
3. fetch 基础用法与常用参数拆解
3.1 最简单的 GET 请求
Vue 里最常见的就是打开页面拉列表数据。比如某模拟项目用户列表接口GET /api/users,直接这样写:
async function getUsers() { const response = await fetch('/api/users'); if (!response.ok) throw new Error('请求失败'); const users = await response.json(); return users; }调用方拿到的就是解析好的数组或对象。这里有一个容易被忽略的点:fetch默认请求不带credentials,跨域或同域下如果接口需要携带 Cookie,必须在参数中明确声明。
3.2 fetch 的 options 参数详解
fetch(url, options)的第二个参数是整个 API 的核心,常用配置我整理成一张表:
| 配置项 | 作用 | 常见取值 |
|---|---|---|
method | 请求方法 | GET / POST / PUT / DELETE / PATCH |
headers | 请求头 | 对象或 Headers 实例 |
body | 请求体 | 字符串、FormData、Blob 等 |
credentials | 携带凭证 | same-origin / include / omit |
cache | 缓存策略 | default / no-store / reload |
mode | 请求模式 | cors / no-cors / same-origin |
signal | 中断信号 | AbortSignal 实例 |
填 body 的方式有两种常见情况。一是提交JSON数据,必须用JSON.stringify()转成字符串,并在 headers 里标明Content-Type: application/json;二是传表单数据,可以用FormData对象,浏览器会自动设置multipart/form-data。
const response = await fetch('/api/save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '张三', age: 18 }), });手动设置Content-Type的意义在于:如果后端框架(比如某 Java 或 Python 的服务)严格按 Content-Type 做参数解析,缺了它可能拿不到@RequestBody数据。我遇到过二次,都是因为初始化函数里忘了写这一行。
3.3 请求头处理与常见报错
习惯用对象字面量传 headers 时,遇到同名请求头需求就会发现问题——原生支持其实是通过Headers实例来管理复杂头的。举例来说,既要授权信息又要自定义头:
const headers = new Headers(); headers.append('Content-Type', 'application/json'); headers.append('Authorization', 'Bearer token123'); headers.append('X-Request-Id', generateRequestId()); const response = await fetch('/api/data', { headers });之所以推荐Headers实例,是因为它自带has()、delete()等操作,动态增删请求头时不会动不动就整个替换,排查“头没传上去”这类问题会快很多。实际上大部分项目用对象字面量就够了,但理解这个机制对排查问题很有帮助。
3.4 上传文件与 FormData
表单上传是业务里绕不过去的一个场景。假设做一个图片上传组件,通过input[type=file]拿到文件后:
async function uploadFile(file) { const formData = new FormData(); formData.append('file', file); formData.append('description', '示例图片'); const response = await fetch('/api/upload', { method: 'POST', body: formData, }); return response.json(); }注意这里不需要手动设置Content-Type,浏览器会把boundary随机串一起生成完整头。之前有个同行手动设了Content-Type: multipart/form-data,结果少了 boundary,后端直接报解析错误。这个坑写出来,提醒后来人一句:FormData 场景把 Content-Type 交给浏览器就好。
4. 实战封装:在 Vue 项目里把 fetch 用得更顺手
4.1 为什么需要二次封装
直接裸用fetch在简单页面里没问题,但模块一多就发现重复代码满天飞:每个页面都在判断response.ok、解析response.json()、处理错误提示。而且业务场景通常还有统一的 BaseURL、统一的鉴权头、统一的超时处理、统一的业务错误码拦截。把这些逻辑抽到一个公共函数里,开发效率和可维护性都会好很多。
我在做过的一个跨平台系统中采用的就是api.js单文件封装,所有业务模块通过它发起请求,后来替换接口地址时只改一行配置,省了不少事。
4.2 一个可直接用的 fetch 封装示例
结合 Vue 3 的 setup 语法,可以封装成独立的工具函数,下面是完整代码(这是基于项目实践总结的常见方案,可直接参考):
// src/utils/request.js const BASE_URL = import.meta.env.VITE_API_BASE || '/api'; const TIMEOUT = 10000; function timeoutSignal(timeout) { const controller = new AbortController(); setTimeout(() => controller.abort(), timeout); return controller.signal; } async function request(url, options = {}) { const { method = 'GET', params, data, timeout = TIMEOUT, headers = {} } = options; // 拼接 GET 查询参数 let requestUrl = url; if (params && Object.keys(params).length) { const queryString = new URLSearchParams(params).toString(); requestUrl += (url.includes('?') ? '&' : '?') + queryString; } const requestOptions = { method, headers: { 'Content-Type': 'application/json', ...headers, }, signal: timeoutSignal(timeout), }; // 如果传入的是 FormData,不手动覆盖 Content-Type if (data !== undefined) { if (data instanceof FormData) { requestOptions.body = data; } else { requestOptions.body = JSON.stringify(data); } } const response = await fetch(BASE_URL + requestUrl, requestOptions); // HTTP 层错误统一处理 if (!response.ok) { const err = new Error(`请求失败:${response.status} ${response.statusText}`); err.status = response.status; throw err; } // 业务层面按接口约定解析 const result = await response.json(); if (result.code !== 0) { const bizError = new Error(result.message || '业务处理失败'); bizError.code = result.code; throw bizError; } return result.data; } export const http = { get: (url, config) => request(url, { ...config, method: 'GET' }), post: (url, data, config) => request(url, { ...config, data, method: 'POST' }), put: (url, data, config) => request(url, { ...config, data, method: 'PUT' }), delete: (url, config) => request(url, { ...config, method: 'DELETE' }), };调用方式很直观:
import { http } from '@/utils/request'; const users = await http.get('/users', { params: { page: 1, pageSize: 20 }, }); await http.post('/user/save', { name: '李四', age: 25, });4.3 封装里的关键设计思路
第一,timeoutSignal是专门为“超时中断”设计的。默认情况下 fetch 没有内置超时功能,接口卡住时只会在那一直挂着,严重影响用户体验。利用AbortController在指定时间后主动 abort,接口层就能快速失败并进入 catch 分支。
第二,业务错误码的拦截要放在response.ok通过之后。因为 HTTP 200 只代表请求到达应用层,后端可能返回一个带错误码的 JSON(比如{ code: 1001, message: '参数不合法' }),此时前端不能当成成功数据处理。把业务校验统一收口在request函数里,页面代码会干净很多。
第三,对FormData的差异化判断也很有讲究。一旦我们统一在 headers 里预设了Content-Type: application/json,直接传FormData就会冲突。所以要做类型判断,见上面代码中的分支处理。
4.4 在 Vue 组件中的调用方式
在 Vue 组件里,我习惯配合组合式函数(Composable)来组织业务请求,这样逻辑复用度高、组件内不臃肿。简单示例:
// src/composables/useUserList.js import { ref, onMounted } from 'vue'; import { http } from '@/utils/request'; export function useUserList() { const list = ref([]); const loading = ref(false); const errorMsg = ref(''); async function loadUsers() { loading.value = true; errorMsg.value = ''; try { list.value = await http.get('/users', { params: { status: 'active' } }); } catch (e) { errorMsg.value = e.message; } finally { loading.value = false; } } onMounted(loadUsers); return { list, loading, errorMsg, loadUsers }; }页面里这样用:
<template> <div v-if="loading">加载中...</div> <div v-else-if="errorMsg">{{ errorMsg }}</div> <ul v-else> <li v-for="user in list" :key="user.id">{{ user.name }}</li> </ul> </template> <script setup> import { useUserList } from '@/composables/useUserList'; const { list, loading, errorMsg, loadUsers } = useUserList(); <// script>loading、errorMsg都集中在同一个 composable 中,模板里只做展示,测试和后续维护都方便。
5. 错误处理与边界情况处理
5.1 错误分类:网络错误、HTTP 错误与业务错误
我在处理错误时,通常把它拆成三层来理解:
- 网络层错误:断网、DNS 解析失败、请求被 CORS 拦截、超时中断。这层错误会让
fetch的 Promise 进入 reject,可以在try/catch里统一捕获。 - HTTP 层错误:服务器返回 4xx、5xx,请求本身完成了,但状态码不理想。此时
fetch不会 reject,必须手动判断response.ok。 - 业务层错误:HTTP 200 但业务 code 不为 0,表示逻辑上失败。需要在拿到 JSON 后自行判定。
三者的处理时机和处理方式都不一样,封装时分门别类设计才能避免漏判或误报。
5.2 错误提示与用户反馈的配合
前端不是只管把错误抛给控制台就完了。界面上的反馈要分场景:
- 断网时统一提示“网络异常,请检查网络连接”;
- 超时提示“请求超时,请稍后重试”;
- 404/500 根据业务需要提示“服务暂时不可用”;
- 业务错误码则展示后端返回的 message。
一种常见做法是把errors统一转成全局消息弹窗,比如在catch分支里调用某showToast方法。当然,如果项目里有多个页面共用同一封装,错误提示的默认策略可以定得保守一点,避免每个页面都弹窗。
5.3 中断请求:应对组件卸载后的状态更新
Vue 组件销毁后,异步请求才返回,这时如果直接给ref赋值或触发弹窗,虽然不报错,但可能引起内存泄漏或不必要的副作用。比较好的办法是结合AbortController在组件卸载时中断请求。
import { onMounted, onUnmounted } from 'vue'; let controller = null; onMounted(() => { controller = new AbortController(); loadData(controller.signal); }); onUnmounted(() => { controller?.abort(); });fetch的signal参数接收中断信号后,卸载时调用abort(),请求会立即进入 reject,catch分支也能拿到名字为AbortError的错误对象。此时再判断一下错误类型,决定是否提示用户即可。
5.4 并发请求与竞态处理
页面里经常出现“用户快速切换 tab,请求 A、B、C 先后发出,但返回顺序错乱”的竞态。最简单的应对手段是:请求序号比对法。每次发起请求前把序号加一,响应返回时只处理最新序号的结果。
let requestSeq = 0; async function fetchList(query) { const currentSeq = ++requestSeq; const data = await http.get('/list', { params: query }); if (currentSeq !== requestSeq) { return; // 说明已经不是最新一次请求,丢弃本次结果 } list.value = data; }对于更复杂的场景,也可以使用AbortController在发起新请求时把旧请求直接取消。
6. 常见问题排查与避坑实录
6.1 响应无法解析成 JSON
现象:response.json()报错,提示类似Unexpected token < in JSON。原因:接口实际返回的是 HTML(比如网关错误页、Nginx 404 页),而前端强制当成 JSON 处理。排查思路:先response.text()打印结果看是什么;再确认接口地址是否被代理拦截;最后检查后端路由是否未匹配。解决:如果确实可能返回非 JSON,封装里可以先判断Content-Type或做 try/catch 后降级处理。
6.2 请求一直在 pending,迟迟不返回
现象:DevTools 里看到请求挂在那里,直到超时。原因:大概率是后端处理阻塞,或者请求被服务端限流、防火墙拦截;也有可能是AbortController超时时间设置太长。排查思路:查看 Network 面板中请求的状态,观察 TTFB(第一个字节返回时间)。如果 TTFB 很长,问题一般出在服务端,不是前端。
6.3 GET 请求不小心把参数拼错了
现象:后端收到name参数值为"undefined"字符串。原因:模板字符串拼 URL 时没做判空。解决:统一用URLSearchParams处理,只有当值非空时才 append。这也解释了为什么封装里推荐URLSearchParams而非手动拼接。
const query = new URLSearchParams(); if (params.keyword) query.append('keyword', params.keyword); query.append('page', '1'); const url = `/api/search?${query.toString()}`;6.4 接口能通但拿不到 Session
现象:同域接口从浏览器访问正常,但通过 fetch 请求时服务端收不到登录态 Cookie。原因:credentials默认值是same-origin,如果是跨域请求,需要设置为include。解决:
fetch('/api/profile', { credentials: 'include', });如果用了credentials: 'include'且跨域,后端必须配置对应的 CORS 响应头,浏览器才允许携带凭证。这里不展开过多细节,但值得记住这个组合约束。
6.5 页面明明报错了,控制台没有详细堆栈
现象:fetch的 rejection 打印出来只是TypeError: Failed to fetch。原因:跨域限制、网络错误、连接被重置都可能导致这个通用错误,Chrome 出于安全考虑不会暴露详细信息。排查思路:仔细看 Network 面板是否有请求发出、响应头是否完整、Console 面板有没有 CORS 报错。对比同环境下curl请求的返回,逐步缩小问题范围。
7. fetch 与 axios 的取舍思考
写到这里,肯定有人会问:既然 axios 已经成熟,为何还要用 fetch?这个问题我也认真想过。两者并不算“有你没我”的关系。
axios 的核心优势:内置请求/响应拦截器,全局配置方便;浏览器兼容性更好;支持取消请求、上传进度等高级功能;社区生态完善,文档和示例丰富。
fetch 的核心优势:浏览器原生支持,零依赖;Promise 风格简洁自然;配合现代框架的 SSR 环境,Node 18+ 也内置了全局 fetch,代码可以复用;体积上能给项目省下几十 KB 的依赖。
如果项目对老浏览器有硬性兼容要求,或者团队已经深度依赖 axios 生态,继续使用 axios 完全合理。如果是新项目、以现代浏览器为目标,或者接口要求简单,我有理由推荐先认真考虑 fetch。它足够解决大多数日常请求场景。
我在一个轻量 H5 项目里全程用 fetch,整体代码量比其他同类项目少了约三分之一,构建出来的包也小了一些。对“能少一个依赖就少一个依赖”的运维心态来说,还是很有吸引力的。
8. 实践后的几点体会与建议
从最初把fetch当作“另一个请求方法”来用,到现在把它抽象成统一的接口请求层,我对前端通信的理解也在逐步加深。学习过程中最大体会是:不要只背 API,要把错误处理、请求生命周期、组件结合方式想清楚,这才是真正的“会用了”。
最后给刚开始接触 fetch 的朋友几点很实在的建议:
一是初期手动写几个不同场景的请求,比如 GET 列表、POST 表单、上传文件、超时中断。不要急着封装,先暴露所有细节和问题,理解每个参数的作用。
二是封装从“够用”开始,不要一开始就追求大而全的插件式设计。随着业务复杂度上升,慢慢增加错误码处理、请求缓存、重试机制。过度的早期抽象往往是维护负担。
三是多利用 DevTools 的 Network 面板和 Sources 断点去观察真实请求行为。纸上谈兵三天不如实际调试一遍,很多“为什么”在那个时刻会自动清楚。
四是把 fetch 学习与 Promise 基础同步补齐。请求的编排、竞态控制、错误传播都依托于异步模型,一旦这块通了,往后看axios源码或者写自定义请求层都不再是难事。
fetch 不是银弹,但在恰到好处的场景里,它确实能让人把代码写得干净利落。希望这份笔记能给同样在 Vue 项目里探索的同学一点切实帮助。