Vue 项目里做接口请求,我见过太多人一上来就装 axios,但你要真把 fetch 搞明白,会发现相当一部分请求逻辑根本不需要额外引依赖。fetch 是浏览器原生提供的请求方案,基于 Promise,写法干净,Vue 2 和 Vue 3 里都能直接用。这篇就是我学习 Vue 过程中关于 fetch 接口调用的整理笔记,除了基本用法,重点放在 async/await 写法、返回解析的坑、组件里的加载状态设计,以及一套能直接搬进项目的请求层封装。适合刚接触 Vue 的初学者,也很适合想把手头请求代码整理得更规范的中级开发者。
先说一个我的感受:很多教程把 fetch 讲得太简单,就一句“用 fetch 可以发请求”,然后把 axios 的封装丢给你抄。结果一上手,状态码 404 不报错、响应 JSON 解析失败、组件卸载之后请求还在回调,这些问题接踵而来。这篇文章的目的就是把这些坑提前给你填上,让你少走弯路。
1. 为什么前端项目里绕不开 fetch 这个话题
1.1 fetch 到底是什么,和 XHR 有什么区别
fetch 是浏览器 Window 对象上提供的原生接口,用来替代老一代的 XMLHttpRequest 方案。老项目里常见的xhr.onreadystatechange、xhr.open('GET', url)、xhr.send()那一套写法,现在基本不用手写了。fetch 用 Promise 把整个请求过程包装起来,代码结构要直观得多。
它可以做几件事:发送 GET、POST、PUT、DELETE 等 HTTP 请求,携带请求头、请求体,读取响应状态和响应数据,还支持取消请求、上传文件、跨域请求(配合后端 CORS 配置)。最核心的一点是,它是浏览器原生能力,零依赖,不用额外安装任何包。
在 Vue 项目里,fetch 可以直接在组件里用,也可以封装成独立的请求文件。这一点非常符合 Vue 组件的使用习惯——按需引入,不强耦合。而且不仅浏览器支持,现在很多 JS 运行时也提供了原生 fetch,比如较新版本的 Node.js 环境,还有小程序生态里的部分实现,学习成本可以迁移。
1.2 和 axios 比,fetch 的优势与不足
先摆一个对比表,方便你选型时判断:
| 对比维度 | fetch | axios |
|---|---|---|
| 依赖方式 | 浏览器原生,零依赖 | 第三方库,需要安装 |
| 返回数据解析 | 需要手动调用res.json()或res.text() | 默认自动解析 JSON |
| 错误处理边界 | HTTP 状态码非 2xx 时不会自动 reject | 状态码非 2xx 时自动进入 catch |
| 请求取消 | 使用 AbortController | 使用 CancelToken(现推荐 AbortController) |
| 超时控制 | 需要手动配合 AbortController 实现 | 有现成的 timeout 配置 |
| 上传进度 | 没有现成进度事件 | 支持 onUploadProgress |
| 运行环境 | 浏览器为主,Node 高版本也有 | 浏览器和 Node 都能用 |
从这个表能看出来,fetch 不是 axios 的完美替代品,它更接近底层。我的建议是:小型项目、内部工具、不想引入额外依赖的场景,直接 fetch 完全够用;团队项目或者需要统一拦截器、上传进度、全局错误处理的场景,可以基于 fetch 做一层封装,没必要一开始就依赖 axios。
我在某个人项目里就这么干过:一个数据看板只需要请求几个只读接口,用 fetch 封装了七八行代码就搞定了。对一个几十 KB 的项目来说,为这点功能引一个 axios 反而显得臃肿。这也是值得学习的地方:不是每个项目都要上重量级方案。
2. 半小时上手:fetch 基础用法和返回解析
2.1 GET 请求与 async/await 写法
最简单的 GET 请求,一行代码就能发起:
fetch('https://api.example.com/users') .then(res => res.json()) .then(data => console.log(data)) .catch(err => console.error(err))但在 Vue 组件里,我建议直接用async/await,配合try/catch/finally,这样代码读起来更像同步逻辑,状态管理也更好写:
async function getUserList() { try { const res = await fetch('https://api.example.com/users') const data = await res.json() console.log(data) } catch (err) { console.error(err) } }注意一个细节:fetch和res.json()都是异步的,需要分开 await。很多人会漏掉第二次 await,或者直接把res.json()当作同步方法用,结果打印出来一个Promise对象而不是数据。我第一次写的时候也在这里懵了一下,千万别踩。
res.json()的等待时间取决于响应体大小,接口数据多的时候会明显一点。如果你只需要知道请求成不成功,完全可以不解析响应体,直接看状态码就行。
2.2 第一次遇见的坑:HTTP 错误不会自动 reject
这是 fetch 和 axios 最典型的行为差异。fetch 只有在网络层出错(断网、DNS 解析失败、连接被拒绝)时才会 reject,对于返回 404、500 这类 HTTP 状态码,它不会抛错,照样走进then或者 await 后的下一行。
也就是说,下面的代码在接口 404 时不会进 catch:
const res = await fetch('https://api.example.com/users/9999') const data = await res.json() // 这里得到的可能是错误页面的内容要自己判断状态码的话,得显式检查res.ok属性。ok是一个布尔值,状态码在 200 到 299 之间为 true,否则为 false:
async function getUser(id) { const res = await fetch(`https://api.example.com/users/${id}`) if (!res.ok) { throw new Error(`HTTP error! status: ${res.status}`) } return res.json() }我在自己项目里就吃过这个亏。接口因为参数写错返回了 400,前端一直拿到一个错误 HTML 页面去解析,结果JSON.parse直接报错,排查了半天才发现是状态码处理的问题。所以封装请求层的时候,第一步就是先处理这个行为差异。
2.3 POST 登录、请求头和请求体写法
发 POST 请求时要手动指定三样东西:method、headers里的Content-Type、以及body。body 不能直接传对象,必须用JSON.stringify转成字符串,这是新手最容易忽略的地方。
以一个登录接口为例:
const res = await fetch('https://api.example.com/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'demo', password: '123456' }) })如果后端收不到参数,九成是以下两种原因之一:
- 忘记
JSON.stringify,直接把对象传给了body Content-Type没有设置成application/json,后端按表单格式解析不出来
如果你要发表单数据,写法不一样,不能用 JSON 字符串,要用URLSearchParams:
const res = await fetch('https://api.example.com/login', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ username: 'demo', password: '123456' }) })这里的区别值得记住:后端接口是接收 JSON 还是接收表单,决定了前端Content-Type和body的格式,乱搭配会导致接口直接报 400 或者参数为 null。
3. Vue 组件里怎么优雅地做接口调用
3.1 请求时机的选择:onMounted 还是 watch
Vue 组件里发请求,位置不对会引发一系列连锁问题。最常见的时机有三个:组件挂载后、某个响应式数据变化时、用户触发事件时。
列表页一般用onMounted,表示组件初始化完成后就去拉数据:
import { ref, onMounted } from 'vue' const list = ref([]) async function loadList() { const res = await fetch('https://api.example.com/list') list.value = await res.json() } onMounted(loadList)搜索场景一般用watch监听关键词变化:
import { ref, watch } from 'vue' const keyword = ref('') const result = ref([]) watch(keyword, async (val) => { const res = await fetch(`https://api.example.com/search?q=${val}`) result.value = await res.json() })注意一点:不要在setup顶层直接写无限制的await。虽然<script setup>支持顶层 await,但它会把组件变成异步组件,整个组件的渲染会被阻塞,父组件会一直等它。如果你只是想初始化数据,应该把逻辑放在onMounted里,这样组件可以先渲染出来,数据到了再更新视图。
3.2 data、loading、error 三层状态设计
接口调用不只是发请求、拿数据,还要考虑用户看到什么。我习惯给每个请求配三个响应式状态:数据data、加载中loading、错误信息error。这样组件模板可以很清晰地根据状态切换展示。
一个完整的 Vue 3 示例:
<script setup> import { ref, onMounted } from 'vue' const list = ref([]) const loading = ref(true) const error = ref('') async function loadList() { loading.value = true error.value = '' try { const res = await fetch('https://api.example.com/list') if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`) list.value = await res.json() } catch (e) { error.value = e.message } finally { loading.value = false } } onMounted(loadList) </script>模板里就可以这样控制:
<template> <div v-if="loading">加载中...</div> <div v-else-if="error">加载失败:{{ error }}</div> <ul v-else> <li v-for="item in list" :key="item.id">{{ item.name }}</li> </ul> </template>这个三层结构的价值在于,用户不会面对一个白屏。我在做某后台管理系统的时候就发现,加上 loading 和 error 状态之后,接口挂了用户也能立刻知道发生了什么,而不是抱着“页面怎么打不开”的疑问反复刷新。这一步不算复杂,但对体验的提升非常明显。
3.3 竞态问题:连续请求的返回顺序
竞态问题在真实项目里极为常见,尤其是搜索场景。用户输入“vue”,再输入“vue fetch”,第一次请求可能比较慢,第二次请求反而先返回。结果页面被先发的慢请求覆盖,显示的内容和关键词不匹配。
解决思路是:给请求加序号,只有最新一次请求的返回结果才允许更新数据。
let requestSeq = 0 async function search(keyword) { const current = ++requestSeq const res = await fetch(`/api/search?q=${keyword}`) const data = await res.json() if (current !== requestSeq) return result.value = data }原理很简单:每次发起请求前把序号加一,请求回来之后检查自己的序号是不是最新的,不是就丢弃。我实测下来,这个方案对慢网络环境特别有效。如果你用 AbortController,更彻底的方案是直接取消上一个请求,这个在下一节展开。
4. 把 fetch 封装成项目统一的请求层
4.1 超时控制与统一请求头
项目里的接口越来越多,如果在每个组件里重复写Content-Type、判断res.ok、处理超时,迟早会被埋到代码堆里。所以第五个核心动作就是:把公共逻辑抽到一个request函数里面。
一个比较健壮的基础封装长这样:
const request = async (url, options = {}) => { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 10000) const headers = { 'Content-Type': 'application/json', ...options.headers } try { const res = await fetch(url, { ...options, headers, signal: controller.signal }) if (!res.ok) { const message = res.status >= 500 ? '服务端异常' : '请求失败' throw new Error(`${message}(${res.status})`) } return await res.json() } finally { clearTimeout(timer) } }超时控制是我觉得最实用的一段逻辑。后端偶尔卡住不返回,如果没有超时机制,前端请求会一直挂在那里,loading 永远不消失。用AbortController加setTimeout,10 秒没响应就终止请求。
需要留意的是,AbortController不仅仅能做超时,还能用来手动取消请求。比如下拉刷新时取消正在进行的加载请求,或者组件销毁时取消所有未完成的请求。
4.2 三类错误的分层处理
接口异常不能只靠一个 catch 全吞掉,建议分成三层处理:
- 网络错误:断网、DNS 失败,fetch 自身 reject
- HTTP 状态码错误:404、500 等,需检查
res.ok - 业务错误:接口返回 200,但业务逻辑上失败,比如
{ code: 1, message: '无权限' }
在封装里可以这样区分:
if (err.name === 'AbortError') { console.warn('请求超时或已取消') } else if (err instanceof TypeError) { console.error('网络异常,请检查网络连接') } else { console.error(err.message) }这里有个小经验:网络错误在 fetch 里往往会抛TypeError: Failed to fetch,因为底层是网络栈出错。如果你看到这个错误,优先排查是不是断网、跨域、或者后端服务没启动。
业务错误需要和后端约定好统一的返回结构。我在项目里常用{ code: 0, data: ..., message: '' }这种格式,在请求层判断code === 0才算成功,否则抛出业务异常。这样组件里只需要关心data,不用每个接口都去判断一次。
4.3 登录态 token 的自动注入
登录态是每个后台系统都绕不开的环节。请求层做统一的 token 注入,能避免每个接口手动加请求头。
在封装里加这么一段:
const token = localStorage.getItem('token') if (token) { headers.Authorization = `Bearer ${token}` }这样请求发出去时,后端就能从 Authorization 字段解析用户身份。这个做法的好处是,以后换 token 存储方式(比如改成 cookie 或者内存中保存),只需要在请求层改一处。
还建议在请求层统一处理 401 未登录的跳转逻辑。如果发现响应状态码是 401,说明登录态失效,此时可以清掉本地 token,并跳转到登录页。这一步放在请求层做,比在每个组件里单独判断要省事得多。
5. 排查手册:接口调用常见问题实录
5.1 一张速查表定位大部分接口问题
我在学习和实战中遇到的问题积累下来,大部分都能用下面这张表快速定位:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 接口 404 但没进 catch | fetch 不把 HTTP 状态码当作错误 | 显式检查res.ok,非 2xx 手动抛错 |
| 控制台报 JSON 解析错误 | 响应体不是 JSON(可能是 HTML 错误页) | 先看响应内容,用res.text()打印确认 |
| 跨域请求失败 | 后端未配置 CORS 或配置不完整 | 需要后端加Access-Control-Allow-Origin等头 |
| POST 后后端收不到参数 | body 没 stringify 或 Content-Type 用错 | 用JSON.stringify并保持请求头一致 |
| loading 一直不消失 | finally 没写,或提前 return 跳过了恢复逻辑 | 用try/catch/finally保证状态归位 |
| 请求返回顺序错乱 | 没有处理竞态条件 | 用请求序号标记,或 AbortController 取消旧请求 |
| 中文乱码 | 后端返回非 UTF-8 编码 | 和后端约定统一 UTF-8,或手动转码 |
这张表和前面几节的内容是对应的,建议收藏。我后来每次遇到接口问题,先对照一遍,省下大量盲查的时间。
5.2 一次加载状态不消失的排查过程
分享一个实际案例。某次写列表页,接口本来就慢,loading 一直转是对的,但接口返回后页面还是转圈,明显有问题。
排查路径是这样的:先在 Network 面板确认接口已经返回,状态码 200,响应体也正常。然后看代码,发现问题是这样写的:
onMounted(async () => { loading.value = true const res = await fetch('/api/list') list.value = await res.json() loading.value = false })表面看没什么问题,但接口抛异常时loading.value = false这行根本不会执行。虽然当时接口成功返回了,可是列表渲染阶段如果因为数据结构不对报错,同样会卡在 loading 状态。
修复方案就是前面反复强调的try/catch/finally结构:
onMounted(async () => { loading.value = true try { const res = await fetch('/api/list') list.value = await res.json() } catch (e) { error.value = e.message } finally { loading.value = false } })这个改动看起来很小,但作用很大。finally保证不管成功还是失败,加载状态都会复位。这是一条写成肌肉记忆的规则:所有涉及 loading 状态的异步操作,恢复 loading 的代码永远放在finally里。
排查过程中还有一个细节值得注意:不要只盯着报错信息,要先确认请求本身的状态。浏览器 Network 面板里的请求行是红色还是绿色、状态码是多少、响应体是否正常,这一步能立刻区分是前端逻辑问题还是后端接口问题。我一般先看 Network,再看 Console,最后看代码,这个顺序基本不会跑偏。
关于我自己的习惯,再补一句:用 fetch 做接口调用,封装层不一定越复杂越好。如果项目只有三五个接口,一个 request 函数加 res.ok 判断就足够了。等接口量增多、错误处理逻辑变重的时候,再逐步往封装里加统一拦截和业务状态判断。这样一路用下来,你会发现 fetch 不仅够用,还能帮你想清楚请求层每一行代码到底是干什么的。