ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Axios请求拦截器从入门到实战:统一鉴权、取消请求与避坑指南

Axios请求拦截器从入门到实战:统一鉴权、取消请求与避坑指南 1. 拦截器到底是什么——从一次真实项目需求聊起先说一个我去年真实遇到的场景。当时公司在做一个管理后台系统用户每天第一次打开页面登录态已经过期了后端返回401。前端这边需求是只要遇到401就自动跳转到登录页同时把用户原来要访问的地址记下来等重新登录完再跳回去。听起来很简单对吧但问题是这个后台有二十多个模块几十个接口调用点总不能在每个请求后面都写一段401判断吧。那会儿我刚好用Axios做统一封装解决这个需求最合理的落点就是请求拦截器和响应拦截器配合使用。Axios的请求拦截器简单说就是在请求真正发出去之前给你一个“插一脚”的机会。你可以在这里改配置、加请求头、拼公共参数、加签名甚至是直接把这次请求拦下来不发。响应拦截器则是拿到服务器返回数据之后在业务代码拿到数据之前统一做一层处理。不少刚从jQuery时代转过来的朋友问我我直接在封装函数里写不行吗为什么非要用拦截器我的看法是如果你只有三五个接口封装函数确实够用但一旦项目上了规模接口几十上百个请求场景千奇百怪拦截器这种“集中管控”的方式才管得住。它相当于安检口所有出境的包裹都过一遍安检而不是每个快递员各自检查自己的包裹。这也是Axios在国内这么流行的原因之一——它的拦截器机制让前端能够在网络层做统一治理而不是把逻辑散落在各个业务模块里。这篇文章我就围绕Axios请求拦截器展开从基础用法到进阶场景再到我实测过程中踩过的坑一次性讲清楚。适合正在做项目封装、想提高前端工程质量的同学参考。2. 请求拦截器的核心机制与写法拆解2.1 拦截器的基本结构两个参数一个都不能忽略Axios请求拦截器的标准写法是这样的import axios from axios // 创建实例 const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 15000 }) // 请求拦截器 service.interceptors.request.use( (config) { // 在发送请求之前做些什么 return config }, (error) { // 对请求错误做些什么 return Promise.reject(error) } )很多人只看第一个参数把第二个参数当摆设。其实第二个参数处理的是请求配置组装过程中出现的异常比如你在config里取某个变量报错了或者config本身有问题这个时候错误会走进第二个回调而不是直接抛到页面上。我建议不管用不用得到都把这个回调写上至少打个日志。这里有一个特别容易忽略的知识点请求拦截器里return config是必须的。如果你只写逻辑不返回请求就直接断在那里前端页面表现为“请求一直在pending最后超时”。更隐蔽的是你返回一个Promise.resolve(config)也能用但返回Promise.reject(config)就会把请求掐掉这在做请求阻断时会用到后面我会细说。补充一个细节——Axios拦截器的执行顺序。如果你注册了多个请求拦截器它们按“先注册先执行”的顺序跑但多个响应拦截器正好反过来是“后注册先执行”。这一点在团队协作时容易出问题比如你写了一个全局的请求拦截器用来加token同事又在另一个文件里注册了一个拦截器两个都改了headers执行顺序不同会导致最终效果不一样。2.2 请求拦截器里最常做的事动态组装请求头90%的项目里请求拦截器最核心的职责就两个加token加公共参数。加token的常规写法是service.interceptors.request.use( (config) { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } // 附带额外公共参数 config.params { ...config.params, timestamp: Date.now(), requestId: generateRequestId() } return config }, (error) Promise.reject(error) )这里有一个我特别想提醒的细节config.headers[Authorization]这个写法在Axios不同版本里表现有差异。老版本里headers可能是普通对象新版本里可能是AxiosHeaders实例直接通过下标赋值通常都能成功但如果你用的Axios版本比较新推荐用config.headers.set(Authorization, ...)这种方式避免某些情况下headers被覆盖却毫无提示。另一个新手常踩的坑是在拦截器里直接config.headers { Authorization: ... }等于把原来的headers整个替换掉了。原来可能已经设置了Content-Type: application/json被你这么一整后端接口直接返回415。正确做法是在原有headers基础上追加字段而不是重新赋值。还有关于自定义header很多人喜欢用X-Token这种自定义字段来传token这没问题但要注意跨域场景下后端必须显式允许这个header出现在跨域请求里否则浏览器会把请求判定为失败。另外自定义header在服务端能不能取到取决于你用的后端框架是否会把请求头里的自定义字段暴露出来常见的坑是网关层把小写字段转成了大写后端取值时匹配不上。2.3 请求拦截器与响应拦截器的配合节奏请求拦截器不是孤立存在的它和响应拦截器往往是成对出现的。一个好的设计是请求拦截器负责“发出前处理”响应拦截器负责“返回后处理”两件事在代码里看起来是分开的在业务上却是一条完整的链路。举个例子我的项目里请求拦截器负责加token、加签名、加设备信息响应拦截器负责统一的错误提示、401跳转、二进制数据剥离、业务码判断。这两层拦截器组合起来业务代码里就不再出现任何关于token或错误码的逻辑每个接口函数都是干干净净的export function getUserList(params) { return service.get(/user/list, { params }) }这样的好处是业务同事看到这个函数就知道它只负责“带着参数去请求用户列表”而不需要关心token从哪来、401怎么处理。这也是为什么我强烈建议大家在团队里把请求层统一收口而不是让每个人按照自己的习惯去写axios原生调用。3. 实战一个完整的请求拦截器封装过程3.1 项目背景与方案选型我这里要演示的封装是一个典型的移动端H5项目技术栈是Vue 3 Vite Axios。项目里有两类接口一类是普通业务接口需要带用户token另一类是开放接口比如验证码、各地区配置不需要登录也能访问。如果只用一个axios实例拦截器里得写大量的if判断来区分哪些接口需要token代码会很乱。我的解决方案是创建两个实例一个带完整拦截器链路的authRequest一个只做基础配置的openRequest。项目里大多数团队只建一个实例但我建议只要有“免登录”场景就建两个实例从源头隔离省得在拦截器里写各种分支判断。两个实例共用一套基础配置比如baseURL、timeout、withCredentials所以我一般先把公共配置提取出来。3.2 核心代码实现与注释先看请求实例的创建// request/index.js import axios from axios import { Message } from ant-design-vue import router from /router import { getToken, removeToken, getRefreshToken } from /utils/auth const createRequest (config {}) { const instance axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 15000, withCredentials: false, ...config }) return instance } // 需要token的实例 const authRequest createRequest() // 免登录的实例 const openRequest createRequest()然后给 authRequest 注册请求拦截器// 登录态请求拦截器 authRequest.interceptors.request.use( (config) { const token getToken() if (token) { config.headers.Authorization Bearer ${token} } // 把当前页面路由地址带上去方便后端做埋点或权限溯源 config.headers[X-Page-Route] router.currentRoute.value.fullPath // 加请求时间戳防止某些老旧后端接口缓存参数 config.params { ...(config.params || {}), _t: Date.now() } return config }, (error) Promise.reject(error) )这里有几个细节我想解释一下。第一config.headers.Authorization和config.headers[Authorization]是等价的但注意如果用了config.headers.set的外观就不能用delete来删得用config.headers.delete(Authorization)。不过日常场景里直接赋值就够了。第二我为什么要把_t放到 params 里因为有些后端接口特别是Java写的旧接口会缓存GET请求的URL参数如果不加时间戳后端数据更新了但前端拿到的还是缓存结果。这个参数单独加在拦截器里业务代码里就不用每个请求都写一遍。第三X-Page-Route这个自定义头是我从一次线上问题里吸取的教训。当时后端反馈说某个用户频繁提交订单需要前端提供用户当前所在页面来做判断。如果每个请求都手动带这个参数太容易遗漏放在请求拦截器里一劳永逸。3.3 响应拦截器的配套实现响应拦截器要和请求拦截器对上口径这里给一份我常用的模板authRequest.interceptors.response.use( (response) { const res response.data // 后端业务约定code为0时是成功 if (res.code 0) { return res } // 登录态失效 if (res.code 401) { removeToken() router.push({ path: /login, query: { redirect: router.currentRoute.value.fullPath } }) return Promise.reject(new Error(登录状态已过期)) } // 其他业务错误统一提示 Message.error(res.message || 系统开小差了请稍后再试) return Promise.reject(new Error(res.message || Error)) }, (error) { // 网络层错误 if (error.code ECONNABORTED) { Message.error(请求超时请检查网络) } else if (error.response) { const status error.response.status if (status 401) { // 这里能进来说明后端返回了401但没走业务code分支 removeToken() router.push(/login) } else if (status 403) { Message.error(没有权限访问) } else if (status 500) { Message.error(服务器内部错误) } } else { Message.error(网络连接异常) } return Promise.reject(error) } )我见过不少项目只处理response.data.code不处理HTTP状态码。这在前后端约定统一时问题不大但一旦遇到网关超时、504这类问题前端拿到的不是业务JSON而是网关的HTML页面这时候response.data.code就取不到了整条逻辑就断了。所以我在响应拦截器里做一个兜底当HTTP状态码不是2xx时根据statusCode走不同的提示这个分支写在错误回调里。3.4 两个实例的隔离边界为什么openRequest不注册请求拦截器我举个具体场景用户未登录时访问某个页面页面里要调一个获取省市区列表的开放接口。如果这个请求也走完整拦截器链路拦截器里发现没有token虽然我的写法是“有token就加没token就跳过”不会报错但免不了增加一些无效判断。更严重的是如果后端某天开始对部分开放接口也做鉴权校验那些没带token的请求可能直接被拒。我希望开放接口的语义足够清晰——它们就是不需要任何身份信息所以干脆用完全不同的实例从代码结构上强制隔离。其实在一个实例里加一个config.withoutAuth之类的标记也能实现但我实际体验下来“两个实例”比“一个实例加特殊标记”更不容易出错。原因在于Axios拦截器的逻辑是全局的你越是加特殊标记你就越容易在某一次迭代里忘记带标记或者新来的同事不知道这个标记的存在把需要鉴权的接口传成了withoutAuth排查起来相当麻烦。4. 进阶场景请求拦截器还能做哪些“越界”的事4.1 在拦截器里实现带取消的请求当你用Axios做搜索组件时用户不断输入关键词前端就不断发起请求。如果上一次请求还没返回下一次请求又发出去了响应顺序一旦错乱页面就会闪烁、数据错位。常规做法是手动跟踪一个CancelToken但这样每个用到搜索的组件都得写一遍取消逻辑。更好的方式是在请求拦截器里统一处理。思路是这样的给每个请求分配一个标识通常是URL 方法 关键参数组合在拦截器里判断如果这个标识对应的请求还在pending就把上一次的取消掉再放行新的。import axios from axios const pendingMap new Map() const getPendingKey (config) { return [config.method, config.url, JSON.stringify(config.params)].join() } const removePending (config) { const key getPendingKey(config) if (pendingMap.has(key)) { const cancel pendingMap.get(key) cancel() pendingMap.delete(key) } } authRequest.interceptors.request.use( (config) { removePending(config) config.cancelToken new axios.CancelToken((cancel) { pendingMap.set(getPendingKey(config), cancel) }) return config } )这段代码平时不动声色但用在搜索、筛选、批量导出等高频请求场景里效果非常明显。我最开始是用在列表页的筛选器上用户选了品牌、价格、分类每次筛选都会发请求如果网络稍慢上一次请求回来会把这次的响应覆盖掉。加了这层取消拦截之后类似问题一次都没再出现过。提醒一句CancelToken在Axios新版本里已经标记为deprecated推荐用AbortController但机制是类似的。如果你还在用老版本CancelToken没问题升级到新版本的话可以用config.signal controller.signal的方式替换。4.2 自动刷新token并重放请求这是很多后台系统都会遇到的痛点token有效期2小时用户正用着系统token突然过期了后端返回401。如果只是跳登录页用户刚才填的一大堆表单就白填了。更好的体验是前端检测到401先尝试用refreshToken去换新token成功后就自动把刚才失败的请求重新发一次。这个逻辑放哪里最合适的地方就是请求拦截器加响应拦截器的配合。单独在响应拦截器里处理刷新token最大的难点是刷新token这个动作本身也是一个axios请求它不能也走401刷新逻辑否则会死循环。所以一般会单独创建一个基础实例来做token刷新const refreshRequest axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) let isRefreshing false let queue [] const refreshToken async () { const res await refreshRequest.post(/auth/refresh, { refreshToken: getRefreshToken() }) return res.data.token } authRequest.interceptors.response.use( (response) response, async (error) { const { response, config } error if (response?.status 401 !config._retry getRefreshToken()) { if (isRefreshing) { // 已经有请求在刷新token了把当前请求放进队列排队 return new Promise((resolve) { queue.push(() { config.headers.Authorization Bearer ${getToken()} resolve(authRequest(config)) }) }) } config._retry true isRefreshing true try { const newToken await refreshToken() setToken(newToken) queue.forEach((cb) cb()) queue [] config.headers.Authorization Bearer ${newToken} return authRequest(config) } catch (e) { removeToken() router.push(/login) return Promise.reject(e) } finally { isRefreshing false } } return Promise.reject(error) } )这段代码里最关键的是isRefreshing和queue的设计。如果没有这个锁机制第一个请求拿到401后开始刷新token第二个请求紧跟着也拿到401也会去刷新token两个刷新请求同时发出后端会拿两个refreshToken去换极有可能出现token互踢的情况。加上锁之后同一时间只允许一个刷新操作其他请求暂时排队等新token回来后统一重放。我在这个环节上踩过一次很深的坑第一次写的时候在refreshToken里用的也是authRequest结果刷新接口本身拿到的也是401直接进了这个响应拦截器又重新触发刷新死循环。所以刷新token的请求必须用独立的实例或者通过配置标记skipAuthRefresh: true来绕过拦截器。4.3 在拦截器里做请求数据签名与加密如果是金融类、支付类项目接口数据往往需要签名。签名过程通常是把请求参数按字典序排列拼接固定字符串再加上时间戳和密钥做MD5或SHA256生成sign放到请求头或者请求体里。这个逻辑如果散落在每个业务函数里一是容易漏加二是签名算法一旦调整改起来就是灾难。放请求拦截器里做只需要改一处。import CryptoJS from crypto-js const generateSign (params, secret) { const keys Object.keys(params).sort() const str keys.map((key) ${key}${params[key]}).join() return CryptoJS.MD5(str secret).toString() } authRequest.interceptors.request.use((config) { const timestamp Date.now() const params { ...(config.params || {}), timestamp } const sign generateSign(params, SECRET_KEY) config.params params config.headers[X-Sign] sign config.headers[X-Timestamp] timestamp return config })注意一点签名用的密钥千万别直接写在代码里应该通过环境变量或者部署平台的配置注入否则一旦前端源码暴露签名机制就形同虚设了。另外签名用的参数顺序要和后端约定完全一致字典序只是一个初步方案有些后端用的还是自定义排序规则这个必须在接口文档里写清楚。4.4 灰度发布与多环境请求地址切换线上项目经常要做灰度发布。最简单的灰度策略是后端根据请求头里的某个标识来决定把请求转发到新旧哪个服务。前端如何优雅地加上这个标识请求拦截器就是现成的入口。const getGrayFlag () { if (window.location.href.includes(graynode1)) { return new } return stable } authRequest.interceptors.request.use((config) { config.headers[X-Gray-Flag] getGrayFlag() return config })这个用法很轻量但作用很大。它帮助前端团队在不改动任何业务代码的情况下通过URL参数、cookie或者localStorage来控制自己走新服务还是旧服务。我自己在测试新后端接口时经常用这种方式把流量切到灰度环境比反复改baseURL方便得多。5. 常见问题排查与避坑经验5.1 请求拦截器里改了config但实际请求没生效这个问题我大概被问过不下十次。现象是拦截器里console.log(config)明明看到headers已经改好了但开发者工具里网络请求的Request Headers没有显示这个字段。排查思路依次是检查是否使用了一个没有注册拦截器的实例。很多项目里同时存在axios默认实例和service自定义实例业务代码里一时图省事直接用了全局的axios.get(...)当然不会走拦截器。检查请求是否走了缓存。如果请求命中了HTTP缓存浏览器发出的实际请求可能根本没有到达服务器拦截器里的改动自然看不到。检查是否有第三个库在请求发出前又改回了headers。比如有些第三方上报SDK会在内部使用axios而且它们用的是自己的实例和你的拦截器毫无关系。确认你的axios版本看headers对象是否正确赋值。新版本axios建议用config.headers.set因为 headers 可能不是普通对象直接的config.headers.foo bar在部分版本会失效。我自己遇到过一次最离谱的同事在拦截器里写了delete config.headers[Content-Type]想让后端区分JSON和表单提交结果因为大小写问题没删干净后端一直收到content-type小写形式导致解析错误。排查了很久才发现Axios内部在发送请求前会把所有headers作归一化处理你在拦截器里改的Content-Type最终会被转成content-type。5.2 拦截器里使用Vue Router或Pinia导致循环依赖这是工程化项目里比较隐性的一类问题。假设request/index.js里引入了router而router/index.js里又引用了request/index.js比如路由守卫里要调用一个接口判断用户权限就会形成循环依赖。浏览器表现可能是启动项目时某些变量是undefined运行到某个时机才报错。我之前就遇到过在请求拦截器里用router.currentRoute.value.fullPathrouter文件里又在beforeEach里调用了某个APIAPI走的就是authRequest。启动时JavaScript加载顺序不稳定有时拦截器执行时router还没初始化完毕直接报Cannot read properties of undefined (reading fullPath)。解决方案是用import()动态导入或者把路由跳转放在setTimeout里延迟到路由初始化完毕。更稳妥的是把router这件事从拦截器里解耦出去——比如维护一个全局的globalRoutePath变量路由切换后通过watch更新它拦截器只需要读取这个变量不需要直接依赖router。5.3 请求拦截器里的异步问题是否支持 async/awaitAxios请求拦截器里的回调支持返回Promise所以你当然可以在里面写async/await。最常见的用法是在发请求前从本地存储异步读取一个加密密钥或者调用某个接口动态获取token。authRequest.interceptors.request.use(async (config) { const token await getTokenFromAsyncSource() config.headers.Authorization Bearer ${token} return config })这里有一个容易被忽略的问题配置了async拦截器后后续代码会等待这个拦截器执行完毕才继续所以会稍微增加一点请求耗时。如果这个异步过程本身比较慢比如访问的是慢速IndexedDB每个请求都会排队变慢。因此异步操作尽量在初始化时提前做好拦截器里只做同步读取除非确有必要才做异步。我见过一个项目在拦截器里做await checkToken()而checkToken里又调用了一个网络接口结果网络慢时一个页面几十个请求全部卡在等待状态页面白屏很久。这种设计要极度谨慎最好把“检查token”和“发请求”解耦用缓存机制保证只检查一次。5.4 第二个参数到底怎么办拦截器错误处理前面提到请求拦截器的第二个参数是处理请求配置错误的。但实际使用中由于业务代码很少在构造config时出错这个回调确实不常用。可是有一个场景它是有用的当你故意在拦截器里throw new Error()时这个错误会走进第二个参数而不会直接抛向业务代码。我常用它来做“请求阻断”。比如某些接口需要后端开白名单若当前环境不允许调用直接在第一个回调里抛错业务代码收到的是一个可预期的reject而不是一个莫名奇妙的网络错误。authRequest.interceptors.request.use( (config) { if (config.url.includes(/admin) !getToken()) { // 阻断请求抛一个业务错误 return Promise.reject(new Error(需要登录后才能访问管理接口)) } return config }, (error) { // 这里可以统一做日志上报 console.error([request interceptor error], error) return Promise.reject(error) } )这个方法比在业务代码里逐个判断要省事得多。尤其是在做权限控制时把“没有权限就不发请求”这种逻辑统一收敛在拦截器里用户体验是更好的——不会出现一个请求发出去转半天圈再弹一个权限不足的提示。5.5 关于axios.defaults.headers与实例级拦截器的优先级这里有个容易混淆的点。Axios里既可以在axios.defaults上设置全局headers也可以在实例创建时传headers还可以在请求拦截器里改headers。它们的优先级从低到高分别是默认值、实例配置、请求拦截器里的修改。但这不是绝对的。如果你在拦截器里只是给headers添加字段不会覆盖实例配置的同一字段那实例配置里的值会保留。如果你直接config.headers 新的对象那拦截器里的设置就是最终生效的。所以团队统一规范很重要拦截器里只做追加不要做整体替换否则不同人的写法不同合并代码时容易出现冲突。6. 我最后再说一个真实使用中的小技巧对于请求拦截器的质量评估我有两个“土办法”。一个是打开开发者工具把Network面板里的请求挨个检查一遍看看headers是否都带上了预期的字段另一个是在拦截器里放一个环境开关只在开发环境打印精简日志生产环境一律关闭避免日志刷屏也避免泄露信息。另外我想分享一个习惯给拦截器里每段逻辑都注释上“为什么”。拦截器是全局性的代码影响所有请求新接手项目的同事如果不理解某段逻辑的设计背景很容易在我的代码基础上“优化”出问题。比如“为什么加_t时间戳”——初次看代码的人大概率会以为是无用代码并删掉结果缓存问题复现。注释清楚“为什么这么做删了会有什么后果”这比写一百行API文档都管用。拦截器虽然看起来只是axios的一个简单函数但把它设计好、用好它能让你的前端代码在“网络请求”这个维度上做到标准化、平台化。我个人是建议每个前端项目都花点时间把请求层打磨好因为几乎所有线上问题最后都能在请求层找到蛛丝马迹。
返回列表