尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue项目Axios封装与请求拦截器实战:统一管理接口与Token刷新

Vue项目Axios封装与请求拦截器实战:统一管理接口与Token刷新 这半年来我复盘过好几个 Vue 项目发现一个很有意思的规律凡是请求代码写得好、接口维护起来不头疼的项目几乎都有一个共性——在 axios 之上做了一层完整封装而不是每个组件里直接import axios from axios随手调用。反之那些到了后期改个统一前缀都要全局搜索替换、报错信息五花八门的项目大多是把业务请求写散在了组件内部每个页面各写各的互不统一。今天这篇就围绕Axios 封装与请求拦截这个主题把我实际沉淀下来的那套封装思路、拦截器实现和踩坑记录完整梳理一遍希望给正在做 Vue 项目、尤其是团队协作项目的朋友一个可以直接参考的落地方案。这篇文章适合谁如果你刚接触 Vue 不久能用 axios 发请求但总觉得代码越写越乱你应该看如果你已经在做中后台项目想优化请求层的复用性和可维护性你也应该看。我会从最实际的痛点讲起再到封装目录设计、拦截器实现细节最后用登录/401 这种高频场景串起完整链路结尾还有几个我在真实项目里踩过的坑和排查思路。全程没有虚的全是能直接抄的代码和思路。1. 散装请求的痛为什么项目里必须有一个统一的请求层先说一个我印象很深的案例。有一个项目到了中期页面大概 20 多个团队三个人并行开发每个人都在组件里直接调 axios用各自习惯的方式处理错误。到后面前端负责人在周会上吐槽某个后端接口的超时时间从 15 秒改成 30 秒光改配置就翻了十几个页面想给所有请求加一个平台标识头发现有些请求加了有些没加更别说 token 过期之后有的页面跳登录页有的页面弹报错有的页面直接白屏——完全不受控。这种痛其实不是一个页面写错了某一个请求而是没有统一出口导致的系统性失控。具体来说散装请求至少会带来这几个问题配置无法收敛baseURL、timeout、withCredentials这些全局配置在每次请求里都重复写改一处忘一处改 A 页面漏 B 页面。身份信息注入重复且容易出错很多后台系统的接口需要 Token。散装时期每个页面都要从 localStorage 或者 vuex/pinia 里取 Token再手动塞到 headers 里代码复制来复制去一旦 Token 存储的 key 改了全项目都要跟着改。错误处理风格不统一有人用catch(err console.log(err))有人弹alert有人用 UI 库的 Message还有人干脆不处理放给用户看一个红屏。出了问题想统一弹个错误提示根本无从下手。接口地址散落各处后端一改接口路径你要在组件里逐个找。组件是业务粒度的不是接口粒度的接口和组件强耦合想复用和维护都很别扭。多环境切换基本靠手改测试环境一个地址生产环境一个地址某些场景还要连本地 mock。没有统一的环境配置就只能靠每个人的好记性。封装之后这些问题的解决逻辑就非常清晰全局配置收拢到 axios 实例里身份信息由请求拦截器统一注入错误码由响应拦截器统一收敛接口地址全部集中在 api 模块里按业务分文件管理。组件里只剩纯度极高的业务代码它只知道我调用一个函数拿数据完全不需要关心请求是怎么发出去的异常是怎么处理的。打个比方散装的请求像是每家每户自己从自来水公司拉水管规格不一、接口不一对出了漏点要挨家排查封装之后就是小区统一做了一个水表间和总阀门进你家之前所有的问题都在总闸处处理掉了你只管打开水龙头就行。2. 封装目录怎么搭一套可落地的接口层结构设计第一步不是写代码而是把目录结构定好。我常用的在 Vue 项目里比较顺手的结构是这样的src/ ├── api/ │ ├── modules/ │ │ ├── user.js │ │ ├── article.js │ │ └── file.js │ └── index.js ├── config/ │ └── env.js ├── utils/ │ ├── request.js │ └── auth.js这套结构里几个关键文件的职责划分是这样的utils/request.js整个请求层的核心。负责创建一个 axios 实例、挂载请求/响应拦截器、导出封装好的 request 方法。所有请求都从这个文件出发拦截器逻辑也都集中在这里。config/env.js环境配置。把VUE_APP_*这类环境变量在这层做一次映射代码里只引用这里的常量不直接散落字符串。api/目录业务接口层。每个文件对应一个业务域用户、文章、文件文件里每个函数的职责是描述一个接口请求包括 URL、方法、参数类型。组件永远只引入这里的函数不直接接触 axios。utils/auth.jsToken 相关操作。读写 Token、解析 Token、清理 Token 都收敛在这一个文件里避免各处直接操作 localStorage。为什么要坚持业务接口和axios 实例分开我见过很多封装方案是把所有userApi.getUser(id)一个函数一个函数地直接写在 request.js 里几百行之后这个文件变得又臭又长。按业务域拆成模块之后每个模块独立维护而且天然地就和后端接口文档的模块划分对上了。更重要的一点是如果后续要接 mock、要做多版本接口兼容、要换请求库你只需要改 request.js 和每个 api 模块内部的一层薄壳组件侧零改动。utils/request.js的基础模板我这样写// utils/request.js import axios from axios import { getToken, clearToken } from ./auth import router from /router const service axios.create({ // 这里取的是环境变量具体值在 config/env.js 里映射 baseURL: process.env.VUE_APP_BASE_URL, timeout: 15000 }) // 请求拦截器、响应拦截器在下方第三部分展开 export default serviceconfig/env.js的小巧思在于把环境变量收拢而不是在每个 api 模块里直接process.env.xxx// config/env.js const API_BASE_URL { development: process.env.VUE_APP_BASE_URL_DEV, production: process.env.VUE_APP_BASE_URL_PROD, test: process.env.VUE_APP_BASE_URL_TEST }[process.env.NODE_ENV || development] export default { apiBaseUrl: API_BASE_URL, requestTimeout: 15000 }这样做的理由是不同环境的后端地址往往不一样你在.env.development、.env.production里写好各自的地址代码里只引用config/env.js暴露的常量就不会出现代码里写死了一个 http://192.168.x.x上线前到处找哪里改的尴尬。api 模块里再强调一点就是函数要一个接口一个函数并且尽量用 JSDoc 标明参数类型和返回值用 TypeScript 更好。裸写axios.get在业务里满天飞和封装之后getArticleList(params)的代码可读性差别是立竿见影的。3. 拦截器才是封装的灵魂请求拦截与响应拦截的完整实现axios 的拦截器是整个封装方案的核心价值所在它把横切关注点身份注入、日志、错误码收敛、Loading 控制、防重复提交从各个业务代码里抽离出来统一在一个管道里处理。没有拦截器前面的目录结构只是空壳。3.1 请求拦截器身份注入、平台标识与Loading请求拦截器的作用范围是请求发出之前。我最常用的能力有两个Token 注入和 Loading 控制。service.interceptors.request.use( config { // 1. Token 注入 const token getToken() if (token) { // 注意这里用 set 而不是直接赋值避免覆盖已有同名请求头 config.headers.set(Authorization, Bearer ${token}) } // 2. 平台标识后端常用来区分 Web / App / H5 config.headers.set(X-Client-Type, web) // 3. 业务级透传参数比如让后端判断当前请求来源页面 // config.headers.set(X-Request-Source, config.source || default) // 4. Loading 控制配合 UI 库做示例 // if (!config.silent) { // Loading.show() // } return config }, error Promise.reject(error) )这里有个细节特别值得注意请求拦截器里config.headers.set(...)比直接config.headers.Authorization ...更稳妥。因为某些场景下业务代码也可能手动给本次请求设置 headers直接赋值会把同名的前一个值覆盖掉用 set 方法可以避免一些意外的隐式覆盖这在 axios 新版v1.x里也推荐使用。你会在实际项目里发现排查为什么我设置的请求头丢了这类问题很多时候就是因为赋值顺序或覆盖方式导致的。Token 注入的完整逻辑是这样的用户登录成功后Token 被写入utils/auth.js中的存储位置localStorage 或 cookie。之后任何请求发出时请求拦截器统一从存储里取 Token塞进请求头。这样业务组件里就完全看不到取 Token 塞 Token的逻辑了即便某天你把 localStorage 换成 cookie或者把 Token 的 key 改了业务侧也零感知。Loading 控制在真实项目中需要谨慎使用。我建议的做法是默认不全局显示 Loading只对关键接口比如列表查询、提交按钮显示局部 loading或者干脆交由组件内部用 loading 字段控制。原因是后端接口如果响应很快loading 一闪而过看起来更卡。我在后文第四部分会再展开这个问题。3.2 响应拦截器状态码收敛、错误分级与401统一处理响应拦截器是我在实操中花费最多时间的地方。它的核心目标是把所有后端可能的返回情况统一收敛到同一套处理逻辑里让业务代码永远只接收真正干净的数据。service.interceptors.response.use( response { const res response.data // 后端约定HTTP 200 时返回 { code: 0|200|0, message, data } // 407 表示登录态失效也可以约定为 401 if (res.code 401 || res.code 407) { handleUnauthorized() return Promise.reject(new Error(登录状态已失效请重新登录)) } // 业务级错误码统一弹错误提示 if (res.code ! 0 res.code ! 200) { Message.error(res.message || 请求错误) return Promise.reject(new Error(res.message || 请求错误)) } // 成功时直接把业务数据抛给调用方组件里拿到的不再是 {code,message,data} 包裹 if (res.code 0 || res.code 200) { return res.data } return Promise.reject(new Error(未知错误)) }, error { // 网络层错误 / HTTP 非 2xx 错误 // 这里的 error 是 axios 的 error 对象 const status error.response?.status if (status 401) { handleUnauthorized() } else if (status 403) { Message.error(没有权限访问该资源) } else if (status 404) { Message.error(请求的资源不存在) } else if (status 500) { Message.error(服务器开小差了请稍后重试) } else if (!error.response) { Message.error(网络异常请检查网络连接) } return Promise.reject(error) } )这里有两个非常关键的实践结论我强烈建议你记下来第一业务码和 HTTP 状态码要分开处理。很多后端设计会将业务错误也通过 HTTP 200 返回只靠 body 里的code区分成功失败而网络层错误、权限类错误、服务端 500 又通过 HTTP 状态码体现。响应拦截器必须两套判断都做。如果你只判断 HTTP 状态码业务码判断缺失时你会拿到HTTP 200 但其实业务失败的脏数据如果只判断业务码网络中断时拿到的 error 只是 axios 堆的错弹不出友好提示。第二401 的统一处理逻辑要单独抽成handleUnauthorized()在所有项目里都要这么做。原因是 401 不只是弹个提示那么简单它会牵涉到用户状态清理、路由跳转登录页、甚至 Token 无感刷新。我在第四部分会专门展开这个场景。这里先给一个简单的版本// handleUnauthorized import { clearToken } from ./auth import router from /router function handleUnauthorized() { clearToken() // 避免重复跳转先判断当前路由 if (router.currentRoute.value.path ! /login) { router.push({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) } }从调用方的视角看响应拦截器带来的收益非常直观组件里请求成功的回调拿到的直接是data不再是response.data.data请求失败的回调必拿到一个Error对象附带一条已经弹好的错误提示。业务侧只需要关心成功时我要的数据长什么样失败时我要不要做额外兜底。3.3 拦截器里的类型设计与可维护性用 JavaScript 写的话res的形态因人而异用 TypeScript我通常会为后端的通用响应结构建一个类型。虽然这里以 JS 为主展示但建议你在项目中至少给request.js写一个 JSDoc 说明让团队其他成员一眼明白这个函数返回的数据是解包后的 data不是 axios 的 response。响应拦截器返回res.data而不是整个response这也是和 axios 默认行为的关键差异。这么设计之后业务代码可以写const list await getList(params)而不是const { data: { data } } await getList(params)。不要小看这一层解包它让请求层对业务代码的侵入降到最低也让你后续如果要替换请求库比如从 axios 换成 fetch 封装或者换成本地 mock业务的调用方式可以基本不变。4. 从登录到401自动跳转一次完整的拦截器实战链路前面讲了拦截器的设计和代码框架但拦截器到底在真实业务流程里能产生什么效果还是串一个完整闭环最好理解。这里我用最常见的登录 → 业务请求 → 登录态过期 → 自动跳转登录页来做一次完整的链路演示。4.1 登录阶段接口函数 Token 存储api 模块里user.js 长这样// api/modules/user.js import service from /utils/request export function login(data) { return service({ url: /user/login, method: post, data }) } export function getUserInfo() { return service({ url: /user/info, method: get }) }在登录页的登录函数里async function handleLogin(form) { loading.value true try { const res await login(form) // 响应拦截器已经解包这里 res 是后端返回的业务数据 // 假设后端返回 { accessToken, refreshToken, userInfo } setToken(res.accessToken) setRefreshToken(res.refreshToken) // 存用户信息pinia 或 vuex 都行 userStore.setUserInfo(res.userInfo) router.push(/) } catch (error) { // 错误提示已在拦截器里统一弹过这里主要处理额外逻辑 // 比如把按钮的 loading 复位 } finally { loading.value false } }登录接口返回的 Token 存入utils/auth.js管理的存储位置之后的任何请求都由请求拦截器自动携带 Token。这一步完成之后登录后进入系统请求所有业务接口都带 Token 这个最基础的数据交互闭环就通了。你可以在浏览器的 Network 面板里验证一下登录成功后随便点一个列表页请求头里是否自动带上了 Authorization。4.2 无感刷新 Token避免用户被突然踢下线上面那个闭环里藏着最典型的一个体验问题如果 Token 有效期是 2 小时用户正在填一个长表单时 Token 过期了下一个请求直接返回 401此时用户被踢回登录页填了一半的表单全丢了。这体验非常糟糕。比较成熟的做法是双 Token 无感刷新。思路是后端同时返回accessToken短期有效比如 2 小时和refreshToken长期有效比如 7 天请求拦截器携带 accessToken当 accessToken 过期收到 401 时响应拦截器先用 refreshToken 调用一个刷新接口拿到新的 accessToken 后重新请求原来的接口如果 refreshToken 也失效了才跳登录页。这段逻辑其实是响应拦截器 401 分支里的核心实现。我在项目中是这样处理的伪代码 精简化// 用一个 flag 防止并发 401 导致的重复刷新 let isRefreshing false // 缓存等待队列刷新期间的新请求都进队列刷新完成后再统一重放 let pendingQueue [] async function handleUnauthorized() { const refreshToken getRefreshToken() if (!refreshToken) { forceLogout() return } if (isRefreshing) { // 如果已经在刷新中把当前请求挂到队列里 return new Promise((resolve, reject) { pendingQueue.push({ resolve, reject }) }) } isRefreshing true try { // refreshToken 请求新 accessToken const res await service.post(/user/refresh, { refreshToken }) setToken(res.accessToken) // 把所有等待的请求重放补上最新的 token pendingQueue.forEach(({ resolve, reject }) resolve(res.accessToken)) pendingQueue [] return res.accessToken } catch (error) { // 刷新失败彻底登出 pendingQueue.forEach(({ resolve, reject }) reject(error)) pendingQueue [] forceLogout() throw error } finally { isRefreshing false } }这段代码里面有一个很隐蔽但项目里必然遇到的坑多个请求同时 401 时不能每个请求都去调一次刷新接口。比如用户打开系统后首页同时发了三个业务请求三个都 401如果各自刷新后端会重复刷新、浪费资源甚至刷新失败因为旧的 refreshToken 可能被最先那次刷新就已经置失效了。解决方式就是上面展示的isRefreshing标志 等待队列第一个 401 触发刷新后续的 401 不重复触发而是把原来的请求挂到队列里等拿到新 Token 后统一重放。在响应拦截器里怎么接入这个刷新逻辑关键在于不能只跳登录页还要重新发一次原来的请求。所以拦截器里要这样做// 在响应拦截器的 error 分支里对 401 做特殊处理 if (status 401) { try { const newToken await handleUnauthorized() // 用最新 token 重发原始请求 const originalRequest error.config originalRequest.headers.set(Authorization, Bearer ${newToken}) return service(originalRequest) } catch (refreshError) { // 刷新失败已经 forceLogout 过了 return Promise.reject(refreshError) } }这段逻辑如果在团队里跑起来会是请求层最有价值的体验提升之一。用户完全无感知Token 过期后所有请求自动续期继发不会再出现莫名被踢出登录页的情况。4.3 路由守卫与拦截器的配合边界这里要提醒的是路由守卫beforeEach和响应拦截器的 401 跳转不要搞成双重嵌套。有一个很常见的错误写法路由守卫里每一跳都校验 Token 是否过期过期就跳登录页。这不完全是坏事但校验过期的动作如果每个路由都同步去做你又要在守卫里发一个接口去验证 Token这个接口可能也带 401响应拦截器又跳登录页——两个地方同时跳页面会跳得莫名其妙。我的实践原则是路由守卫只判断本地有没有 Token 这个标识不做过期校验Token 是否真实过期由接口的 401 结果来判定。因为只有真实访问了受保护接口才能得到后端对 Token 的有效性结论。如果本地的 Token 是假的但还没过期拦截器也能兜住如果本地没有 Token 而访问了需要登录的页面路由守卫基于本地状态就能做拦截。两层职责分清就不会出现跳转竞争。5. 拦截器实战中的坑参数丢失、文件上传与服务端空数据的隐藏问题封装做得再漂亮落到真实项目里总是能遇到一些预料之外的情况。下面这几个坑是我在多个 Vue 项目里反复踩过、也帮别人排过的写在这里希望你能绕开。5.1 请求头覆盖问题拦截器 set 的 header 被横插一手现象某个接口需要自定义一个 header比如X-Custom-Header业务侧在调用时这样写service.get(/xx, { headers: { X-Custom-Header: value } })然后你发现请求真的发出去了但拦截器里设置的Authorization丢了。排查下来才明白axios 在合并请求配置时的顺序是全局默认配置 → 实例配置 → 单次请求配置。如果单次请求配置里你重新构造了一个 headers 对象某些版本的 axios 在 merge 时会把不确定的 header 字段给覆盖掉而不是合并。细看之后发现是业务方把整个 headers 对象作为请求配置传入了导致拦截器里config.headers.set(Authorization, token)设置的字段被丢弃。解决方案是在请求拦截器里赋值时先判断当前config.headers是否存在并尽量通过 set 方法修改避免直接整体替换。同时在 api 模块里调用请求时尽量不要传全新 headers 对象而是通过 api 模块统一拼装或使用config.headers.set能力。如果再遇到奇怪的头丢失优先在 Network 面板里看闸口请求的实际请求头再回来对照拦截器代码。5.2 文件上传的 Content-Type 陷阱axios 上传文件的场景如果拦截器里统一做了数据序列化或统一设置了Content-Type: application/json那你直接传 FormData 就会让后端收不到文件。我遇到的项目里有些同事将拦截器写得比较霸道直接config.headers[Content-Type] application/json这导致 multipart/form-data 请求被强制转成 JSON 格式文件传不上去还可能拿到 400。正确做法是文件上传接口在调用时显式指定headers[Content-Type] multipart/form-data并且拦截器里不要去全局覆盖这个配置。更稳妥的是把上传请求独立封装绕开常规序列化逻辑单独设置 headers。因为后端对文件的接收方式和普通接口不一样适合特殊化处理。实际调试时如果上传失败先把 Network 面板的请求 body 打开看确认是否变成了 JSON 字符串而不是 boundary 拼出的 multipart 格式。这基本一眼就能看出来问题出在 Content-Type 上。5.3 空数据返回时的解包问题有些后端接口成功时返回的结构是{ code: 0, data: null }比如删除操作、更新操作后端不返回任何字段。响应拦截器统一return res.data后业务里就拿到null这本身没问题。但如果你的响应拦截器里有 成功时判断 res.data 是否存在不存在就抛个错 的逻辑删除和更新这类接口就会全部报错。我见到过类似的拦截器因为对空 data 过于敏感导致用户点删除按钮明明成功了前端却报个异常。我的建议响应拦截器只认code不要认data是否为空。data为 null 是合法的业务结果交给业务侧自行判断。除非你明确和后端约定好null 代表接口异常否则千万别在拦截器加这种好心但误伤的断言。5.4 并发请求重复 401 的有些项目不会猜场景上一部分讲到的等待队列在真实项目里会有一个误解很多人觉得登录后第一个接口就是主接口根本没多个并发。但实际上非常常见用户进入详情页同时请求详情数据、请求评论列表、请求用户信息甚至还有埋点上报。三五个请求一起发出一起到 401如果没有isRefreshing就会触发多次刷新。这类 bug 很难在本地复现因为本地 Token 常常设置很长但线上几乎隔三差五出现。别问我为什么这么强调因为我就是在生产环境被这玩意儿坑过一遍才彻底想明白的。6. 封装的最终收益从组件代码视觉效果看变化把所有逻辑都串完之后封装前和封装后的组件代码差异是非常直观的。封装前一个获取列表的onMounted可能长这样onMounted(async () { try { const token localStorage.getItem(token) const res await axios.get(process.env.VUE_APP_BASE_URL /user/list, { headers: { Authorization: Bearer ${token} } }) if (res.data.code 0) { list.value res.data.data } else { alert(res.data.message) } } catch (error) { console.log(请求失败, error) } })封装后同一个功能的代码是onMounted(async () { const res await getUserList({ page: 1, pageSize: 10 }) list.value res })区别一眼能看出来。前者的请求细节、错误处理、状态码判断全部堆在组件里后者的组件只关心调函数、拿数据。当项目页面多起来这个差异会放大为维护成本的差异甚至直接决定你改一个公共逻辑要不要连坐十几个文件。另外我在做封装时还有一个习惯把service实例的方法再导出一层命名为request让语义更直观。如果你的项目同时用 axios 的get/post/put/delete方法也可以在上层包一层语义化方法方便统一挂载取消请求、统一处理特定场景但这个属于可选先把主体封装做好其他能力按需加。7. 写在最后的几个封装建议以上是我做 Vue 项目里请求层的常用套路。代码不长但收益很大。最后按我的个人体会补充几条实操建议第一别把封装做成一锤子买卖。请求层要跟随项目成长持续迭代比如新增了取消重复请求的需求、增加了自动刷新 Token的能力都要在 request.js 里不断沉淀。每加一个能力就意味着业务侧少掉一类散装逻辑这个投资收益比是很高的。第二封装不是越厚越好。我看到过有人把拦截器写得极其复杂各种隐藏逻辑、魔法字段业务侧根本看不懂报错从哪来。封装的本质是把复杂的通用逻辑收敛住把简单的确定性交给业务如果封装本身变成了黑盒那就背离初衷了。关键原则是拦截器里的逻辑要尽量可解释团队里任何一个人打开 request.js 都能迅速看懂每一段是干什么的。第三每次改动拦截器都要做一次全量回归。拦截器是全局出口动一行代码可能影响所有接口。我习惯在发版前后手动过一遍登录、列表、上传、导出这几类典型场景确保新改的 header、新加的错误分支没有误伤。做前端这些年我越来越觉得请求层是 Vue 项目里最值得投入精力的基础设施之一。它不是炫技的地方但绝对是决定项目长期好不好维护的地方。希望这篇关于 Axios 封装与请求拦截的完整拆解能帮你把网络请求和数据交互这块做得更顺手。如果你在实践中也遇到了一些奇特的拦截器问题欢迎在评论区交流我们一起把这层总阀门修得更稳。
返回列表