ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue项目用fetch替代axios:从基础用法到请求层封装

Vue项目用fetch替代axios:从基础用法到请求层封装

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 的优势与不足

先摆一个对比表,方便你选型时判断:

对比维度fetchaxios
依赖方式浏览器原生,零依赖第三方库,需要安装
返回数据解析需要手动调用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 但没进 catchfetch 不把 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 不仅够用,还能帮你想清楚请求层每一行代码到底是干什么的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进