Vue项目请求统一封装实战:axios拦截器、错误处理与避坑指南

发布时间:2026/10/14 23:43:46
Vue项目请求统一封装实战:axios拦截器、错误处理与避坑指南
接手过几个 Vue 项目之后我对“请求统一封装”这个词有一种特别深的体会。早期我在某公司维护中后台系统的时候接口调用写得相当随意页面里直接 this.$http.get、组件里各自处理错误、有的地方包 loading、有的地方不包接口地址散落在十几个文件里一个字段改名得全局搜索半天。直到有一次线上事故——后端改了某个接口的返回结构前端十几个页面同时异常大家排查了整整一晚上才定位到是数据格式兼容问题。那次之后我开始认真重构项目的请求层把 axios 实例、拦截器、错误处理、业务调用全部梳理成一套统一方案。这篇博文就是把这套方案的设计思路、实现细节和踩坑记录完整地写出来适合正在搭建或准备重构前端请求层的同学也适合刚接触 axios、想理解“封装”到底封什么的初中级前端。1. 为什么要做请求统一封装先看散装请求的代价先讲一个真实的项目场景。某公司内部的信息管理系统Vue 2 项目技术上不复杂但请求层几乎没人管理。新同事入职第一周改一个客户列表页发现同一个“获取客户列表”接口在三个页面分别用自己的方式调用过一个直接 axios.get一个走了某个未导出成模块的工具函数还有一个在 store 里又封装了一层。三处错误码判断的写法全都不一样。结果后端把接口返回结构一调整全站崩了三块大家排查到深夜才意识到是同一个接口导致的连锁反应。这种混乱绝对不是个例。我见过太多项目里组件中直接写 axios.get然后在 .then、.catch 里各自弹 message接口 URL 没有常量管理随手写字符串后端改字段名前端得用全局搜索一个个找。项目前期接口不到十个、页面两三张的时候问题还不明显等到接口数量超过三十个、页面超过十张团队每个人都在重复造轮子请求层就成了最大的拖后腿环节。统一封装的核心目标可以归纳为三个。第一是统一出口所有网络请求必须从一个配置好的 axios 实例发出baseURL、超时时间、请求头这些基础设施只配置一次。第二是统一流程token 注入、错误码处理、loading 编排这类横切逻辑收口到拦截器里业务代码不再重复写。第三是统一产物给页面层提供一组规范的接口函数和类型定义调用方只关心参数和数据不关心 HTTP 细节。1.1 散装请求的典型乱象散装请求最直接的体现是“同一个接口多种调用方式”。有人习惯在组件 created 里写 axios.get有人习惯在 action 里写 axios.post还有人封装了自己的一套 fetch 工具但没有文档。这就导致接口的调用面极其分散后端一改前端整个项目变成雷区。另一个乱象是错误处理的无序。同样的“登录过期”A 页面弹一个自定义 modalB 页面用 message 提示C 页面连提示都没有直接白屏。用户感受到的是产品行为不统一维护者感受到的是每次改错误逻辑都要全局搜代码。重复代码本身不可怕可怕的是重复代码里的逻辑还各写各的改起来无从下手。1.2 统一封装到底解决了什么统一封装最大的收益是“改动成本集中在单点”。比如要求所有请求都带上设备信息 header没有封装的时候你要改所有调用处有封装之后你只需要改请求拦截器。再比如后端把所有接口的错误码从 0 改成 200没有封装的时候你满项目找 code 0有封装之后你只需要改一处常量。更实际的好处是联调效率。接入新接口时业务方只需要按照模块文件里既有的函数模板加一个函数参数类型、返回类型、错误行为都是项目内现成的规范。新同事不需要从零理解请求链路看一两个 api 文件就能照葫芦画瓢。1.3 哪些项目暂时不需要封装这里必须泼一盆冷水不是所有项目都需要一上来就做这套东西。如果只是三天能做完的演示 demo、内部小工具或者团队只有一个人、接口不超过十个过度封装反而浪费时间。我见过一个六人团队花了两周把请求层封装出了“框架”的味道结果业务迭代快接口变化频繁封装变成了额外负担。判断标准其实很朴素同一段请求逻辑你写第二遍的时候就值得考虑抽出来了等到同一个错误处理逻辑在不同页面出现三遍以上就必须动手了。封装不是越早越好而是痛点到了就做。2. 请求层整体架构怎么做三层划分与目录规划我用了近一年时间迭代某中后台项目的请求层最后沉淀出一套比较稳定的分层结构简单说就是“基础层、编排层、业务层”三种职责。这个分层最初只是为了让代码不全都挤在一个 index.js 里后来才发现它天然地把变更频率不同的代码隔离开了。2.1 三层职责划分最底下是基础 HTTP 层。这一层只做一件事创建 axios 实例并配置最基础的参数。baseURL 从环境变量读取、timeout 设置一个合理上限、withCredentials 按需开启。这一层不掺业务逻辑不处理业务错误码只暴露一个配置好的实例对象。它最大的特点是“稳定”一个项目从头到尾基本不会被改动。中间是编排调度层。这一层是整个封装的灵魂放的是请求拦截器和响应拦截器。请求拦截器负责在请求发出前做统一增强比如注入 token、添加时间戳、标记请求 ID响应拦截器负责在数据返回后做统一处理比如剥离外层包装、判断 HTTP 状态码、分发业务错误。这一层也负责全局 loading 的编排和错误提示的统一出口。最上层是业务接口层也就是页面最终会 import 的那一层。这层把每个后端接口映射成一个具名函数函数内部调用前面封装好的 request 实例并完成参数拼装、数据类型约束。业务层是数量最多、变化最频繁的一层但它只做“翻译”工作不写冗余逻辑。2.2 目录结构与依赖方向对应到目录结构长这样src/ ├── api/ │ ├── http/ │ │ ├── index.js # axios 实例创建 │ │ ├── interceptors.js # 拦截器逻辑 │ │ ├── retry.js # 重试与请求合并 │ │ └── config.js # 环境变量读取、常量 │ ├── modules/ │ │ ├── user.js # 用户相关接口 │ │ ├── order.js # 订单相关接口 │ │ └── dashboard.js # 报表相关接口 │ └── types/ │ ├── api.d.ts # 通用响应类型 │ ├── user.d.ts │ └── order.d.ts这套目录之所以这样分核心逻辑是“依赖方向必须单向流动”业务接口层依赖 http 基础层但 http 基础层绝对不能反过来依赖业务层。否则就会出现为了在拦截器里判断“某个请求是否需要 token”而 import 某个具体接口函数的尴尬局面一旦那个接口被删了整个请求层都跟着报错。2.3 单实例还是多实例axios 实例数量这件事我用的是单实例为主。大部分业务系统一个 baseURL、一套鉴权逻辑就够了。如果项目里同时对接了多个后端服务baseURL 不同、鉴权方式也不同那才考虑多实例按照服务拆出 base 和 baseExtra 两个实例各自配自己的拦截器。这种方式比在单个实例里用 config 判断“要打到哪个后端”干净得多也避免请求拦截器里写一堆 if 分支。3. axios 拦截器请求进出的两个统一控制点拦截器是 axios 统一封装里最值钱的部分理解透这两个钩子整个请求层就算拿下一大半。很多封装做得乱根因往往是拦截器里塞了太多东西或者设置拦截器的顺序没想清楚。3.1 请求拦截器出发前做完所有准备请求拦截器最常见的工作是 token 注入。这里有个细节很多人会踩坑token 到底从 store 里拿还是从缓存里拿。我的建议是优先从 store 拿因为 store 里是内存态修改后立即生效而且登出后清理也方便。但如果项目用的是 Pinia且登录后 token 持久化到缓存那么 store 初始化阶段 token 可能还没恢复进来。解决方案是在 store 里做一个“从缓存恢复 token”的动作保证页面刷新后 store.token 有值而不是在请求拦截器里点。// http/interceptors.js export const setupRequestInterceptors (instance) { instance.interceptors.request.use( (config) { const token getTokenFromStore() if (token) { config.headers.Authorization Bearer ${token} } config.headers[X-Request-Id] createRequestId() return config }, (error) Promise.reject(error) ) }第二个实用点是防重复提交。在编辑弹窗里点保存如果接口慢用户连点两次就会发出两条一样的请求后一条很可能覆盖前一条的结果。我一般会在请求配置里加一个幂等标识键用一个 Map 记录“还在飞行中的请求”相同标识的请求在未完成前直接复用结果或者直接拦截掉。这个逻辑可以做得简单复杂场景结合 AbortController 处理但核心原则是明确语义你到底是想要“最后一次生效”还是“第一次生效”。请求拦截器里还适合放一些统一请求头处理比如 GET 请求要不要带 Content-Type、文件上传接口是否自动转 FormData、是否把版本号放进 header。写进拦截器的好处是业务调用方完全不需要感知只管按函数签名传参。3.2 响应拦截器数据落页面前先解包响应拦截器要做的事比请求拦截器更多。第一个核心动作是解包。绝大多数后端接口的返回结构是 { code, message, data }封装之后的响应拦截器应该在成功时直接返回 response.data.data把两层壳剥掉让业务函数拿到的直接就是业务数据。这样页面层就不用写 response.data.data 这种反人类的链式取数了。第二个核心动作是 HTTP 状态码的初步判断。axios 默认 2xx 之外都会走 error 分支所以 error 分支里要按状态码分流401 未登录、403 无权限、404 资源不存在、500 服务器异常。每种状态码给一个统一提示文案。401 的处理要小心不能简单弹一个 message 就完事还要考虑并发场景下多个接口同时返回 401 会弹出 N 条提示的问题。第三个核心动作是业务码分发。HTTP 200 不代表业务成功code 非 0 时业务照样失败。响应拦截器里统一判断业务码把常见逻辑错误翻译成调用方能识别的异常对象并决定是否要弹全局提示。业务码很多的场景建议维护一张码表而不是在拦截器里写七八个 if。3.3 执行顺序与链式细节axios 拦截器有个容易被忽略的机制先注册的先执行后一个拿到的 config 是前一个处理之后的结果。所以如果你注册了“token 注入”和“请求 ID 标记”两个请求拦截器顺序就决定了最终 header 里的内容。要注意不要在后面一个拦截器里把前面注入的 header 覆盖掉比如统一设置 Content-Type 时把已有的 Authorization 重置了。响应拦截器的执行顺序则相反同样要注意每个拦截器都要有 return否则数据会在某一环被丢成 undefined。排查这种问题很费时因为表现可能是接口返回了但页面数据为空看起来像后端问题实际上是你自己拦截器链断了。改造现有项目时我建议先加一个独立的日志拦截器观察链路等稳定了再移除。4. 统一错误处理与业务码设计别再把 message 撒得到处是做请求层封装最见功力的是错误处理的设计。老实说很多项目的封装做得像样但一碰到错误就露馅有的把错误吞了有的把错误弹得满天飞有的服务端一改错误码页面就白屏。这一节我把错误处理拆开讲透。4.1 三类错误三种处理姿势前端里谈“错误处理”真正要管的不是一种错误而是三种。第一层是网络层错误。断网、请求超时、DNS 解析失败。这类错误的特点是根本收不到后端响应axios 会把 error 直接抛出来error.response 是 undefined。第二层是 HTTP 层错误。能连上服务器但返回了 4xx/5xx。第三层是业务层错误。HTTP 状态码是 200但后端 code 非 0。我见过很多团队把这三种混为一谈在 catch 里靠判断 error.response 是否存在来分开处理写得非常绕。注意断网场景下axios 的 error.response 是 undefined但超时场景也类似。所以不能用“有没有 response”来区分超时还是断网要看 error.code 或者 error.message 里的标志位。这里最容易踩坑。4.2 一套够用的错误处理模板我常用的是一套三选一的策略。网络层错误统一提示“网络连接异常请检查网络”HTTP 层错误按状态码映射提示业务层错误查码表决定提示内容。所有错误在响应拦截器里收口根据类型决定是否弹全局消息并返回一个统一结构的 Error 对象业务函数可以决定是否覆盖默认提示。export const mapError (error) { if (!error.response) { return { type: network, message: 网络连接异常请检查网络 } } const status error.response.status const message HTTP_STATUS_MESSAGE[status] || 服务异常请稍后重试 return { type: http, status, message } }这套模板的核心是“拦截器只做翻译不做决定”。翻译网络错误、HTTP 错误、业务错误成统一结构但最终要不要弹提示、要不要跳转可以由调用方在函数里决定。这样做业务更灵活不会出现“所有错误都被全局弹走想单独静默一个错误却做不到”的情况。4.3 业务码表怎么维护业务码建议单独维护一个常量文件。我见过有人把码表写在页面里一个项目里同一业务码的提示文字能写出三个版本后端改文案后前端要跟进很久。集中维护的好处是“一处修改全局生效”。我列一个我实际维护过的精简版码表作参考业务码含义处理策略0成功正常返回10001未登录触发跳登录逻辑全局只提示一次10002登录过期尝试静默续期续期后重放原请求20001参数缺失全局弹提示并抛错20002数据不存在静默抛错由业务层决定是否提示50001服务内部错误全局弹提示并做日志上报50002上游服务超时全局弹提示建议稍后重试码表不只是错误码和文案的映射它还是“行为策略”的配置中心。同一个码在 A 页面是致命错误在 B 页面可能是可忽略的警告。所以码表里除了文案最好还带一个级别字段例如 fatal、warn、silent由拦截器统一读取再决定行为。4.4 重试与降级给请求兜底移动端项目里网络抖动频繁通常我建议在请求层做一层“失败自动重试”的兜底但重试必须克制。一般用指数退避第一次失败等 300ms 重试第二次失败等 600ms最多重试两次超过就不再重试避免重试风暴把后端打挂。重试只对 GET 这类幂等请求生效POST/PUT 直接失败因为重放 POST 可能导致数据重复创建。实现方式主要靠 axios 配置里的自定义字段比如 retry配合响应拦截器 error 分支判断。如果你用的 axios 版本较新也可以考虑二次封装 adapter 来做。这个属于进阶玩法普通项目里用拦截器重试就够用了。重试场景还有一个重要前提token 已经过期但又能续期成功时重放请求才有意义。否则每次都重试每次都 401没有任何价值。5. 业务调用层封装实践怎么让你同事用起来最爽请求层封装的最终目标不是自己写得开心而是让业务开发调用得顺手。这一节我讲几个调用层的最佳实践都是能直接抄作业的。5.1 API 模块化与命名规范每个后端资源模块对应一个 api 文件。用户模块就叫 user.ts、订单模块就叫 order.ts。文件内部导出的命名统一是“动词 资源 描述”例如 fetchUserInfo、updateOrderStatus。坚持这个规范后页面里 import 名字一看就知道在干什么。不建议把方法挂到 this 上或全局对象上。具名 import 的好处是可被静态分析、可被 IDE 跳转、方便单元测试 mock团队协作时也能搜到所有被引用的接口。// api/modules/user.ts import request from ../http export const fetchUserInfo (params) { return request({ url: /user/info, method: get, params }) } export const updateUserInfo (data) { return request({ url: /user/info, method: put, data }) }5.2 TypeScript 泛型让返回值有类型纯 JavaScript 的请求层对调用方来说还有一个痛点返回值是 any字段写错了编译期发现不了。使用 TypeScript 之后我一般会定义一套通用响应类型和泛型约束让每个接口函数都明确声明入参出参。// api/types/api.d.ts export interface ApiResponseT { code: number message: string data: T } export interface UserInfo { id: string name: string avatar: string }配合 axios 的泛型重载业务函数可以直接写出带类型的签名// api/modules/user.ts import request from ../http import type { UserInfo } from ../types export const fetchUserInfo () { return request.getApiResponseUserInfo, UserInfo({ url: /user/info }) }关键点在于axios 的第二个泛型参数代表“真正返回给调用方的类型”。只要响应拦截器解包成了 data第二个泛型就该是业务数据类型本身而不是外层壳子。这里特别容易写错我第一次封装时也把泛型写成了 ApiResponse 结果调用方拿到的还是一个带壳对象类型和实际运行时对不上排查了很久才意识到是泛型参数理解错了。5.3 轮询、并发、下载场景的封装增强业务里经常有“等订单支付结果”这种轮询需求。我通常在 API 层提供开箱即用的轮询工具函数而不是让业务方自己 setInterval 手动调用接口。轮询默认 3 秒一次最多 10 次超过后触发超时回调。组件卸载时必须 clear 掉定时器这是最容易造成内存泄漏的地方。文件下载的处理我这里再强调一个重点下载接口务必用 responseType: blob并且要专门处理“下载接口返回了 JSON 错误”的场景。因为 HTTP 200 但业务失败时后端可能返回一个包裹着错误信息的 JSON你拿 responseType 是 blob 就会出现一个没办法直接读取的 blob 对象。解决方案是把 blob 里的内容解析成 JSON 再判断。const download async ({ url, params }) { const response await request.request({ url, method: get, params, responseType: blob }) if (response.type application/json) { const text await response.text() const err JSON.parse(text) throw new Error(err.message) } const link document.createElement(a) link.href URL.createObjectURL(response) link.download decodeFileName() link.click() URL.revokeObjectURL(response) }5.4 自动 Loading 与防重复提交除了后端接口层我还会在调用层做一个轻量的请求编排工具让页面上常见的 loading 状态更可控。做法是给 API 函数一个统一包装在请求开始前置 loading 为 true请求结束后无论成败都置为 false。页面里就不用每个请求都手写 loading 状态机了。做这一步时要注意并发场景别因为一个请求结束就把其他请求共享的 loading 关掉。安全做法是计数 1 打开、计数 -1 关闭计数器归零才真正关闭。这样两个并发请求同时运行时loading 不会提前消失。这个方案和防重复提交的拦截策略可以配合使用按钮级别的重复点击用“提交中”状态挡掉页面级别的并发等待用计数 loading 兜住。6. 从几十次排障里记下的避坑清单请求层封装听起来不难真正用起来坑都在细节里。这一节我把这几年排查过的问题整理成一份清单每一条都对应一个真实踩过的教训建议收藏备用。6.1 拿不到 token先查初始化顺序项目刷新后第一次请求就发起了但 Pinia store 里的 token 还没从缓存恢复回来请求拦截器拿着空 token 发出的请求被后端拒绝。这个问题的根源不是拦截器而是 store 初始化时机。我的解决方案是封装一个 appBootstrap 函数在应用启动时先等 store 恢复完成再挂载根组件。或者更简单粗暴token 读取不要走 store统一走一个带缓存的工具函数既能保证最新值也能保证初始化时读得到。6.2 401 提示风暴与循环跳转一个页面同时发 5 个接口token 过期后 5 个接口全部返回 401。如果每个 401 都弹一次“登录已过期”然后跳登录页用户会看到一堆弹窗还可能因为跳转逻辑里又触发了某个接口而无限重定向。我的处理方法是引入一个“是否正在处理登录过期”的标记只在第一个 401 触发跳转并在跳转前全局只提示一次。同时要禁止被某个业务接口自身触发的 401 再次进入 401 处理逻辑否则就是死循环。6.3 环境配置的坑前缀名和代理Vue 3 Vite 环境下环境变量的前缀必须是 VITE_否则 import.meta.env 里读不到。我们曾经把 VITE_API_BASE_URL 误写成 API_BASE_URL开发环境怎么请求都是错的部署到测试环境才暴露出来。另外要区分开发代理和生产环境完整地址的差异。本地开发用 /api 做代理前缀生产环境才是真实域名这两个不能混用一个变量。6.4 修改 header 不生效的问题axios 的 config.headers 在不同版本里的传递路径不完全一样。有时你改了 config.headers 的某个属性但发出请求后发现后端没收到。排查时先确认版本再确认你改的是请求拦截器里接收到的 config 引用本身而不是直接复制了一份。另一个常见问题是 Content-Type 被统一设置成 application/json某天一个文件上传接口走同一个拦截器文件就传不上去了。文件上传应该走独立封装不走普通拦截器的强制 Content-Type。6.5 错误被吞掉的链式陷阱响应拦截器里我见过有人把业务错误 catch 之后 console.error 一把就 return 了页面上的 await 也拿不到异常看起来“请求失败了但代码继续往下走”。正确做法是业务错误要用 Promise.reject 继续往下抛页面里的 try/catch 才能感知。吞错误这个问题在重构期最容易出现我一般会定一条规则拦截器里要么 return 数据、要么 reject 错误绝对不允许“吃掉”。6.6 重复请求合并还是拦截要分场景防重复提交的拦截策略有时候会误伤正常业务。比如一个列表页的搜索用户快速输入触发多次请求后一次应该取消前一次而不是整体拦截。这两种场景的处理方式完全不同取消旧请求用 AbortController 或者 axios 的 signal合并同类型请求是在请求层维护一个“进行中的请求池”。做之前先想清楚你的场景是“最后一次生效”还是“第一次生效”设计错了体验会非常奇怪。最后分享一个我长期维护中的体会。请求层不是写一版就完事它和项目是共同生长的接口少了可能简单点接口多了再慢慢加轮询、重试、幂等它也不是越复杂越好很多封装到最后都败给了过度设计。我个人现在的衡量标准是“业务开发是否真的感觉不到请求层的存在”——他们只需要知道该调用哪个函数、传什么参数、拿什么返回值其他的 token、错误码、loading 都由这一层接管。等到下一次再有后端调整接口结构时你发现只要改一处拦截器全站页面都不受影响那种感觉还是挺踏实的。希望这篇文章能帮你在自己项目里少走几条弯路。