1. 散装请求的后遗症一个改动要全局搜索还常常漏改之前接手过一个做了两年的后台管理项目前端是Vue全家桶加axios。刚打开一个订单列表页就看到组件里直接躺着这样的代码axios.get(/api/order/list, { params: this.searchForm }) .then(res { this.orderList res.data.data }) .catch(() { this.$message.error(订单列表加载失败) })表面上看没啥问题可当你真的在这个项目里待上一段时间就明白了axios被import塞进了几十个组件同一个接口换个参数就能在三四个地方重复出现接口地址有的带/api前缀有的不带全凭写代码的人当时的心情。最离谱的是有一次后端要改接口域名运维那边只改了一台测试环境前端直接崩了三四个页面因为有人把完整地址写死在组件里压根没走统一配置。1.1 散装请求的典型症状我总结了一下一个接口管理失控的项目通常会有下面这些症状你可以对照自己手上的项目看一眼地址硬编码组件里直接出现axios.post(http://192.168.1.20:8080/login)这种写法环境切到生产时靠全局搜索替换。搜索一次还不一定搜全因为有人写单引号、有人写双引号有人拼了变量。错误处理各写各的有人用alert有人用组件库的提示框有人在.catch()里什么都不做接口错了只是控制台有红报错用户那边毫无反应。Token获取逻辑五花八门有的从localStorage直接取有的调了一个 store 里的函数有的干脆写死在请求头里过期之后接口返回 401页面却没有统一的跳转处理。环境切换靠手动改开发环境、测试环境、预发布环境、生产环境的地址不一样每次部署前要人工检查漏改了就是线上事故。接口与新同事的理解成本高新同学入职看代码想找一个用户列表的接口不知道去哪找只能在 IDE 里全局搜索大概的关键字效率很低。1.2 为什么会变成这样说句公道话项目刚起步的时候组件里直接发请求是最高效的。一个小页面就那么两三个接口没必要先铺一套封装人力不够的时候先跑起来才是正事。问题出在项目变大之后没有及时补上这层管理等到接口数量超过一百个、协作人数超过三个人散装代码的维护成本就开始指数上升。所以接口统一管理这件事本质上不是把代码集中到一个文件夹里就完事了而是要解决四个问题环境可切换同一套代码能通过配置快速匹配开发、测试、生产环境不靠人肉改。请求出入口唯一所有请求尽量通过一个封装实例发出统一注入鉴权信息、统一处理业务错误。业务模块清晰接口按业务领域组织找接口像翻书一样不需要全局搜索。异常有兜底网络错误、业务错误、登录过期都有统一处理而不是每个组件各写一套。下面我按自己的实操顺序从环境配置讲到拦截器设计再到具体场景的坑把这套东西完整展开。2. 基础设施先行环境配置与axios实例的最小闭环统一管理的第一步不是写拦截器而是先把环境问题解决掉。因为底层的baseURL都定不下来后面写的所有代码都得返工。我见过有人先把拦截器封装好了结果环境地址还在用process.env.NODE_ENV production粗糙判断最后还是出了问题。2.1 用环境变量区分接口前缀现在新项目大多数用 Vite 构建老项目还有 Vue CLI。两者的环境变量读写方式不同但思路一样不同模式加载不同的.env文件。以 Vite 为例在项目根目录建这几个文件# .env.development VITE_API_BASE_URL/api VITE_REQUEST_TIMEOUT15000# .env.test VITE_API_BASE_URLhttps://test-api.xxx.com VITE_REQUEST_TIMEOUT15000# .env.production VITE_API_BASE_URLhttps://api.xxx.com VITE_REQUEST_TIMEOUT15000Vite 里只有以VITE_开头的变量会暴露给前端代码所以命名必须要规范别图省事写个API_BASE就完事等你要在代码里读取时发现自己费半天劲拿不到那才难受。代码里通过import.meta.env.VITE_API_BASE_URL读取。如果是 Vue CLI 的老项目前缀要换成VUE_APP_读取方式是process.env.VUE_APP_API_BASE_URL。这个细节新老项目切换时特别容易踩我过渡期吃到过教训写在这里提醒一句。2.2 创建axios实例统一出入口的起点环境变量就绪后在src/utils/request.js里创建一个axios实例。这个实例是后续所有接口的统一出入口// src/utils/request.js import axios from axios const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: Number(import.meta.env.VITE_REQUEST_TIMEOUT) || 15000, withCredentials: false }) export default service几个参数说明一下baseURL所有请求的前缀后面写接口地址时就不用带域名、带/api了直接写/user/list。方便后端调整前缀时只改环境文件。timeout默认超时时间。后台管理项目里普通接口 15 秒够用但如果项目里有大文件下载、报表导出这个值要单独放大后面讲具体场景。withCredentials如果你们的接口要携带跨域 Cookie这里要设为true。不需要的话保持false省得莫名出现一些 CORS 怪问题。这里有一个非常容易踩的坑baseURL末尾不要留斜杠。比如你填的是/api/然后接口地址写/user/list实际发出的 URL 就成了/api//user/list后端如果没做兼容直接 404。这个玄学问题排查起来很费时间所以约定baseURL不带尾斜杠接口地址以/开头。2.3 开发环境代理与跨域你可能会有疑问开发环境配baseURL: /api那请求发到哪里去了这就涉及 Vite 的代理配置。前端开发服务器把/api开头的请求转发到真实后端地址同时还能避开浏览器跨域限制。// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://192.168.1.10:8080, changeOrigin: true } } } })注意changeOrigin: true一定要开否则部分后端在识别请求来源时会把前端开发服务器的地址传过去导致个别逻辑异常。代理只解决开发环境生产环境靠 Nginx 或者后端网关转发但这属于部署侧的事情前端主要把环境变量配置好就行。Vue CLI 老项目则是在vue.config.js里配devServer.proxy字段几乎一致不必再单独演示。2.4 第三方接口怎么处理统一管理不意味着所有请求都用同一个实例。有时候项目里要对接第三方地图、短信平台、开放 API它们的鉴权逻辑和业务接口完全不一样硬塞进同一个实例会让拦截器变得极其复杂。我的做法是默认实例只服务自家后端业务接口第三方接口单独建一个thirdRequest.js也是用axios.create创建一个实例但它的拦截器逻辑更简单甚至不接业务错误码处理。这样做的理由很朴素拦截器是全局收敛的地方如果里面要同时处理两三种不同鉴权、不同返回格式的系统代码很快会变成一团乱麻。不同系统不同实例边界清晰反而更好维护。3. 接口模块化的组织方式一个业务模块对应一个文件底层的axios实例建好之后接下来最关键的一步是接口文件怎么组织。这一步决定了新同事找你问用户列表接口在哪的时候你是能一句话告诉他路径还是默默打开 IDE 帮他搜半天。3.1 目录结构规范我目前比较推荐的目录结构是这样的src/ api/ modules/ user.js order.js goods.js dashboard.js index.js utils/ request.jsutils/request.js放上面说过的axios实例api/modules下按业务域拆文件。划分的依据一般参考后端服务边界或者后台菜单结构用户模块、订单模块、商品模块、Dashboard一个文件管一块千万不要把所有接口都堆进一个api.js里那个文件会膨胀到几千行根本没法看。3.2 接口函数的写法每个接口导出一个具名函数内部调用实例// src/api/modules/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 updateUserStatus(id, status) { return request({ url: /user/${id}/status, method: put, data: { status } }) } // 删除用户 export function deleteUser(id) { return request({ url: /user/${id}, method: delete }) }每个函数名都用动词 业务名词的格式getUserList、createUser、updateUserStatus、deleteUser一眼就能看出这是在干什么。这比随手起的getList、submit、modify要好因为多个模块会有大量重名到时候 import 冲突了还得起别名。接口地址是字符串我建议在函数里通过模板字符串拼接路径参数而不是在组件里拼好完整URL再传进来。这样路径玩法固定在里面组件里不会出现有人直接改路径参数导致绕过接口层的情况。3.3 聚合导出与组件中的调用每个模块文件导出后我习惯在api/index.js里做个聚合导出// src/api/index.js export * as userApi from ./modules/user export * as orderApi from ./modules/order export * as goodsApi from ./modules/goods export * as dashboardApi from ./modules/dashboard这样在组件里的引用方式非常清晰import { userApi } from /api async function loadUserList() { loading.value true try { const data await userApi.getUserList({ page: 1, pageSize: 20 }) userList.value data.records || [] } catch (error) { // 错误已经在拦截器处理过了这里通常只需要清理状态 } finally { loading.value false } }组件里没有axios的 import没有 URL 字符串只有语义化的接口函数。后期哪怕后端把接口地址从/user/list改成/member/queryList也只需要改user.js这一个文件组件代码一个字符都不用动。3.4 接口层可以做数据转换很多团队只把接口层当搬运工参数原样传、返回原样给。但接口层其实是个很好的数据转换位置。举个例子后端返回的时间字段是2025-04-13T10:30:00Z这种格式如果多个页面都要正常显示你在每个组件里写格式化逻辑就会很烦。可以在接口函数里顺手处理一下// 获取用户列表 export async function getUserList(params) { const data await request({ url: /user/list, method: get, params }) // 统一格式化创建时间 data.records.forEach(item { item.createTime formatTime(item.createTime) }) return data }这种转换逻辑放在组件里会导致每个页面重复写放在接口层只需要写一次。但要克制接口层的转换只做通用的、全局的数据适配不要塞页面特有的业务逻辑比如这里要计算金额合计这种还是放组件或store里更合适。4. 拦截器才是统一管理的核心token注入、错误码与401处理很多项目说做了统一管理实际上只是把接口函数集中了一下拦截器迟迟没设计好。其实拦截器才是统一二字真正的价值所在。请求拦截器负责出去的时候把所有东西带齐响应拦截器负责回来的时候把所有问题处理掉。4.1 请求拦截器一切outgoing请求的装配车间// src/utils/request.js import { useUserStore } from /store/user service.interceptors.request.use( config { // 从pinia或localStorage里取token const userStore useUserStore() const token userStore.token || localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer ${token} } // 如果项目需要指定后端语言版本或租户ID也可以统一在这里设置 config.headers[X-Locale] zh-CN return config }, error { console.error(请求初始化失败:, error) return Promise.reject(error) } )为什么要在这里统一注入token因为如果让每个业务组件都手动在请求里塞token一定会出现漏塞、塞错的情况。集中到这里之后不管谁调接口token都不会丢。这里有一个容易犯的错在请求拦截器里读了 store 或 localStorage 的状态但如果你的项目用了服务端渲染或者多实例场景useUserStore()的调用时机要小心。常规 Vue 单页应用没问题但要注意不要在模块顶层调用 store而要在拦截器函数体内调用我就是因为早期在模块顶层调了一次导致热更新时出现 store 实例错乱的诡异报错。4.2 响应拦截器业务错误码与HTTP状态的分层处理响应拦截器至少要干三件事剥离外层包装、处理业务错误码、处理HTTP错误。我们项目里后端约定的返回结构是{ code: 0, message: success, data: { } }code 0表示成功非0是业务错误HTTP层面一般都会返回200。响应拦截器这样写service.interceptors.response.use( response { // 针对文件流下载的单独判断后面会细说 if (response.config.responseType blob) { return response } const res response.data // 业务错误码非0统一提示 if (res.code ! 0) { // UI组件库的全局提示比如 Element Plus 里的 ElMessage Message.error(res.message || 请求出错了) if (res.code 40100) { // 登录状态失效统一跳转登录页 redirectToLogin() } return Promise.reject(new Error(res.message || 请求出错了)) } // 成功时直接把data返回组件里拿到的就是数据本身 return res.data }, error { // 这里是HTTP层面的错误 if (error.response) { const status error.response.status switch (status) { case 401: redirectToLogin() break case 403: Message.error(没有权限访问该资源) break case 404: Message.error(请求的资源不存在) break case 500: Message.error(服务器开小差了请稍后重试) break default: Message.error(请求失败(${status})) } } else if (error.code ECONNABORTED) { Message.error(请求超时请检查网络) } else { Message.error(网络异常请稍后重试) } return Promise.reject(error) } )注意我对成功响应的处理直接返回res.data组件里拿到的就不再是包了code、message的完整对象了而是真正的数据。这样组件代码会干净很多。早期我图省事返回整个 response到了组件里还得写res.data.data丑且容易出错。4.3 401跳转一个必须处理的并发问题redirectToLogin这个函数里面有个关键细节多个接口同时返回401时如果不做防重会连续跳转或者弹出多个提示框。// 简单可靠的防重跳转 let isRedirecting false function redirectToLogin() { if (isRedirecting) return isRedirecting true // 清除本地登录状态 localStorage.removeItem(token) localStorage.removeItem(userInfo) // 跳转登录页可以带上当前地址登录后跳回 const currentPath encodeURIComponent(window.location.href) window.location.href /login?redirect${currentPath} // 重置标记避免单页应用内无法再次跳转 setTimeout(() { isRedirecting false }, 3000) }这里我特意用了window.location.href而不是 Vue Router 的router.push因为拦截器是独立模块直接依赖 router 实例容易造成循环依赖router 内部又可能引用了这个请求模块。直接整页跳转虽然粗暴但胜在干净可靠登录页一般也不是代码分割路由不会增加太多负担。5. 容易翻车的场景超时、重复提交、下载文件流基础封装跑通之后项目里真正消耗时间的反而是那些不太常规的场景。下面三个我在实际项目里都踩过每一个都值得展开说一下。5.1 超时时间不能一个值走天下前面创建的 axios 实例统一设了个15秒超时。对大部分查询接口来说够了但有两个场景会出问题报表导出接口后端处理可能要20秒甚至更久上传大文件接口耗时受网速影响很大。如果统一走同一个15秒超时用户点导出或者上传时请求会被硬生生掐断前端报请求超时后端还在处理两边状态就对不上了。我的处理方案是针对耗时接口单独建一个长超时实例或者直接在建请求时覆盖timeout// 文件下载接口单独建一个长超时axios实例 import axios from axios const downloadService axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 60000 })这个downloadService只服务下载/导出场景拦截器逻辑可以复用默认实例的拦截器逻辑通过导出拦截器函数的方式也可以简化掉业务错误码处理看你对下载接口返回结构的了解程度。稳妥起见我建议复用同一套拦截器逻辑避免特殊情况被遗漏。5.2 重复提交与请求竞态用AbortController做取消同一个页面里用户快速点击查询按钮两次或者输入关键字搜索上一次请求还没回来下一次就发出去了。如果不做处理先发的请求如果后返回就会覆盖最新结果这叫竞态问题。axios 早期提供的是CancelToken但官方已经在较新版本里标记推荐使用标准的AbortController。如果你的项目用 axios 1.x直接用AbortController就好// 组合式API风格每次查询时保存最新的AbortController let abortController null async function searchUser() { // 如果上一次请求还没结束先取消它 if (abortController) { abortController.abort() } abortController new AbortController() try { const data await userApi.getUserList( { keyword: keyword.value }, { signal: abortController.signal } ) userList.value data.records || [] } catch (error) { // 被取消的请求会进入这里axios抛出的错误带 cancel 标识 if (axios.isCancel(error)) { console.log(上一次请求已被取消) return } throw error } }对应的接口函数需要支持透传配置项export function getUserList(params, config {}) { return request({ url: /user/list, method: get, params, ...config }) }不传config时不影响任何现有调用传了signal后取消能力就生效了。这个方案比请求序号比对要干净得多强烈推荐。顺带说一下组件卸载时要不要取消请求我的看法是数据回来后会写入组件的ref或reactive即使组件卸载了大多数情况只是内存泄漏问题不会白屏。但如果你在回调里调用了ElMessage或跳路由就会看到残留提示。稳妥做法是在onBeforeUnmount里也调用一次abortController?.abort()成本很低。5.3 文件下载blob返回与错误信息的隐形坑文件下载是响应拦截器里最容易骗到人的地方。后端接口明明出错了返回的是一个 JSON 错误对象但因为你的responseType: blobaxios 会把这段 JSON 也当成 blob 接收导致页面上的下载处理拿到一个不知道是什么的文件。正确处理方式是两层判断// 下载文件 export async function exportOrderList(params) { const response await downloadService({ url: /order/export, method: post, data: params, responseType: blob }) // 如果response中拿到的不是blob说明后端返回了错误信息 if (!(response.data instanceof Blob)) { throw new Error(导出失败) } // 尝试从响应头中提取文件名 const disposition response.headers[content-disposition] let filename 导出文件 if (disposition disposition.includes(filename)) { const encodedName disposition.split(filename)[1] filename decodeURIComponent(encodedName) } const blobUrl window.URL.createObjectURL(response.data) const link document.createElement(a) link.href blobUrl link.download filename document.body.appendChild(link) link.click() document.body.removeChild(link) window.URL.revokeObjectURL(blobUrl) }如果后端返回的是错误 JSON那么response.data不是 Blob 实例这时如果你把这段数据直接扔给createObjectURL浏览器会创建一个未知类型文件用户下载后打不开还说不清楚问题在哪里。所以前置判断很有必要。Content-Disposition文件名解析这里也藏着一个坑如果文件名是中文后端多数会做 URL 编码需要用decodeURIComponent。如果后端没编码直接返回原始中文decodeURIComponent会抛异常稳妥点可以包一层 try/catch解析失败就兜底用默认文件名。6. 重构之后真实的收益与三个坑前面几章把这套东西讲得差不多了最后聊点实在的重构之后到底收获了什么以及中途踩到过哪些坑。这些经验不是说看完就会但至少能让你少走一段我走过的弯路。6.1 收益盘点给一个做了统一管理后的真实项目做体检最直观的变化集中在三点第一排查问题的路径变短了。接口报错时先在拦截器里响应错误提示给用户兜底然后根据api/modules/xx.js里的函数定位到具体接口一查两查就到底不用在几十个组件里翻。第二前端能够放心地重构请求方式。比如某天项目要从普通 JSON 接口换成 GraphQL 或者双端协议只要改utils/request.js和各个接口函数其余业务代码基本不动改造风险大幅降低。第三后端联调体验明显提升。后端改了字段名前端只需要在接口函数里调整映射后端临时把接口地址改了个版本号我也只要在user.js里改动一行 URL 就行不用催着测试和运维同步改配置。6.2 三个实际踩过的坑坑一拦截器里别直接依赖某个具体组件的实例。早期我们响应拦截器的错误提示直接引用了当时还在用的老组件库的this.$message结果组件库版本升级后 API 变化拦截器里的调用全部失效错误提示一夜之间全部消失排查了很久才发现是版本问题。正确的做法是拦截器里统一使用一个你自己封装或者纯函数化的提示工具不绑定具体组件实例方便随时替换底层库。坑二总有同事为了省事绕过统一封装。你辛辛苦苦把request.js和api模块铺好了但项目里还是会有人图方便直接在组件里import axios from axios发请求。这样绕过拦截器token 不会自动带错误也不会统一提示等于前功尽弃。我后来在 eslint 配置里加了一条规则限制业务代码里直接引入axios{ rules: { no-restricted-imports: [error, { paths: [{ name: axios, message: 请使用 src/utils/request.js 中的统一请求实例 }] }] } }不过要注意utils/request.js内部还是要引入 axios 的所以只限定业务目录把src/utils/request.js加进例外。这个约束看起来有点粗暴但确实把团队里散装请求的苗头掐掉了。坑三接口文件会膨胀别等到几百个接口堆在一个文件里才拆分。我见过一个老项目把用户、订单、库存、财务全部几百个接口写在一个api.js里文件一万多行滚动都有卡顿感。好规划在接口数量到 30 个左右时就开始按模块拆分别嫌麻烦拆分成本远低于后期从大文件里剥离的成本。还有一个体会想分享接口统一管理不是一蹴而就的工程量它是随着项目演进而不断调整的。一开始只需要一个默认实例、一个登录态注入、一个模块目录。等到出现了文件下载、第三方接口、多设备登录等场景再逐步加长超时实例、加细拦截逻辑、加隔离配置。没必要一上来就设计一个庞然大物把团队绑死在一套过度设计的框架里那反而是另一种灾难。如果你现在手上正有一个接口散落在各地的 Vue 项目我的建议是先从环境配置和 axios 实例入手把这两个环节拉通团队里的请求自然就会开始向统一模式靠拢。后续的模块拆分和拦截器细化随着需求推进慢慢补充即可。这套思路不仅限于 VueReact、小程序里同样适用芯子是一样的一个唯一出口、一份全局配置、一整套明确约定。
阅读完成 · 觉得有帮助?