接手过几个 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 页面弹一个自定义 modal,B 页面用 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 ApiResponse<T> { 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.get<ApiResponse<UserInfo>, 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 都由这一层接管。等到下一次再有后端调整接口结构时,你发现只要改一处拦截器,全站页面都不受影响,那种感觉还是挺踏实的。希望这篇文章能帮你在自己项目里少走几条弯路。