Vue3 项目的网络请求层,看起来是无数教程里“安装 axios、发个 GET、拿个数据”三步走的事,但真正被问到痛点时,往往不是“怎么发请求”,而是“为什么我的请求层越写越乱”“拦截器到底该放什么逻辑”“请求取消到底怎么落地”。这篇文章从实际项目出发,把 Vue3 组合式 API 环境下 Axios 的完整用法、封装思路、拦截器设计、并发与取消处理、类型安全适配这些事一次讲透。适合正在写 Vue3 项目、想把请求层做得干净可控的开发者,也适合刚接触 Axios 但不想只停留在 demo 层面的朋友。
1. 为什么是 Axios:做方案选型前先看清楚 Ajax 工具的真相
Vue3 项目发异步请求,大家默认会用 Axios,但很多同学其实没想明白“为什么是它”。我自己见过不少团队,项目里直接拿原生fetch就开干了,写到后期才被各种头疼的问题缠上。这里先花点篇幅把这些年的经验聊清楚,免得你后面踩同样的坑。
1.1 原生 fetch 的真实短板,以及它为什么替代不了 Axios
原生fetch不是不能用,而是它的底层设计决定了它处理复杂业务场景时很吃力。
第一,请求中断非常麻烦。fetch虽然支持AbortController,但你需要手动把 controller 实例传进请求函数,组件里哪个请求取消了、哪个没取消,代码一旦多起来就是灾难。而 Axios 在浏览器端同时支持AbortController和自身早期 API 里的CancelToken,封装成统一的取消策略后,代码干净得多。
第二,超时控制需要自己实现。fetch没有内置超时机制,你得自己包一层Promise.race或者AbortSignal.timeout()。但这两种方案都有副作用:Promise.race只能让外层 Promise 先挂掉,底层请求还在跑;AbortSignal.timeout()兼容性在老版本浏览器也有限。Axios 直接在配置项里写timeout: 10000,一行搞定,超时后自动断开。
第三,拦截器这种横切逻辑,fetch完全没有对应机制。一个项目里要注入 Token、要在响应到达时统一解包、要在出错时统一弹提示,用fetch就得在每个调用处重复写这些代码。而 Axios 的设计天生就是为这类横切关注点准备的。
第四,上传进度的监听。fetch至今没有原生的上传进度 API,只能靠XMLHttpRequest的实现来模拟,而 Axios 的onUploadProgress配置项底层就是用 XHR 实现的,拿来即用。
1.2 Axios 的核心机制:适配器、拦截器、转换器
Axios 这套设计的核心,我总结下来是三件事:
适配器(Adapter)机制。Axios 在浏览器端默认走XMLHttpRequest,在 Node 端默认走http/https模块,这个切换是内部适配器完成的。这意味着你写一套 API,浏览器和 Node 环境都能跑。更重要的是,你可以自己传入自定义适配器,后面在做测试 Mock 的时候这个特性非常有用。
拦截器(Interceptor)机制。Axios 把请求和响应流程各拆成一条管道,你可以往管道里挂函数,请求发出去前按顺序过请求拦截器,响应回来后再按顺序过响应拦截器。这正是业务中最需要的“统一处理点”。
转换器(Transformer)机制。发请求时要把 JS 对象转成 JSON 字符串,收到响应后要把 JSON 字符串转回 JS 对象,这些默认的序列化与反序列化逻辑是交给transformRequest和transformResponse处理的。遇到特殊数据格式时,你可以改掉默认行为。
提示:理解了这三个核心机制,后面不管怎么封装,你都是在跟“管道 + 钩子”打交道。搞清楚这套心智模型,比背 API 重要得多。
1.3 在 Vue3 项目里选择 Axios,还有一个隐藏理由
Vue3 的组合式 API 和ref/reactive这套响应式体系,跟 Axios 配合起来非常顺手。请求的结果可以很自然地赋给响应式变量,再配合computed做派生状态,整个数据流是清晰的。加上 Axios 的 TypeScript 类型定义也比较完善,封装好之后团队协作的提示体验会提升一大截。
2. 从零搭建请求层:Vue3 组合式 API 下的 Axios 落地姿势
很多人上手 Axios 都是直接在组件里写:
import axios from 'axios' axios.get('/api/user').then((res) => { userList.value = res.data })这种写法在 demo 里没问题,但放到真实项目里要不了几天就失控:请求散落在各个组件,接口地址随意拼接,请求头不统一,错误处理到处重复,一旦后端接口路径全换一遍,你就要全局搜索替换。所以工程化的第一步,一定是做统一封装。
2.1 基础用法与组件内直接请求的坑
组件内直接使用 axios 的明显问题有三个。
一是无基础路径管理。项目里/api前缀、代理路径、版本前缀,散落在各处,哪天改配置就是灾难。
二是无法统一注入请求头。Token 会变,每个请求都要手动取一下 localStorage,漏掉一处就得调试半天。
三是难以处理统一的错误弹层。后端返回统一格式{ code: 200, message: 'ok', data: ... },每个组件都要判断一次code,重复代码非常多。
所以下面这套分层方案,是我在多个 Vue3 项目里反复打磨出来的,基本可以无脑套用。
2.2 封装 Axios 实例:baseURL、超时、请求头统一管理
创建实例是第一步。用axios.create生成一个独立实例,避免污染全局默认配置:
// src/utils/http.js import axios from 'axios' const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000, headers: { 'Content-Type': 'application/json;charset=utf-8', }, }) export default http注意两个工程细节:
baseURL从环境变量读取,不同环境(本地开发、测试环境、生产环境)通过.env.development、.env.production分别配置,构建时自动替换。timeout默认 10 秒。这个值不是随便定的,要看项目里最慢的业务接口平均响应时间。一般建议在 8~15 秒之间,太短容易误杀慢查询,太长用户等待感太强。
2.3 请求函数模块化:按业务域拆分接口
有了实例还不够,请求函数还是得收敛到一个地方。常见的做法是建src/api目录,按照业务域拆分模块:
// src/api/user.js import http from '@/utils/http' export function getUserList(params) { return http.get('/user/list', { params }) } export function createUser(data) { return http.post('/user/create', data) }组件里只依赖这些导出的函数,根本不知道“axios”的存在:
// src/views/UserList.vue import { getUserList } from '@/api/user' async function loadUsers() { const res = await getUserList({ page: 1, size: 20 }) userList.value = res.data }这样做的好处是:接口路径变了,只改 api 模块;接口入参变了,只改 api 模块;后端接口需要额外参数,还是只改 api 模块。组件层保持了完全干净的调用方式。
实操建议:如果你的项目接口非常多,建议按后端模块建文件:
user.js、order.js、goods.js这样。命名用“资源 + 动作”的方式,如getUserList、createUser,团队成员看到函数名就知道调的是哪个接口,不需要查文档。
3. 拦截器与业务场景的深度融合:这才是 Axios 的灵魂
实例和模块化只是地基,真正让 Axios 产生巨大价值的是拦截器的设计。很多项目拦截器只写了一层 Token 注入,这远远不够。我在项目里一般把拦截器拆成三个关键角色:请求鉴权、响应解包、错误路由。
3.1 请求拦截器:Token 注入与公共参数携带
请求拦截器主要负责在请求发出前把“每个请求都必须带的东西”统一加上。最常见的是 Token 注入:
http.interceptors.request.use( (config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, (error) => Promise.reject(error) )除了 Token,团队里可能还有公共参数(如渠道号、客户端类型、设备指纹)。这些不要散落在各业务代码里,一并在这里追加:
http.interceptors.request.use((config) => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } const channel = getChannel() if (channel) { config.headers['X-Channel'] = channel } return config })注意:不要在这里做“提示用户登录”这种事情。拦截器只负责“加料”,不负责“引导”。登录跳转和弹窗逻辑应该放在响应拦截器里,因为请求拦截器阶段你还不确定请求会不会 401。
3.2 响应拦截器:统一数据解包与业务错误路由
响应拦截器解决两件事:解包和业务错误码路由。
后端返回统一结构时,与其让每个业务代码都res.data.data,不如在拦截器层直接解包:
http.interceptors.response.use( (response) => { const res = response.data if (res.code !== 200) { // 业务错误,比如登录过期、参数错误、权限不足 if (res.code === 401) { // 统一跳转登录逻辑可以在这一层处理 redirectToLogin() return Promise.reject(new Error('登录已过期')) } showToast(res.message || '请求失败') return Promise.reject(new Error(res.message || '请求失败')) } return res }, (error) => { // HTTP 层面的错误 handleHttpError(error) return Promise.reject(error) } )解包之后,业务代码拿到的就直接是业务数据:
const res = await getUserList({ page: 1, size: 20 }) // res.data 就是业务里的列表数据,稍做类型转换即可3.3 状态码与错误处理:HTTP 错误、业务错误、网络错误三层分离
错误处理最容易写成一团浆糊。我建议把错误分成三层:
第一层:HTTP 层错误。比如 404、500、502。这些发生在响应拦截器的第二个回调里,通常与业务无关,提示“服务异常,请稍后重试”即可。
第二层:业务层错误。返回了 HTTP 200,但 body 里的code不是 200。这里要区分登录态失效、没有权限、参数校验失败、服务端业务异常等类型,分别路由到不同处理流程。
第三层:网络层错误。超时、断网、请求被取消。Axios 的网络错误error对象里有一个error.code字段,比如ECONNABORTED表示超时,ERR_CANCELED表示请求被取消。对这类错误,提示文案要更轻,因为很多时候是用户网络问题。
我通常在业务代码里使用统一错误处理入口:
class RequestError extends Error { constructor(message, code, data) { super(message) this.code = code this.data = data } }拦截器里处理不了的具体业务错误,就reject一个 RequestError,业务代码用try/catch捕获后,按code精确处理。
4. 进阶实战:请求取消、并发控制与应用内体验优化
到了这一层,已经是比较成熟的团队才会用到的东西。但这部分真的不是炫技,而是实际场景里能救命的技能。
4.1 请求取消的实现与正确选择
在 Vue3 组件中,路由切换时未完成的请求如果不去取消,会带来两个问题:一是浪费网络资源,二是请求响应回来后组件可能已经卸载,更新响应式数据时会报警告甚至引发内存泄漏风险。
Axios 在浏览器端有两种取消方式:
第一种是AbortController:
const controller = new AbortController() axios.get('/api/data', { signal: controller.signal, }) // 取消 controller.abort()第二种是 Axios 的CancelToken(新版本依然兼容,但官方更推荐 signal):
const source = axios.CancelToken.source() axios.get('/api/data', { cancelToken: source.token, }) // 取消 source.cancel()在 Vue3 组合式 API 下,更推荐把取消逻辑封装成可复用的 composable,或者配合生命周期自动取消:
import { onBeforeUnmount } from 'vue' import axios from 'axios' export function useAutoCancelRequest() { const controllers = new Set() function register(controller) { controllers.add(controller) } function cancelAll() { controllers.forEach((controller) => controller.abort()) controllers.clear() } onBeforeUnmount(cancelAll) return { register, cancelAll } }注意:取消请求并不会报业务错误,通常会被 Axios 包装成
CanceledError。在拦截器里要特殊处理这类错误,不要在业务弹窗里弹“请求被取消”,直接静默吞掉即可。
4.2 并发控制与请求合并的实用场景
并发请求是另一个高频需求。
最简单的场景是“多个接口并行加载”,比如页面同时需要用户信息、菜单权限、系统配置。三个请求互不依赖,这时可以并行发出:
const [userInfo, menuList, sysConfig] = await Promise.all([ getUserInfo(), getMenuList(), getSysConfig(), ])但有一种场景不能用 Promise.all 粗暴处理:多请求合并提交。比如前端一次性提交大量数据,但后端按批次处理,或者为了减少请求数做合并加载。这时候需要自己实现并发池,控制同一时间发出的请求数量,避免瞬间打爆后端:
async function runWithConcurrency(tasks, limit = 3) { const results = [] const executing = new Set() for (const task of tasks) { const p = task().then((res) => { results.push(res) executing.delete(p) }) executing.add(p) if (executing.size >= limit) { await Promise.race(executing) } } await Promise.all(executing) return results }这个封装也很适合用在“批量上传图片但每次最多并发 3 个”的场景里。
4.3 文件上传进度与 SPA 中的 Loading 状态管理
Axios 的onUploadProgress是上传大文件时的利器:
function uploadFile(file, onProgress) { const formData = new FormData() formData.append('file', file) return http.post('/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', }, onUploadProgress: (progressEvent) => { const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100) onProgress?.(percent) }, }) }组件里拿到进度百分比,配合一个进度条组件展示即可。下载进度可以用onDownloadProgress,逻辑类似。
跟 loading 相关的还有另一个经验:全局 loading 不要做成一刀切。不是每个请求都需要全屏 loading,有些是局部按钮 loading。我一般会在 api 函数里做二次封装,让调用方自定义是否开启全局 loading:
export function getUserList(params, config = {}) { return http.get('/user/list', { params, ...config, }) }在响应拦截器里结合一个全局计数器的方案来管理全局 loading 的显隐,请求发起时计数加一,响应回来时减一,计数归零时关闭 loading。这样处理并发请求时不会出现第一个请求结束就提前关掉 loading 的尴尬问题。
5. 类型安全、测试适配与常见问题排查
最后这块内容,看起来不如前面“刺激”,但恰恰是决定你的请求层能不能长期维护、团队协作顺不顺的关键。
5.1 TypeScript 泛型化:让响应数据变得可预期
Axios 自带完善的类型定义,但如果直接使用默认的response.data,类型往往退化成分散的结构。更好的做法是设计一套泛型,让后端返回的{ code, message, data }每个字段都类型明确:
// src/types/api.d.ts export interface ApiResponse<T = unknown> { code: number message: string data: T }封装请求函数时,直接指定返回类型:
import http from '@/utils/http' import type { ApiResponse } from '@/types/api' export interface UserItem { id: number name: string email: string } export function getUserList(params: { page: number; size: number }) { return http.get<ApiResponse<UserItem[]>>('/user/list', { params }) }这样调用处拿到res.data时,TypeScript 能直接推导出UserItem[],字段名拼错会在编译期直接报错。
实操经验:后端的
data结构经常会有嵌套,建议为每个后端模块单独维护类型文件,并保持“类型文件、API 文件、后端接口”三者一一对应。后期后端接口改动时,第一时间改类型定义,所有使用处会立即暴露编译错误。
5.2 自定义适配器:测试 Mock 与多后端切换的优雅解法
真实项目中,前端的接口联调往往要等后端就绪,而 UI 开发不能停。很多人会用 mockjs 或者自建 mock server,但一旦接口量多,这种方案也会乱。Axios 的自定义适配器给了另一种思路:
// src/utils/mockAdapter.js const mockAdapter = (config) => { return new Promise((resolve, reject) => { // 通过 config.url 匹配 mock 数据 const mockData = mockMap[config.url] resolve({ data: { code: 200, data: mockData }, status: 200, statusText: 'OK', headers: {}, config, }) }) } const http = axios.create({ baseURL: '/api', }) // 在特定环境下切换适配器 if (import.meta.env.DEV && useMock) { http.defaults.adapter = mockAdapter }好处是业务代码一行不用改,所有请求在开发环境自动走 mock,后端 ready 后直接删掉useMock的开关即可。同理,如果项目要对接多个后端环境,也可以用适配器做请求分发。
5.3 常见问题与排查技巧实录
最后整理一份我在实际项目中高频遇到、也高频被咨询的问题清单:
问题 1:请求超时后一直处于 pending 状态
排查思路:先看 Axios 实例的timeout是否设置成功,再看是否在拦截器里手动 return 了一个永远不会 resolve 的 Promise。还有一个常见原因:请求被浏览器队列阻塞,排在它前面的请求一直没完成,此时网络面板能看到请求排队而不开始。
问题 2:刷新页面后 Token 失效,但接口还在继续发
排查思路:一般是 Token 存储与注入逻辑不一致。比如登录时存了 Token,但请求拦截器读的是另一个 key。解决办法是统一封装存储工具,登录写入与请求读取都走同一个函数。
问题 3:并发请求时全局 loading 闪烁太频繁
排查思路:这就是我前面提的“全局计数器”方案能解决的问题。不要用loading = true/false这种布尔开关,用number计数。
问题 4:取消请求后控制台报 “Uncaught (in promise) CanceledError”
排查思路:原因是你没有为reject的 CancelledError 做处理。建议在所有.catch的地方都先判断:if (error.__CANCEL__) return,或者用 axios 提供的axios.isCancel(error)统一判断。
问题 5:接口数据已经返回了,但页面数据不更新
排查思路:如果是 Vue3 项目,优先检查是否给响应式数据赋值了ref或reactive外的普通变量。还有可能是接口返回的是嵌套层级很深的数据,初始化时没有提前声明ref({ list: [] })这种结构,导致新增字段不具备响应式。
问题 6:组件卸载后请求才返回,会报警告
排查思路:建议在 composable 里封装自动取消逻辑,组件卸载时abort所有未完成请求。如果项目里历史代码太多,改起来成本高,可以在响应拦截器里判断一下:组件已经卸载时不更新数据,只做错误吞掉。
关于测试,还有一个非常实用的经验:在单元测试中,不要真正发网络请求。用自定义适配器替换为内存数据,或者直接对封装好的 api 函数做 mock。这样测试跑得快,也不会因为网络波动整出 flaky 测试。
6. 最后再聊几句这些年踩过的坑
请求层是最容易被低估的模块。很多项目刚开始只写了三五个接口,觉得封装多余,等到几十个接口、多人协作时,才意识到统一管理的重要性。但到那个阶段再重构,成本已经高了几倍。
我在实际项目里体会最深的一件事是:封装不是设计出来的,是需求逼出来的。没必要一上来就整非常重的抽象,先保证“有统一实例、有基础拦截器、请求函数收敛到模块”,这三点做到,就已经超过大多数项目的请求层质量了。后续遇到具体痛点,再逐个击破:需要取消就加取消,需要并发池就写并发池,需要类型安全就上泛型。
还有一个小技巧:给项目里的请求模块配一个 README,把“如何新增接口”“如何处理错误码”“哪些场景用取消”“哪些场景用并发池”写清楚。新同学接手的时候,看一眼就知道规范在哪里,不用到处问人。这个习惯帮团队省掉的沟通成本,远比你想象的多。
如果后续要做 SSR、要做移动端跨端请求、要做 GraphQL 迁移,这套“实例 + 拦截器 + 函数模块”的骨架也能平滑扩展。把 Axios 当成一个管道来用,而不是一堆散装的 API,你才会真正找到工程化的感觉。