首页 / 资讯中心 / 文章详情

Vue3中Axios响应拦截器实战:统一错误处理与异常治理

Vue3中Axios响应拦截器实战:统一错误处理与异常治理 ★ FEATURED ARTICLE
做前端这么多年真正让我意识到“响应拦截”不是花架子是一次凌晨两点上线的教训。那年公司做后台管理系统接口偶尔超时后端一报5xx前端页面直接白屏用户刷新也没用日志里一堆看不懂的报错。后来我把Axios的响应拦截器重写了一遍把错误处理从“到处try/catch碰运气”改成“统一分类、统一提示、统一上报”情况才彻底好转。Vue3搭配响应拦截不是单纯拦截HTTP响应那么简单它实际上在帮你建立一套完整的异常治理体系这篇文章就围绕这件事展开。这篇文章适合谁正在做Vue3后台管理系统和商城项目、被接口报错折磨的新手以及想把代码里散落一地的错误提示统一收拢的经验型开发者。我会从为什么要做统一拦截开始讲到Axios实例封装、拦截器代码实现、TS类型坑位、常见报错排查尽量一篇讲透。1. 响应拦截到底在拦截什么1.1 没有统一拦截的痛点现场先说最常见的问题。很多项目刚起步时每个接口请求都是单独写一遍Axios调用然后各写各的catch。今天A页面提醒“网络错误”明天B页面报一个“Error: Request failed with status code 500”后天C页面直接卡住没有任何提示用户还以为系统死了。这背后其实暴露了三个痛点错误处理逻辑严重重复几十个接口就有几十份差不多的catch代码错误提示风格不统一有得弹窗、有的Toast、有的压根没反应HTTP状态码、业务错误码、网络异常混在一起前端根本分不清哪种情况该给用户看什么。我在真实项目里见过最夸张的情况同一个401错误有的页面跳登录有的页面弹“未授权”有的页面直接白屏。原因就是每个页面各自处理最后谁也处理不明白。响应拦截器解决的就是这个“各管各”的问题。它把请求返回的响应集中到一个入口先统一判断状态码、再做数据解包、最后把干净的data返回给业务页面。业务代码里不用再关心HTTP层的事情只需要处理业务上的成功与失败。1.2 响应拦截的三个职责边界很多人对响应拦截器有误解以为它就是把response.data取出来。其实一个成熟的响应拦截器至少要做三件事数据解包把Axios的response对象剥开只把后端真正的数据字段返回给页面让调用者拿到的就是数据而不是一层套一层的结构体错误分类要把HTTP状态码、业务错误码、网络异常、超时、取消请求这几类情况区分开来分别走不同的处理分支统一处理Token失效该跳转、后端业务失败要不要提示、提示用什么形式、什么错误需要上报监控平台这些都在拦截器里决定不让页面各自为战。我常打一个比方响应拦截器就是公司前台的保安室所有访客都先到这里登记谁有权限、谁被拉黑、谁是来捣乱的保安先过滤一遍而不是让每个员工都跑去门口盯人。这么想拦截器的职责边界就特别清晰了。1.3 设计原则接口层、业务层、展示层分层治理从这里开始设计思路要提升一层。响应拦截和错误处理本质上是一个分层治理的问题。接口层就是框架Axios负责发请求、收响应业务层是具体调用者的代码比如Vue组件里的onMounted、handleSubmit它只关心数据对不对展示层负责把错误变成用户能看懂的话。响应拦截器站在接口层和业务层之间。接口层告诉它“状态码500请求被拒”它分析后告诉业务层“这个接口挂了原因已上报”业务层只需要关心要不要做兜底不需要自己判断状态码。这也是Vue3组合式API能发挥作用的地方。把拦截逻辑放在独立的request.ts文件里再用composable封装请求函数组件里的代码就会变得非常干净。这个思路对应到热搜词里的“vue3后台管理系统”开发尤其管用——后台系统接口多、权限复杂、错误场景多不分层治理代码很快乱成一锅粥。2. Axios实例封装从创建到拦截的完整链路2.1 创建实例时那些容易忽略的配置先别急着写拦截器实例创建这一步很多人就漏了不少配置。一个生产级的Axios实例至少要关注下面几个配置项import axios from axios; const service axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000, withCredentials: false, headers: { Content-Type: application/json;charsetutf-8, }, });baseURL用import.meta.env来读这个在Vue3 Vite环境下是标准做法不同环境开发、测试、生产的接口地址可以分别配置不需要每次打包前手动改代码。timeout建议设置得比后端平均响应时间长一些但又不能太长后台系统里我通常设置10~15秒超过这个时间基本可以判断网络或服务有异常让用户干等没有意义。还要提醒一个细节withCredentials在跨域场景下是否开启要和后端协商好不然可能今天能拿到Cookie明天运维调一下跨域配置就什么都拿不到了。这类问题排查起来特别费劲因为错误不一定在前端。2.2 请求拦截器不是简单塞Token请求拦截器负责在请求发出前做处理。最典型的是把Token加到请求头里但我建议把下面这几件事一起干了service.interceptors.request.use( (config) { const token localStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } const pendingKey ${config.method}:${config.url}${config.params ? JSON.stringify(config.params) : }; config.pendingKey pendingKey; return config; }, (error) Promise.reject(error), );为什么要把请求方法、URL和参数拼成一个pendingKey这是为了后面做“取消重复请求”。后台管理系统里按钮被连续点击两次或者列表页切换筛选条件时连续触发多个请求如果不去重要么后端被挤爆要么前端响应错乱——先发的请求后返回把后发的数据覆盖了。这个场景我用Vue3做商城项目时遇到过商品列表频繁切换规格接口响应顺序乱了页面显示的规格和价格对不上用户一脸懵排查半天才发现问题在并发请求时序上。Token的存储位置值得专门说一句localStorage有XSS风险更稳妥的方案是用Pinia存储并使用pinia-plugin-persistedstate持久化或者在内存和localStorage之间做双缓存。考虑到响应拦截器里也可能需要读Token刷新接口我更推荐把Token的读写封装成独立的auth模块不要让拦截器直接操作localStorage这样后续切存储方案时不用改动拦截器代码。2.3 响应拦截器的核心数据解包与错误分类响应拦截器是这篇文章的主角代码也最容易写崩。我在实践中迭代过好几个版本下面这份是目前觉得比较稳妥的模板service.interceptors.response.use( (response) { const res response.data; if (res.code ! 0 res.code ! 200) { if (res.code 401) { handleTokenExpired(); } ElMessage.error(res.message || 请求失败); return Promise.reject(new Error(res.message || 请求失败)); } return res; }, (error) { const { response, config } error; if (axios.isCancel(error)) { return Promise.reject(new Error(请求已取消)); } if (error.code ECONNABORTED || error.message.includes(timeout)) { ElMessage.error(请求超时请稍后重试); return Promise.reject(error); } if (!response) { ElMessage.error(网络异常请检查网络连接); return Promise.reject(error); } const status response.status; switch (status) { case 400: ElMessage.error(请求参数错误); break; case 401: handleTokenExpired(); break; case 403: ElMessage.error(没有权限执行此操作); break; case 404: ElMessage.error(请求的资源不存在); break; case 500: ElMessage.error(服务器内部错误请稍后重试); break; case 502: case 503: case 504: ElMessage.error(服务器繁忙请稍后重试); break; default: ElMessage.error(error.message || 请求失败); } return Promise.reject(error); }, );这个写法的关键点有几个。第一response 分支处理的是“HTTP请求成功返回了业务数据”的情况。此时Axios不会进error分支但业务上可能还是失败的比如code非0。很多人只处理HTTP状态码忽略了业务码结果就是接口返回“库存不足”前端却一点反应都没有用户点购买按钮发现没反应以为坏了。第二error分支里要先判断网络层异常再判断HTTP状态码。不是所有异常都有response比如断网、超时、域名解析失败error.response是undefined的。如果不先做!response判断后面直接取error.response.status会报“Cannot read properties of undefined”想象一下本来想处理错误结果自己的代码先抛了个TypeError那才是真的尴尬。第三401的处理要收敛。handleTokenExpired里面做什么值得仔细设计let isHandling401 false; async function handleTokenExpired() { if (isHandling401) return; isHandling401 true; try { const refreshed await refreshToken(); if (refreshed) { isHandling401 false; window.location.reload(); return; } } catch (e) { // ignore } isHandling401 false; localStorage.removeItem(token); router.replace(/login?redirect${encodeURIComponent(router.currentRoute.value.fullPath)}); }这里用了一个isHandling401的标志位防止多个接口同时返回401时页面被疯狂跳转。这个场景在后台管理系统里太常见了页面打开时并发请求三四个接口Token过期后这四个接口同时返回401不去重的话会被重定向很多次用户体验极差。3. 错误处理的分层设计从HTTP状态码到业务错误码3.1 HTTP状态码与业务码的区别这个区别我要单独拿出来说因为很多人栽在这里。HTTP状态码是传输层面的“投递回执”业务码是后端对业务结果的“评价”。用快递做类比HTTP 200快递已送达HTTP 401快递员发现收件人身份不对拒绝投递HTTP 500快递仓库炸了无法处理包裹业务码Code 2001快递送到了但收件人拒收业务拒绝。比如“用户下单失败”HTTP状态码可能是200因为服务端正常处理了请求并返回了结果但响应体里code可能是一个非成功值message写着“库存不足”。如果只判断HTTP 200就放行前端就会把一个失败的下单操作当成成功跳转支付页后才发现不对损失就大了。所以在响应拦截器里我的处理原则是只有业务码表示成功才把数据交给页面业务码表示失败则统一提示HTTP状态码非2xx走网络或服务端异常分支业务码和HTTP状态码都判断缺一不可。3.2 错误归一化让后端返回什么你都能接住业务码这个东西不同后端团队风格完全不一样。有的用0表示成功有的用200有的用success字符串。这导致前端封装拦截器时判断成功的条件要写得很别扭。我建议在自己端做一次“归一化”function normalizeResponse(res: any) { const SUCCESS_CODES [0, 200, 200, success, SUCCESS]; const isSuccess SUCCESS_CODES.includes(res.code ?? res.code undefined ? ... : res.code); return { isSuccess, code: res.code, message: res.message || res.msg || 操作失败, data: res.data, }; }当然上面的三元运算符写得不伦不类实际实现时应该用明确的条件判断。但核心思想是前端不要假定后端一定按某个规范返回而是在拦截器里做一个翻译层把后端五花八门的返回结构统一成{ isSuccess, code, message, data }。这样业务组件永远只认这一种结构后端改返回格式时只需要动拦截器不需要全项目到处改。还有一个很实用的做法和后台系统联调时要求后端至少保证code、message、data三个字段永远存在哪怕data为null。缺少message时前端兜底给一个“操作失败”缺少code时按业务失败处理避免undefined静默通过。3.3 401、403、超时、断网这些场景怎么处理才体面401 Token失效前面提到了要静默刷新Token刷新失败再跳登录页。但要注意刷新Token的请求本身不能再触发401拦截否则会死循环。常见做法是给相关请求加一个silent: true标记拦截器见到这个标记就跳过401处理。403 无权限分两类一类是登录了但没权限访问某个接口提示“没有权限”另一类是登录状态在服务端已被踢下线同一账号在其他地方登录这要提示“账号在其他设备登录”必要时强制下线。做商城系统时遇到过这种需求清购物车接口返回403用户不知道发生了什么光在那里刷新页面等于白给。超时超时是“慢”不是“坏”提示“请求超时”之外最好提供一个重试按钮。更讲究的方案是自动重试一次——对幂等接口如GET请求在超时后自动再发一次请求两次都失败才提示用户。这个重试逻辑适合封装在request.ts里而不是每个业务函数里都写一遍。断网没有response没有状态码只有网络层面的错误。这时候提示“网络异常请检查网络连接”就够用了。另外可以监听浏览器的offline事件在断网时全局弹一个可关闭的提醒条恢复online时自动隐藏。3.4 用Vue3组合式API封装useRequestVue3最打动我的是把“跟请求相关的状态”集中在一个组合式函数里。以前Vue2时代每个页面都要自己写loading、error、data代码一堆。现在配合响应拦截器可以封装一个通用的useRequestimport { ref } from vue; export function useRequestT(requestFn: (...args: any[]) PromiseT) { const loading ref(false); const error refany(null); const data refT | null(null); const run async (...args: any[]) { loading.value true; error.value null; try { const result await requestFn(...args); data.value result; return result; } catch (e) { error.value e; throw e; } finally { loading.value false; } }; return { loading, error, data, run }; }组件里的用法就变成了const { loading, data, run } useRequest(getUserList); onMounted(() { run({ page: 1, size: 20 }); });模板里v-loadingloading一绑错误提示由拦截器统一弹出组件里只管渲染数据。这个模式写后台管理系统的列表页、表单提交、弹窗详情都能省掉大量重复代码。不过要注意拦截器已经把错误弹过提示了组件里通常不需要再弹一次。如果你个别接口想静默处理错误比如下拉框的候选列表请求失败时不想打扰用户可以在config上挂一个自定义标记silent: true拦截器检测到就不弹提示只把错误抛出if (response response.config?.silent) { return Promise.reject(error); } ElMessage.error(请求失败请稍后重试);这个“同一套逻辑个别接口选择性静默”的需求不在拦截器层做标记的话早晚得在组件里开一大堆口子。4. 与Vue3生态的集成实践TS类型、Pinia状态、路由守卫4.1 TS类型上容易踩的坑Vue3 TypeScript已经是标配但类型问题能把人折磨疯。最常见的问题是拦截器返回的数据类型和后端实际返回的类型对不上。比如interface ApiResponseT any { code: number; message: string; data: T; } service.interceptors.response.use( (response) { return response.data; // 这里返回的是 ApiResponseT }, ); // 调用时 const res await getUserListApiResponse{ list: User[] }(); const list res.data.list;看起来逻辑没错但拦截器让await拿到的不是ApiResponseT而是T本身如果继续按ApiResponseT去解包res就变成了{ list: User[] }res.data是undefined。这种问题很多人写了很久都没发现因为编译不报错运行时才出问题。解决方法很明确在封装函数里明确指出返回类型。比如export function getUserList(params: PageParams) { return requestPageResultUser({ url: /user/list, method: get, params, }); }然后在request函数内部泛型指向的是拦截器解包后的数据类型function requestT(config: AxiosRequestConfig) { return service.requestApiResponseT, T(config); }这里service.request的第一个泛型是Axios期望的响应类型第二个泛型是返回值的类型。只有对齐这个关系调用方拿到的res才是干净的数据而非整个信封。很多人看“若依vue3 ts报错”这类话题时会发现大量报错集中在Axios封装和类型推断上。模板工程的请求返回类型和业务类型时常对不上tsc检查直接报红逼着你把泛型理清楚。这其实是好事类型系统帮你把问题提前暴露在编译期而不是等上线后黑屏了才查。4.2 响应拦截器里怎么安全地使用路由和状态管理拦截器里要用router和Pinia这是绕不开的需求。但有个问题request.ts如果直接import router在初始化阶段可能会有循环引用或者router尚未就绪的隐患。我的做法是路由实例用函数延迟获取或者确保request.ts在挂载后才被业务代码实际使用Pinia的store也一样不要在模块顶层拿store在拦截器函数体内通过useAuthStore()获取。拦截器里访问Pinia的一个典型场景是401时读取refreshToken刷新成功后用新Token重新发送原请求。这里有一个高级技巧利用Promise队列让多个并发401请求共享同一个刷新Token的结果避免同时刷新多次let refreshPromise: Promisestring | null null; function getRefreshPromise() { if (!refreshPromise) { refreshPromise refreshToken().finally(() { refreshPromise null; }); } return refreshPromise; }在拦截器里这么写刷新Token只发出一次所有排队等待的请求在刷新完成后一起重发页面无感。这个方案实测下来效果很好后台管理系统切换页面频繁、并发请求多的场景尤其明显。4.3 组件层加载状态的自动化管理响应拦截统一了错误处理之后加载状态也有机会统一管理。后台系统页面上“加载中”的状态无处不在列表首次加载、筛选变化、刷新按钮、表单提交、导出文件。我的做法是维护一个全局请求计数器import { ref } from vue; const pendingCount ref(0); export const globalLoading computed(() pendingCount.value 0); export function startLoading() { pendingCount.value; } export function endLoading() { pendingCount.value Math.max(0, pendingCount.value - 1); }请求拦截器里startLoading()响应拦截器里endLoading()全局顶部就能显示一个细长的进度条。这个需求在Vue3后台管理系统里几乎人人会写但很多人是在每个页面里各自控制最后总会有遗漏某个请求的finally忘了把loading置false按钮就一直转圈。放到拦截器层统一管就不存在这个问题了。要注意的是文件下载这种会较长挂起的请求可能不适合计入全局loading。通过给config加一个noLoading: true标记拦截器跳过计数即可。5. 常见问题与排查技巧实录5.1 拦截器里alert不弹、报错不提示最坑的一个案例接口返回500拦截器里明明写了ElMessage.error页面却没有任何反应控制台只有一句“Uncaught (in promise)”。查了半天发现是拦截器分支里先执行了ElMessage.error然后又执行了return Promise.reject(error)组件里的catch也弹了一个提示两个提示重叠在一起被吞掉了一个或者组件里的catch又抛了异常导致全局的错误提示被覆盖。我的习惯是拦截器负责提示组件层只负责后续逻辑不重复提示。如果确实需要组件知道失败原因在组件catch里只处理UI回滚之类的逻辑不再弹提示。想确认提示是否被吞可以用Vue的app.config.errorHandler统一捕获未处理异常打印日志。这里的错误提示很建议做节流同一秒内多次相同错误只弹一次否则后端连环报错时页面会变成弹窗轰炸。5.2 二次封装后返回undefined类型断言把错误吞了这个问题在TS项目里很隐蔽。有人封装了request之后调用方直接const res await request(...) as any;作用是绕过了类型检查但如果拦截器里抛错res实际是undefined。而因为断言成了any代码不报错后续res.data就抛“Cannot read properties of undefined”。类型断言不是不能用但不要在请求返回上无脑用as any除非你确定异常已被完全处理。5.3 并发请求导致重复弹窗前面提到401并发跳登录弹多次的问题其实不只是401其他错误也一样。比如三个接口同时500就弹三个“服务器繁忙”。用户会觉得系统有毛病。我的做法是加一个简单的节流let messageLock false; function showErrorThrottled(message: string) { if (messageLock) return; messageLock true; ElMessage.error(message); setTimeout(() { messageLock false; }, 1000); }批量刷新时的多个失败请求最终只弹一个提示既保留信息又不骚扰用户。这个“1秒内只提示一次”的经验值我用下来比较合适太长会让人忽略信息太短还是会连续弹。5.4 上传、下载接口不走JSON怎么办后台管理系统里的文件上传和导出响应格式和其他接口不一样。上传接口经常返回一个纯文本或者一个URL导出接口返回的是二进制Blob流。这时候拦截器如果统一按res.code判断就会把Blob当成JSON解析直接报“Unexpected token in JSON”。我的解决方案是在请求配置上增加一个responseType标记或者给这些请求单独设置transformResponse: []。在拦截器里判断if (response.request.responseType blob || response.config.responseType blob) { return response; }让文件流原样返回不进入业务码判断。这个分支一定要放在拦截器最前面因为Blob数据根本没有code字段走到后面的逻辑必然报错。另外文件下载接口经常还会遇到“后端报错但HTTP码是200返回体是JSON”的情况。比如导出Excel时后端业务异常HTTP状态码却是200返回的content-type是application/json里面写着错误信息。这种场景要在下载模块里额外判断一下响应头如果是JSON就说明是错误提示用户导出失败而不是丢给他一个损坏文件。5.5 上传进度和错误拦截配合上传文件时通常要监控onUploadProgress进度事件。这里有个细节上传接口报错时onUploadProgress可能已经走到了100%然后整体报错UI上的进度条瞬间从100%退回到0%视觉效果很差。我的处理是进度到100%时先不急着显示“完成”等服务端返回成功响应后再正式标记完成。这个和拦截器的配合在于上传接口的错误类型五花八门有网络中断、有服务端拒绝文件格式、有文件大小超限都需要在拦截器或上传模块中各归其位做出不同的提示文案。5.6 新接手项目时怎么快速定位拦截器问题如果你接手了一个Vue3项目发现接口报错但找不到提示在哪弹的我建议按这个顺序排查先全局搜索ElMessage.error、message.error之类的关键词看哪些文件在直接调用提示找到request.ts存放位置看拦截器里已经写了哪些错误处理在拦截器最前面加一行console.log([response], response.config.url, response)放行后统一观察看项目里有没有自定义的Axios实例或封装多层的情况很多老项目会二次甚至三次封装拦截器可能被套了两层最外层的错误处理覆盖了里层的。我见过一个项目请求封装了三层axios.create→request→http每一层都写了一遍错误处理最后弹窗弹了三遍。遇到这种情况就干脆收敛到一个层里统一治理别将就。写在最后的一点个人经验响应拦截器这个东西看起来就是几段代码真正难点不在写而在“边界条件想得到想不到”。断网、超时、Token失效、并发重复请求、二进制响应、业务码和HTTP码分离这些场景不都提前测过上线后迟早还给你颜色看。我个人做Vue3项目时会刻意把请求模块当一个小小“基础设施”来维护而不是今天这里加一段、明天那里补一块。维护到什么程度算好换一个新的后端团队对接时前端几乎不需要改动后端接口格式微调时只动拦截器一个文件。能做到这一点就说明响应拦截做得基本到位了。最后再分享一个细节拦截器里的自定义标记silent、noLoading这类尽量用常量名管理别在业务代码里随手写字符串。哪天全局搜一遍项目里所有请求配置能一眼看出哪些接口有特殊处理排查问题会轻松很多。如果你正在搭一个Vue3后台管理系统建议把响应拦截从项目第一天就立起来——它的价值往往在项目后期才会彻底显现。
阅读完成 · 觉得有帮助?
咨询建站