Vue项目HTTP请求层封装实战:axios拦截器与接口管理最佳实践

发布时间:2026/10/12 14:22:56
Vue项目HTTP请求层封装实战:axios拦截器与接口管理最佳实践 写 Vue 项目只要涉及页面和用户交互就躲不开 http 请求。不管是拉列表、提表单、上传文件还是做无限滚动归根结底都是浏览器在和服务端打交道。我见过不少朋友一上来就axios.get满天飞页面里到处是.then和.catch短期看着功能能跑等业务复杂起来改个接口地址都要全局搜索替换加个统一的鉴权头得翻遍几十个文件这种债迟早要还。我个人的经验是与其等代码乱到没法收拾不如在项目初期就静下心把请求层设计好。这篇文章就围绕“Vue 发起 http 请求”这件事把我这些年沉淀下来的方案、封装思路、踩过的坑一次性写清楚适合刚写完几个 demo 想进阶的初学者也适合正在重构老项目、想统一请求层的开发者参考。1. 先把方案选明白axios 还是 fetch很多新手纠结的第一件事就是到底用 axios 还是用浏览器原生 fetch我直接给结论在常规 Vue 项目中axios 依然是更稳妥的选择而且大概率你会需要在它之上做二次封装这个结论不是拍脑袋而是基于实际工程里的几个硬需求。原生 fetch 的优势是浏览器内置、Promise 风格、体积小配合async/await写起来确实清爽。但它有几个很实际的短板比如默认不带 cookie跨域时credentials要手动配置超时控制没有内置机制需要借助AbortController自己绕对 HTTP 错误状态码404、500 这类不会自动 reject需要在 then 里手动判断res.ok一不注意就容易漏。axios 在浏览器端基于 XHR 封装天然处理了上面这些痛点withCredentials、timeout、validateStatus都是内置的而且它拥有非常成熟的拦截器机制——请求拦截器可以用来注入 token、时间戳、设备信息响应拦截器可以统一解包、统一兜底错误。更重要的是团队协作时 axios 的约定大家更容易达成一致从面试到干活都属于默认技能。如果你是小项目或者demo用 fetch 完全没问题但凡是正经业务项目我的建议是直接上 axios别让基础层成为未来踩坑的起点。这一节先说明白选型下面几节进入正题怎么写、怎么封装、怎么避免灾难。1.1 常见误区在组件里到处写请求我见过不少代码是这样写的// 某页面 methods: { async fetchList() { const res await axios.get(/api/list, { params: this.query }) this.list res.data.data } }这样写在功能上没问题但它有几个隐患第一请求逻辑和组件生命周期强耦合组件销毁时请求还在飞可能引发状态更新警告第二接口路径散落在组件里一旦后端调整接口前缀或域名改起来想死第三错误处理很难统一做一个页面复制一次 catch 逻辑写十次有八次是 console.log 完事。正确思路是把“请求”看成独立的数据层和“展示”所在的组件层分开。组件只关心调用某个函数拿到数据至于请求怎么发、鉴权头怎么加、错误怎么处理那是封装层的事。这也是为什么 axios 生态下几乎所有人都建议单独封装一个request实例。2. 从零封装一个可复用的 request 实例项目里的 http 请求不能裸用 axios而是要创建一个独立模块专门负责和 axios 打交道。我在新项目的实践是src/utils/request.js或src/api/request.js导出配置好的实例。以下是最基础且实用的骨架import axios from axios import { ElMessage } from element-ui // 假设用了 element-ui也可换成你自己的 UI 提示 const service axios.create({ baseURL: process.env.VUE_APP_BASE_URL || /api, timeout: 15000 // 15 秒超时 }) // 请求拦截器 service.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } // 可以在这里加 loading、加时间戳、加签名 return config }, error Promise.reject(error) ) // 响应拦截器 service.interceptors.response.use( response { const res response.data // 约定后端返回 { code: 200, data: ..., message: ... } if (res.code ! 200) { ElMessage.error(res.message || 业务异常) return Promise.reject(new Error(res.message || Error)) } // 直接返回 data业务侧拿到的就是干净数据 return res.data }, error { // 统一处理 HTTP 错误 const status error.response?.status if (status 401) { // 跳到登录页或刷新 token } else if (status 404) { ElMessage.error(资源不存在) } else if (status 500) { ElMessage.error(服务器开小差了请稍后重试) } return Promise.reject(error) } ) export default service这个文件值得每一个细节都掰开揉碎讲清楚。2.1 为什么响应拦截器只返回res.data很多人不明白这行代码的意义。统一约定后端返回结构是{ code, data, message }拦截器把res.data拿出来返回意味着业务代码里不需要再写res.data.data这种咯噔写法。组件里的调用就变得非常优雅const list await getList(params) // list 直接就是数组这个“解包”行为本质上是把后端的通信协议和数据体剥离开。你对接的是「业务数据」而不是「传输协议」这是职责划分的方式。2.2 拦截器里到底该干什么请求拦截器适合加三类东西认证信息token、防并发/防重复标记、公共参数版本号、渠道号。但要注意一个原则不要什么都塞进拦截器。我见过有人把埋点、日志、加密、设备信息全部塞进去最后拦截器比业务代码还长排查问题时候头都大。拦截器只处理“全局统一、明确无争议”的事情跟业务强相关的参数请在具体请求函数里传不要在拦截器里搞特殊逻辑。响应拦截器除了解包和错误兜底还能做一个很实用的事把 loading 状态全局统一下掉。如果你有全局 loading 需求可以把 loading 实例挂到一个变量上请求拦截器里打开、响应拦截器里关闭。但要注意并发场景——两个请求同时进行前一个返回时把 loading 关了后一个还没结束界面就会闪一下。所以建议用计数器loadingCount和loadingCount--归零时才关闭。3. 请求模块的组织方式按接口文档翻译代码封装好了实例接下来就是如何组织业务请求函数。我习惯把每个页面的接口都写进独立的 api 文件比如用户模块就是src/api/user.js商品模块是src/api/product.js。文件的职责很单一接收参数、调用 request、返回 Promise。说白了这个文件就是接口文档的“翻译代码”目的就是让组件不要直接碰 URL。一个典型请求函数的写法// src/api/user.js import request from /utils/request // 获取用户列表 export function getUserList(params) { return request({ url: /user/list, method: get, params }) } // 创建用户 export function createUser(data) { return request({ url: /user/create, method: post, data }) } // 删除用户 export function deleteUser(id) { return request({ url: /user/delete/${id}, method: delete }) }组件里使用时就不需要关心 URL 和 method 了import { getUserList, createUser } from /api/user async function handleSearch() { loading.value true try { const list await getUserList({ page: 1, size: 20 }) tableData.value list } finally { loading.value false } }3.1 带 TypeScript 时怎么让类型更舒服如果你用的是 Vue 3 TypeScript可以在封装时把泛型加上这样“翻译代码”的体验可以更进一步。比如这样// src/utils/request.ts import axios, { AxiosRequestConfig } from axios const service axios.create({ baseURL: /api }) export interface ApiResponseT unknown { code: number data: T message: string } export function requestT(config: AxiosRequestConfig): PromiseT { return service.requestApiResponseT(config).then(res res.data.data) }再写 api 函数时可以声明返回类型// src/api/user.ts export interface UserInfo { id: number name: string email: string } export function getUserInfo(id: number) { return requestUserInfo({ url: /user/info/${id}, method: get }) }这样在组件里拿到userInfo编辑器会自动提示.id、.name这些字段比 any 漫天飞舒服太多。如果项目还没引入 TS这段可以先收藏等切换时回来翻。3.2 为什么一定要“按模块”建文件夹有人说接口就几十个平铺一个api.js不也很方便规模小时确实能凑合但业务起来后分模块折叠的好处是明显的第一文件名即业务域协作时一眼知道去哪个文件找第二团队做 git 提交时冲突面变小每个人只动自己那部分文件第三后续做接口 mock、权限控制、按模块按需加载都有清晰载体。我习惯在src/api下再放一个index.js把所有模块统一导出组件引入时更整齐// src/api/index.js export * as userApi from ./user export * as productApi from ./product export * as orderApi from ./order组件里import { userApi, productApi } from /api多个模块的 api 一起用时不用写一堆 import 语句视觉上干净不少。4. 请求状态管理与交互细节loading、错误与边界处理封装好请求函数后还有一批高频场景要处理到位踩的人不少新手尤其容易掉坑。4.1 请求 loading 不要用全局 boolean 硬扛很多人写请求时会声明一个页面的loading变量然后loading true请求回来loading false。单个请求没问题一旦页面里同时有三个请求分别管理三个 loading 变量页面上按钮禁用状态就很难维护。我的做法是尽量用一个对象管理各块 loading 状态const loading reactive({ list: false, submit: false, detail: false }) async function fetchList() { loading.list true try { list.value await getList() } finally { loading.list false } }这样按钮和表格区域各自绑定各自的 loading互不干扰。用finally而不是在成功回调里关闭 loading是因为失败时也要关否则请求报错后按钮永远转圈。4.2 竞态问题最后一次请求才有效这是体验里很隐蔽的坑。用户在搜索框里快速输入每敲一个字就触发一次接口请求网络响应顺序不一定先发的请求可能慢后发的请求可能反而先到。如果直接把结果赋给列表很可能出现“旧请求的结果覆盖新请求”的情况——列表内容和输入框关键词对不上。三种常用解法第一请求竞态标识let requestSeq 0 async function search(keyword) { const current requestSeq const list await searchApi({ keyword }) if (current requestSeq) { tableData.value list } }第二取消旧请求。axios 支持AbortControllerlet controller null function search(keyword) { if (controller) controller.abort() controller new AbortController() searchApi({ keyword }, { signal: controller.signal }).then(res { tableData.value res }) }第三防抖配合。搜索场景我一般“防抖 取消旧请求”双管齐下防抖控制频率取消保证最新请求一定生效。4.3 超时与重试的道道全局超时 15 秒是常规配置但有些操作需要更长等待比如导出报表、批量处理。不要把时间调死放到请求参数里按需配置// 导出大文件单独等 60 秒 export function exportReport(params) { return request({ url: /report/export, method: post, params, timeout: 60000 }) }重试机制建议谨慎用只在“幂等且网络层错误”比如超时、502、503时可以重试业务错误code 非 200不要去重试那样只会放大问题。我自己写过一个小函数对 GET 请求默认做一次重试POST 请求一律不重试async function requestWithRetry(config, retries 1) { try { return await service(config) } catch (err) { if (retries 0 config.method get !err.__handled) { return requestWithRetry(config, retries - 1) } throw err } }这行逻辑看着不起眼线上网络抖动时能少很多用户投诉。5. 高频问题与排查技巧实录这部分是最不容易写进文档、但实战价值很高的内容。我把这些年自己项目里遇到的、以及在社区里看别人问得最多的几类问题汇总一下按“现象-原因-解法”的方式整理成速查方便你直接对照。5.1 请求发了但 Vue 页面数据没更新这是 Vue 老玩家也常踩的坑。最常见原因是往响应式数据里添加了新的对象属性比如// 请求回来后 this.data response.data // OK整体替换 this.data.newProp response.extra // 不是响应式的页面不会更新第二种常见原因是赋值了但表格/列表绑定的是某个深层对象而对象没被正确触发依赖追踪。Vue 3 中如果是用reactive定义的对象注意解构赋值不要丢掉代理// 错误 const { list } reactive({ list: [] }) // list 被解构出来就失去了响应性// 正确 const state reactive({ list: [] }) // 使用时 state.list排查思路很简单先在代码里打印出返回的数据判断是没拿到、还是拿到了没更新。如果拿到了没更新十有八九是响应式链路断了。5.2 拦截器里弹错误提示业务里又弹一次响应拦截器已经统一弹了业务错误业务代码里如果 catch 又弹一次用户会看到两条重复报错。这个问题的根子在于错误处理职责划分不清晰。我的约定是拦截器只负责处理“全局通用错误”和“网络层错误”并且通过返回Promise.reject把错误继续向上抛。业务侧捕获后如果这个接口需要对用户做特殊提示就自己调用提示如果不需要就直接忽略。为了区分我习惯在拦截器里给错误对象加个标记error.__isHandled true这样业务侧 catch 到错误时可以先判断err.__isHandled已经提示过的就不再重复弹没有标记的才自己处理。5.3 上传文件时进度条怎么处理上传进度是 axios 的onUploadProgress回调export function uploadFile(file, onProgress) { const formData new FormData() formData.append(file, file) return request({ url: /upload, method: post, data: formData, onUploadProgress: e { if (e.total) { const percent Math.round((e.loaded / e.total) * 100) onProgress onProgress(percent) } } }) }在组件里展示进度条时注意上传完成到接口响应返回会有一个等待后端处理的时间进度条会卡在 100% 或者短暂停顿这是正常的。别因为进度条没到 100 就以为挂了可以结合接口响应时间做“假进度”平滑也可以就保持真实进度告诉用户“已提交等待处理中”。5.4 本地开发跨域上线又跨域怎么配置最省心本地环境下浏览器直接请求接口域名会被 CORS 拦截最常见的选择是用 Vite 或 webpack-dev-server 的代理能力。以 Vite 为例// vite.config.js export default { server: { proxy: { /api: { target: https://your.server.com, changeOrigin: true, rewrite: path path.replace(/^\/api/, ) } } } }开发时前端请求/api/user/list本地代理转发到真实服务器。上线时 Nginx 配置同样的转发规则。这样前后端联调时前端代码里的 URL 始终保持/api开头不需要根据环境切换省掉很多环境判断逻辑。baseURL 直接用环境变量管理就够// 开发环境走本地代理生产环境走同源 baseURL: process.env.NODE_ENV development ? /api : /api实际上很多时候生产环境也走同源/api由网关转发前端只需要保证一个约定。5.5 注意 HTTP 缓存带来的“数据不刷新”用的最多的 GET 接口浏览器可能会缓存响应。用户改了个人资料再查详情时却拿旧数据。排查时看 Network 面板如果请求显示from disk cache或者200 OK (from memory cache)基本就是缓存命中了。规避方法是给 GET 请求加时间戳参数或者在响应头里控制Cache-Control: no-cache。我个人不太建议全局加时间戳会让排查问题时很乱。更优雅的是后端对特定接口响应头设置或者只在必需的几个接口上传params: { _t: Date.now() }。6. 组合式 API 场景下请求 Hook 化说完大而全的封装再补充一个 Vue 3 里很常见、也很值得用的模式把请求逻辑和状态管理提取为use开头的组合式函数。这是我在新项目中非常推荐的做法写起来舒服组件里也更干净。比如一个用户列表请求 hook// src/composables/useUserList.js import { ref } from vue import { getUserList } from /api/user export function useUserList() { const list ref([]) const loading ref(false) const error ref(null) const total ref(0) async function fetchList(params) { loading.value true error.value null try { const res await getUserList(params) list.value res.list total.value res.total } catch (err) { error.value err } finally { loading.value false } } return { list, loading, error, total, fetchList } }组件里用script setup import { useUserList } from /composables/useUserList const { list, loading, fetchList } useUserList() fetchList({ page: 1 }) /script这样做的好处是彻底把数据逻辑从组件中抽离组件只需要负责渲染和交互。尤其是同一个请求模式在多个页面复用时比如列表页和弹窗选择器都要拉用户列表抽成一个 hook后续加缓存、加轮询、加依赖刷新都只需要改一个文件。6.1 请求轮询与自动刷新的 hook 设计再高级一点的用法需要轮询时把定时器封装进 hook 里配合组件卸载自动清除。import { onUnmounted, ref } from vue export function usePolling(fn, interval 5000) { let timer null const isPolling ref(false) function start() { if (isPolling.value) return isPolling.value true const tick async () { await fn() if (isPolling.value) { timer setTimeout(tick, interval) } } tick() } function stop() { isPolling.value false if (timer) { clearTimeout(timer) timer null } } onUnmounted(stop) return { start, stop, isPolling } }这里有个细节我用的setTimeout而不是setInterval。核心原因是避免接口请求还没结束时定时器又触发下一次导致请求叠加。setTimeout保证上一次完成后才开始计时请求慢的时候自然顺延。这个 trick 应用到监控大屏、订单状态追踪这类页面上效果非常明显。7. 工具链与周边生态的实用推荐封装好基础能力后再推荐几个跟请求强相关的周边工具能让你少走弯路。第一接口 mock。后端还没好前端不能等。我常用的是在 Vite 环境里配合本地 mock 中间件或者更轻量的方式是写一个mock函数拦截 axios 适配器。推荐先用vite-plugin-mock这类方案不需要额外起服务直接在项目里定义接口返回联调时一键切换。第二请求日志。调试时非常需要能直观看到“谁发了什么请求、返回了什么”。Vue 生态里可以简单封装一个开发环境的 request logger// 仅开发环境打印 if (process.env.NODE_ENV development) { service.interceptors.request.use(config { console.group([HTTP] ${config.method.toUpperCase()} ${config.url}) console.log(params:, config.params) console.log(data:, config.data) console.groupEnd() return config }) }这个日志在排查前端数据不更新、参数传错时比在业务代码里打一百个 console 都高效。当然也可以直接用成熟的浏览器调试工具但自己打印过的请求会更清晰。第三接口文档一体化。如果你有精力折腾可以做类似 swagger 文档自动生成 api 代码的操作把接口文档和请求代码联动起来后端改了文档前端代码跟着更新。这个属于团队基建初期不用追求但方向值得知道。8. 最后的坑位提醒安全与密钥很多人忽略的一点前端任何写进 JS 里的密钥、密码、签名 secret都是不安全的。无论是请求头还是请求体别人看代码或者抓包都能拿到。所以涉及核心支付、订单、用户敏感数据时一定不要在前端做签名算法、不要在前端保存服务端密钥。前端只负责带上用户凭证如 token服务端做最终校验和鉴权。请求拦截器里可以看到 token 加头发送但 token 本身的有效期、刷新逻辑、过期后的跳转流程务必要设计好。这里推荐一个实践token 不要存 localStorage存内存变量或 cookieHttpOnlylocalStorage 被 XSS 攻击一打一个准。不过这个属于安全工程范畴展开又是一篇长文记住这个方向即可。回头聊聊个人体会。我早期做项目时也是“够用就行”能拉到数据就好直到一次线上事故让我改观接口超时没有统一处理全局 loading 卡死一个用户操作导致一连串请求失败页面直接白屏等待超时。那次之后我下定决心把所有项目的请求层统一重写之后的开发效率提升得非常明显。封装请求这事的收益前期不明显等到业务复杂、接口上百个、团队多人在同一个项目里协作时才会深刻感受到早期规矩的价值。最后再分享一个小技巧写请求函数时任何参数都尽量用具名对象的形式传入不要用散装参数。比如getUserList({ page: 1, size: 20 })比getUserList(1, 20)好维护得多。顺序参数用多了早晚会因为有人传反、漏传而出 bug散装参数的问题在接口多了以后会加倍放大。这个习惯越小养成后来越省事。希望这篇分享对你有帮助文字能把我踩过的坑转化为你们的捷径。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询