做前端这几年,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的最后关卡。
- 请求拦截器处理完之后,交给适配器发请求,浏览器环境底层是XMLHttpRequest,Node环境是http模块。
- 拿到响应后,依次经过所有注册过的响应拦截器,做统一收尾。
- 最终结果进入业务代码里的
then或catch。
所以请求拦截器本质上是一道"闸门",所有请求在出去之前都必须从这里过一遍。你需要做的,就是在它身上挂上你要做的事,然后记得return config把它放行。有人会问,拦截器里能不能做异步操作?可以,完全没问题。拦截器里返回Promise,Axios会等它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.x,headers被换成了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 登录态自动注入与并发刷新
最常见的场景不用多说,凡是需要登录的接口,统一在请求拦截器里带上token:
service.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是null | key拼错或大小写不一致 | 统一用小写,前后端约定规范 |
| 跨域时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 })拦截器函数不return,Axios拿到的就是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级别的日志,能省掉大量排查时间。按这套思路做下去,我后面维护的项目里,因为公共请求逻辑出的线上问题屈指可数,希望这篇能帮你少走一点我走过的弯路。