
做前端这几年Axios基本是每个项目里雷打不动的老朋友。接口要带上用户身份、要统一加签、要统计请求耗时这些活儿如果都散落在业务代码里一个个if判断那代码早就没法看了。请求拦截器就是专门解决这类所有请求发出前必须先做的事的机制。这篇文章我不打算把源码翻个底朝天而是按我实际在项目里用下来的经验把请求拦截器的原理、写法、高频场景和踩过的坑系统梳理一遍。如果你正在被自定义headers死活没带上去拦截器怎么不生效这类问题折磨这篇应该能直接给你答案。1. 请求拦截器到底解决了什么问题1.1 没有拦截器的项目是什么样的现场要理解请求拦截器的价值最快的方式是看一个反面案例。我接过一个老项目没有做任何全局拦截所有请求都在业务组件里这样发const res await axios.get(/api/order/list, { headers: { Authorization: Bearer localStorage.getItem(token), X-From: h5 } })每个页面都重复写一遍headers十几个接口就复制十几遍。到后来问题开始堆积第一token的key拼写不一致某几个页面鉴权悄悄失效排查了大半天才发现一个地方写成了Token一个地方写成了token第二新来的同事不知道要带X-From渠道标识漏掉之后运营在后台看数据发现某个渠道的流量莫名少了一截第三想统一给所有请求加个签名参数需要改动的地方多到只能靠全局替换硬搜。这类问题的根子在于鉴权、埋点、签名这些横切关注点被散落到了业务代码里。请求拦截器的作用就是把这一层公共逻辑从业务里剥离开让业务代码只关心业务参数公共的事情全在拦截器里一次性搞定。这也是为什么稍微规范一点的项目不管用不用TypeScript基本都会单独封装一个request模块把拦截器作为整个项目的请求关卡。1.2 拦截器的工作机制请求发出前的那几毫秒Axios的拦截器分两类请求拦截器和响应拦截器分别挂在请求发出前后。很多人第一次接触时搞不清它到底在哪一步介入这里我用文字把整个流程拆开当你在代码里调axios.get(url, config)时Axios内部大致经过这样几个阶段收集本次请求的config合并默认配置、实例配置、本次调用传入的配置。把config依次传给所有注册过的请求拦截器这里是你可以动手改config的最后关卡。请求拦截器处理完之后交给适配器发请求浏览器环境底层是XMLHttpRequestNode环境是http模块。拿到响应后依次经过所有注册过的响应拦截器做统一收尾。最终结果进入业务代码里的then或catch。所以请求拦截器本质上是一道闸门所有请求在出去之前都必须从这里过一遍。你需要做的就是在它身上挂上你要做的事然后记得return config把它放行。有人会问拦截器里能不能做异步操作可以完全没问题。拦截器里返回PromiseAxios会等它resolve之后再继续后面的流程这一点在讲token刷新时会非常关键。2. 请求拦截器的正确打开方式2.1 最小实现注册与销毁先看最基础的一个请求拦截器骨架就这么点import axios from axios axios.interceptors.request.use( (config) { // 在这里对config做修改 config.headers[X-Device] weapp // 一定要把config返回出去否则请求会被卡死 return config }, (error) { // 请求发出去之前就已经出错时会走到这里 return Promise.reject(error) } )这里有几个要点。第一个要点第二个参数错误处理函数实际触发概率很低一般是因为前一个拦截器抛了异常或者config构造失败。但即便不常触发也建议保留防止异常被静默吞掉。第二个要点如果你想移除某个拦截器axios提供了eject方法拿到use返回的id再调eject即可。不过真实项目里很少动态移除拦截器更多是全局一个实例一个主拦截器的形态。接下来是一个非常关键的设计决策是给全局默认的axios加拦截器还是给一个自定义实例加拦截器我的建议是用一个独立的axios实例。原因有三个第一不会污染全局axios避免影响第三方库内部对axios的直接引用第二需要多套拦截策略比如一套带登录态给业务接口用一套纯公开给登录页用时能灵活切换第三测试时可以对独立实例做精确的mock不像全局axios那样牵一发动全身。const service axios.create({ baseURL: import.meta.env.VITE_API_BASE, timeout: 10000 }) service.interceptors.request.use(...) export default service2.2 自定义请求头的注入姿势自定义headers是很多人搜得最多的点也是最容易出问题的点。不同版本的axios写法有差别。先说结论推荐下面这种兼容写法service.interceptors.request.use((config) { config.headers[X-Custom-Token] getToken() config.headers.set?.(X-Custom-Token2, 12) // 1.x 的 AxiosHeaders 写法 return config })为什么单独强调版本因为Axios在0.x时代config.headers就是个普通对象直接赋值就行。到了1.xheaders被换成了AxiosHeaders实例直接给自定义属性赋值在某些场景下会出问题官方推荐用config.headers.set(key, value)。所以稳妥的做法是上面那种混合写先走set方法不行再直接赋值。还有一个高频疑问axios.defaults.headers[X-XXX]和config.headers哪个优先级高记住结论请求级的config合并时会覆盖默认值。所以你在拦截器里设置的值优先级高于defaults里定义的同名header。再花点篇幅聊聊Content-Type这个特殊header。很多新手在上传文件时喜欢手动写config.headers[Content-Type] multipart/form-data然后死活上传失败。原因是multipart/form-data必须带一个boundary参数来区分每个分块的边界而boundary是浏览器在构造FormData对象时自动生成的。手动指定Content-Type会把默认的boundary顶掉后端解析直接崩。正确做法是上传FormData时什么都不用设置让它自己把完整的Content-Type带上。2.3 多个拦截器的执行顺序一个经典的坑这里有个我见过太多人栽跟头的点多个请求拦截器的执行顺序。在Axios内部请求拦截器是往执行链的头部插入的。你先后注册A和B两个请求拦截器最终的执行顺序是B先于A也就是后注册的先执行。而响应拦截器则是按注册顺序正常排队先注册的先执行。一句话记忆请求侧是后来居上响应侧是先来后到。这个顺序会带来真实的问题。比如你注册了a处理token注册b处理业务签名如果你的签名算法依赖token而a和b的注册顺序跟你想的不一样b在跑的时候可能拿不到已经注入的token最后签出来的值就是错的。更推荐的做法是不要过度依赖注册顺序而是把紧密相关的逻辑放在同一个拦截器里按顺序写清楚如果非要拆成多个拦截器记住上面的顺序规则。顺带说一下拦截器的拆分原则。我个人的习惯是按单一职责拆一个做身份认证一个做埋点一个做签名。身份认证永远排在最前面因为后面每一步可能都会用到token。职责越多排查问题的成本越高。3. 真实项目里的四个高频使用场景3.1 登录态自动注入与并发刷新最常见的场景不用多说凡是需要登录的接口统一在请求拦截器里带上tokenservice.interceptors.request.use((config) { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })但只带token远远不够实际项目里还有个大坑token过期后页面里同时发出的多个请求会一起返回401然后各自触发一次刷新逻辑刷新接口被并发打了很多次甚至刷新token自己也因此失效。我后来用共享同一个刷新Promise的方式解决let refreshPromise null function refreshTokenSafe() { if (!refreshPromise) { refreshPromise axios.post(/auth/refresh).then(({ data }) { refreshPromise null return data.token }) refreshPromise.catch(() { refreshPromise null }) } return refreshPromise }然后在响应拦截器里遇到401时等待同一个刷新Promise resolve之后重放失败的请求。这样不管同时有多少个请求挂掉都只会触发一次刷新从根上避免了并发刷新互相打架的问题。注意加了这套机制之后你的登录接口和刷新token接口本身要排除在拦截逻辑之外否则会死循环请求刷新接口→token过期→再去刷新→又过期……白名单逻辑要提前写好const WHITE_LIST [/auth/login, /auth/refresh] if (WHITE_LIST.some((path) config.url.includes(path))) { return config }3.2 请求签名与参数防篡改这块主要用在小程序、App的webview以及需要防脚本刷的页面。思路是在请求发出前把时间戳、随机数nonce和部分参数拼起来用摘要算法计算签名放到自定义header里import md5 from crypto-js/md5 service.interceptors.request.use(async (config) { const ts Date.now() const nonce Math.random().toString(36).slice(2) const body config.data ? JSON.stringify(config.data) : const sign md5(${ts}${nonce}${body}${SALT}).toString() config.headers[X-Ts] ts config.headers[X-Nonce] nonce config.headers[X-Sign] sign return config })这里有几个实操细节签名内容一般要包含原始body否则只签header很容易被改写抓包nonce配合后端缓存可以做防重放同一个nonce只允许成功一次时间戳要跟服务器时钟做偏差校验太旧的请求直接拒绝。需要提一句的是这种签名能防普通脚本防不了完整客户端逆向别把它当成银弹。3.3 全局埋点与慢请求统计请求拦截器非常适合做统一的数据采集。思路是在请求发出时记录开始时间在响应拦截器里计算总耗时超时或者异常的统一上报service.interceptors.request.use((config) { config.metadata { startTime: Date.now() } return config }) service.interceptors.response.use( (response) { const elapsed Date.now() - response.config.metadata.startTime if (elapsed 500) { reportSlowRequest(response.config.url, elapsed) } return response }, (error) { reportError(error.config error.config.url, error.message) return Promise.reject(error) } )这里的config.metadata是个比较巧妙的做法给config挂自定义字段不会影响请求本身但能让数据在请求与响应两个阶段之间流转。注意别往请求的body或正式字段里塞这些自定义数据会污染业务参数。还有埋点本身不能影响业务主流程上报失败要静默处理不能因为一个统计接口挂了就让用户看到报错。3.4 灰度开关与实验参数透传现在不少项目都有灰度发布和A/B实验的诉求最简单的实现方式就是把实验分组信息放到请求header里让后端按组返回不同结果service.interceptors.request.use((config) { const experiment getExperimentGroup(order_list) if (experiment) { config.headers[X-Exp-Group] experiment.groupId } return config })这种透传方式非常轻后端不需要额外解析参数只看header就能做分流。灰度时还能顺手把用户维度信息比如uid的hash带到请求里方便后端做分组验证。但注意自定义header一旦多了跨域请求会触发浏览器预检也就是常见的OPTIONS请求会多一次额外往返。所以header别滥加命名清晰、功能收敛是关键。关于CORS预检我习惯用个生活化的类比浏览器就像个安检员看到你带了个它不认识的自定义标签自定义header会先问服务器一句这标签你允许吗这就是OPTIONS预检。服务器需要在响应头里把自定义header的名字加到Access-Control-Allow-Headers里后续的真实请求才会放行。前后端联调时看到一堆OPTIONS请求别慌这是正常流程重点检查后端到底allow了哪些header。4. 我在实战中踩过的坑排查清单4.1 拦截器注册了却不生效第一类改的是默认的axios发请求用的却是另一个实例。比如某个模块引入的是service然后你在别的文件里给axios.interceptors.request.use注册拦截器当然永远不生效。排查方法很简单全局搜一下axios.create和axios.interceptors确认注册和请求用的是同一个实例。第二类.use写在了模块顶层但文件加载顺序不对。拦截器的注册模块压根没有被import到或者被某个懒加载延迟了执行导致前面的请求发出去了拦截器还没注册成功。规避办法是把注册逻辑集中到一个入口文件里所有请求模块都依赖它。第三类请求用的是service.get拦截器挂在axios.interceptors上而自定义实例和全局实例的拦截器链是隔离的。这种情况不是bug是设计如此使用时心里要清楚自己该挂哪一边。4.2 自定义headers死活没带上去这个问题的排查面比较广我按经验列了一张速查表现象可能原因处理建议后端收到的header是nullkey拼错或大小写不一致统一用小写前后端约定规范跨域时header丢失CORS预检没通过服务器没allow在Access-Control-Allow-Headers里列上自定义header只对POST生效GET不生效写到了headers.post里用config.headers.set或通用common设置了等于没设置后面有人把整个config.headers重新赋值检查是否有config.headers {...}这类代码最后一条值得单独强调。拦截器里如果有人写了config.headers { X-A: 1 }它会把整个AxiosHeaders对象替换掉之前所有header全没。正确做法永远是合并设置而不是整体覆盖。还有一个小规律浏览器会因为安全策略自动过滤掉一些敏感header比如Cookie相关字段。你的自定义header如果名字撞上这类保留字段同样会出问题。命名时尽量用X-前缀至于X-Custom-Token这类格式符合社区习惯又不占保留坑。4.3 拦截器里的异步坑请求拦截器支持async/await但也带来两个容易忽视的坑。第一个是忘记返回。有人写过这种代码service.interceptors.request.use(async (config) { await someLoad() config.headers[X] 1 // 没有 return config })拦截器函数不returnAxios拿到的就是undefined后面整条链路直接崩。排查时最容易被忽略因为控制台往往只报一个莫名其妙的request failed。第二个是异步竞态。多个请求同时进入拦截器都发现自己没有token于是各自跑去拿token最后拿到的不是同一个。解决方案和之前token刷新类似做一个单例去重让并发请求复用同一个异步结果。顺带提醒拦截器里别写太重的任务比如大文件读取、同步复杂加密因为这些逻辑串在请求链路上处理时间会直接叠加到每个请求上页面体感会非常差。4.4 错误处理断链与重复触发响应拦截器里如果对错误做了统一处理比如弹登录过期提示一定得明确谁来弹窗否则会弹好几遍。service.interceptors.response.use( (res) res, (error) { if (error.response error.response.status 401) { showLoginModal() } return Promise.reject(error) } )这个写法本身没问题但如果业务层每个catch里也各自弹提示就会出现重复弹窗。建议约定成俗错误提示统一由响应拦截器处理业务catch只负责分支逻辑不再弹框。同时401处理要配合前面说的刷新逻辑做成刷新一次后重放重放再失败才踢回登录否则用户会看到一闪而过的报错体验很差。还有一点容易被忽略响应拦截器里处理完错误后一定要return Promise.reject(error)把错误继续往下抛。如果这里直接吞掉错误返回成功值业务代码会拿到一个看似成功但内容是空的结果排查时非常痛苦。错误消息、状态码、原始请求上下文都得原封不动往下传。最后说点我实际用下来的体会。请求拦截器不是越复杂越好。我接手别人项目时第一眼就会看拦截器里堆了多少逻辑如果超过了五六个职责基本可以判断这块已经失控。更合理的做法是保持拦截器薄而清晰身份认证、签名、埋点各管一块复杂的重试、刷新逻辑抽成独立模块拦截器只做编排不做具体实现。另外这类全局逻辑一定要留日志开关线上出问题时打开debug级别的日志能省掉大量排查时间。按这套思路做下去我后面维护的项目里因为公共请求逻辑出的线上问题屈指可数希望这篇能帮你少走一点我走过的弯路。