说到请求响应拦截器链,很多人的第一反应是:拦截器嘛,注册一个就好。先塞token,再收响应,最后统一弹个错误提示,完事。但一旦项目进入多业务线、多环境、多客户端共存的阶段,这种单拦截器写法很快会变成一团乱麻。我这篇想讲的不是“怎么用拦截器”,而是“怎么把20个拦截器节点组织成一条可靠、可测、可降级的链路”——请求侧10个节点,响应侧10个节点,每一道工序只干一件事。适合被单一拦截器折磨过的前端开发,也适合刚接触拦截器链、想绕过常见坑的同学。
先交代一下背景。某中后台项目经历了从单体页面到多业务模块的演进,接口数量翻了几倍,鉴权方式从固定token变成了过期刷新,部分接口还要求加密和签名。最初所有逻辑都堆在一个拦截器里,它同时做token注入、参数签名、响应解密、状态码判断、错误提示、埋点上报,单文件超过400行。每次加需求都像在雷区里挪脚步。线上出过一次事故之后,我下决心把整个链路拆成20个独立节点,重新做了一遍。这篇文章就是把那次重做的完整思路、配置和踩坑记录留下来。
1. 为什么单个拦截器撑不起复杂项目:一个请求背后站着十几道工序
1.1 从一次线上事故看“巨无霸拦截器”的问题
那次事故的典型场景是这样的:A页面发了一个查询列表请求,响应体是加密的,拦截器里先做token过期判断,再做响应解密。问题是token过期判断的逻辑假设响应体已经是明文JSON,它去检查response.data.code,实际拿到的是密文字符串,误判成业务失败,直接抛了个“登录失效,请重新登录”的全局提示。用户还没输密码呢,就被踢回了登录页。
排查了很久才发现,根因根本不是接口有问题,而是解密、状态码判断、token刷新这三件事的顺序天然有依赖,却被塞进同一个拦截器里互相干扰。更难受的是,这个拦截器里还混着埋点上报和数据脱敏。想在A接口跳过脱敏?对不起,你得先读懂400行代码,再加一个if分支。
这类问题本质上是“单一职责失衡”。一个请求从业务代码发出,到真正离开浏览器,中间要经历基础参数补全、token注入、敏感字段加密、签名、埋点等步骤;响应从网络层回来,到业务拿到可用的数据,还要经历解密、包装解析、状态码分类、脱敏、标准化。这些步骤有严格的先后关系,硬塞进一个函数,顺序和异常范围都会失控。
1.2 拦截器链的本质:管道里的每道工序只干一件事
拦截器链其实是一个管道模型。请求数据从左侧进入管道,经过一道道工序,最终交给网络层;响应数据从右侧进入管道,经过一道道工序,最终交给业务层。Axios这类库之所以支持多个拦截器,就是希望大家像搭积木一样,把复杂逻辑拆开。
理解这个模型后,很多事情会变得清晰。比如加解密属于“安全工序”,可以设计成只对白名单接口生效;埋点属于“观察工序”,即使它崩了也不能影响主流程;token刷新属于“状态修复工序”,它要能中断当前链路并重新发起原请求。每个节点独立的话,新增需求基本不需要碰别人写的代码。
2. 注册顺序和执行顺序:请求侧逆流,响应侧顺流
2.1 Axios源码里的两个循环
如果你仔细读过axios的拦截器实现,会发现请求侧和响应侧的存储方式完全不同。源码里大致是这样处理的:
// 请求拦截器:unshift,后注册的先执行 const requestInterceptorChain = []; this.interceptors.request.forEach((interceptor) => { requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected); }); // 响应拦截器:push,先注册的先执行 const responseInterceptorChain = []; this.interceptors.response.forEach((interceptor) => { responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected); });也就是说,你写代码时的注册顺序,和实际执行顺序并不总是同一回事。请求拦截器是后注册先执行,响应拦截器是先注册先执行。我用文字描述一下完整链路:
请求拦截器 按注册顺序倒序执行 -> 真正的HTTP请求 -> 响应拦截器 按注册顺序正序执行这个差异是后面所有顺序坑的根源。
2.2 签名、Token、加密的先后顺序为什么容易写反
假设请求侧有三个工序:加密body、注入token、计算签名。逻辑上正确的执行顺序应该是:加密body,注入token,最后计算签名,因为签名要覆盖加密后的请求体和最终的头信息。
如果我们直接按这个逻辑顺序从上到下注册,后果恰恰相反。因为请求拦截器后注册先执行,代码里先写的加密会最后一个执行,后写的签名反而最先执行,签名拿到的还是明文body和没有token的header,整个签名就废了。
我当时的处理方式是维护一个“逻辑顺序数组”,再用倒序注册还原成真实执行顺序:
const requestHooks = [ encryptBody, // 1. 加密敏感字段 injectToken, // 2. 注入token computeSignature, // 3. 计算签名 ]; // 因为axios请求拦截器后注册先执行,这里倒序遍历注册 [...requestHooks].reverse().forEach((hook) => { api.interceptors.request.use(hook); });这样数组里的顺序就是人眼看到的执行顺序,后续加新拦截器时,按逻辑顺序往数组里插就行,不用再脑补“反着来”这件事。这个习惯帮我避开了大量低级错误。
3. 实战:一条20节点拦截器链的完整配置
3.1 请求侧10个节点的职责划分
既然标题叫“20请求响应拦截器链”,我就把实际的20个节点拆给你看。请求侧10个节点,按逻辑执行顺序排列如下:
| 节点 | 名称 | 核心职责 |
|---|---|---|
| R1 | contextInit | 初始化内部上下文,把本次请求的ID、开始时间、来源页面写入config内部字段 |
| R2 | deviceInfo | 注入设备指纹、平台标识、App版本 |
| R3 | baseParams | 补全timestamp、nonce、appId等公共参数 |
| R4 | normalizeParams | 清理空值、给复杂对象排序 |
| R5 | injectToken | 从token管理器读取当前有效token并写入Authorization头 |
| R6 | encryptSensitive | 对body中指定的敏感字段做AES加密 |
| R7 | computeSignature | 基于method、path、params、body、header子集计算签名 |
| R8 | grayMark | 根据用户灰度策略写入灰度开关头 |
| R9 | offlineReplay | 标记当前请求是否为离线补发请求 |
| R10 | requestLog | 上报请求级埋点 |
R1必须排在最前,因为它负责建立本次请求的上下文,后面所有节点都可能往里面追加数据。R5、R6、R7这三个节点对顺序最敏感。加密要在签名前,token注入要在签名前,签名必须放在这两者之后。如果后端要求header里的内容也参与签名,那么token注入就得继续保持在签名前面。
我放一段加密和签名的简化实现,方便理解它们如何协作:
// R6 敏感字段加密 async function encryptSensitive(config) { const fields = ['password', 'creditNo', 'phone']; const data = config.data || {}; if (typeof data === 'object' && fields.some((key) => key in data)) { const encrypted = await encryptWithAes(pick(data, fields), config.__ctx.aesKey); config.data = { ...data, __encrypted: encrypted, ...omit(data, fields), }; } return config; } // R7 签名计算 async function computeSignature(config) { const signingText = [ config.method.toUpperCase(), config.url, JSON.stringify(config.data || {}), config.headers['Authorization'] || '', ].join('&'); config.headers['X-Sign'] = hmacSha256(signingText, config.__ctx.signKey); return config; }3.2 响应侧10个节点的职责划分
响应侧同样拆成10个节点。之所以要把“统一错误处理”拆开,是因为不同错误发生的链路位置不同,处理策略也不同。下面是我的划分方式:
| 节点 | 名称 | 核心职责 |
|---|---|---|
| B1 | networkError | 捕获超时、断网、CORS等网络层错误,转换成NetworkError |
| B2 | decryptResponse | 对响应体做解密,还原明文 |
| B3 | parseWrapper | 解析{ code, message, data }外层结构 |
| B4 | codeDispatch | 按状态码分类:成功放行,业务失败抛BusinessError,401/403打标 |
| B5 | authRefresh | 处理token过期:单飞刷新、重放原请求、防死循环 |
| B6 | desensitize | 对手机号、身份证、邮箱等字段做脱敏 |
| B7 | standardize | 把后端字段映射到前端模型 |
| B8 | i18nMessage | 根据当前语言映射错误消息 |
| B9 | trackResponse | 响应级埋点上报 |
| B10 | chainLog | 输出整条链路的日志汇总 |
B4和B5是最容易写混的一对。B4只负责“判断和打标”,不要立刻抛错,否则B5就收不到原始response了。B5负责“修复token并重放”,只有它确认已经重放过一次之后,才会把401以最终错误的形式抛给业务层。
3.3 关键代码:Token刷新与请求重放
响应侧最复杂的节点是B5。很多文章的写法都是“刷新token之后重新请求”,但真正落地时有两个关键坑:并发请求401会重复刷新;重放请求会再次触发401无限循环。
先看单飞刷新队列的实现:
let refreshPromise = null; function refreshToken() { if (!refreshPromise) { // 使用独立的authRequest实例,避免触发当前拦截器链 refreshPromise = authRequest .post('/auth/refresh', { refreshToken: getRefreshToken() }) .then((resp) => { saveTokens(resp.data.data); return resp.data.data; }) .finally(() => { refreshPromise = null; }); } return refreshPromise; }这个写法的好处是,10个请求同时401,也只会发起一次刷新接口调用。所有等待的请求共享同一个refreshPromise。
再看B5的完整逻辑:
async function authRefresh(response) { const config = response.config; const body = response.data; if (body && body.code === 401 && !config.__retried) { config.__retried = true; try { await refreshToken(); // 重放原请求,会重新走请求侧拦截器链,使用新token签名 return await api(config); } catch (error) { // 第二次仍然失败,直接把登录失效抛给业务层 throw new AuthExpiredError('登录已过期,请重新登录'); } } return response; }这里有个很容易忽略的点:重放时用的是api(config)而不是底层axios实例。只有走完整拦截器链,token注入节点才会读到新token,签名节点才会重新计算签名。如果直接调用底层axios,很可能带着旧token又发一次,照样401。
4. 拦截器之间的隐形数据传递与协作规则
4.1 利用config内部字段传递上下文
20个拦截器之间需要共享一些数据,比如本次请求的ID、加解密密钥、是否离线补发等。如果全部暴露在业务可见的配置项里,既杂乱又有被误改的风险。
我的做法是统一挂到config.__ctx下面。这个字段在拦截器链内部使用,业务层基本不感知。R1节点负责初始化,其他节点只往里面追加内容:
function contextInit(config) { config.__ctx = { requestId: generateRequestId(), startedAt: Date.now(), sourcePage: getCurrentPage(), aesKey: getAesKeyForApi(config.url), signKey: getSignKeyForApi(config.url), }; config.__internal = undefined; return config; }有一点需要注意:axios最终发出的请求体只关心data和params,自定义字段放在config里不会被序列化到请求体,所以用config.__ctx是安全的。不过如果你们的日志系统会完整打印config,建议在最后一个请求侧拦截器里把敏感字段删除:
function chainLog(config) { // 日志只保留需要的信息 const safeInfo = { url: config.url, method: config.method, requestId: config.__ctx.requestId, responseTime: Date.now() - config.__ctx.startedAt, }; // 清理内部字段,避免泄漏到外部日志 delete config.__internal; return config; }4.2 并发401时的单飞刷新队列
上一节的refreshPromise解决了“多个请求同时刷新token”的问题,但在分布式机器上还要注意刷新接口本身是否幂等。如果刷新接口返回的refreshToken是一次性的,并发使用同一个旧refreshToken去刷新,可能只有第一个请求成功。
前端侧能做的是把并发窗口尽量压扁。用一个共享的refreshPromise已经足够。但有一个隐藏问题:刷新失败后,refreshPromise变成rejected状态。如果其他请求此时也在等待同一个Promise,它们会同时收到异常。所以我在刷新失败的catch里做了一层降级:
function refreshToken() { if (!refreshPromise) { refreshPromise = authRequest .post('/auth/refresh', { refreshToken: getRefreshToken() }) .then((resp) => { const tokens = resp.data.data; saveTokens(tokens); return tokens; }) .catch((error) => { // 单飞失败,清空标记,让后续请求有机会再次尝试 refreshPromise = null; throw error; }); } return refreshPromise; }这样即便一次刷新失败,后续新的401请求还能再触发一次尝试,而不是僵死在一个失败的Promise上。
4.3 用开关和eject控制链路生效范围
并不是所有接口都需要走20个节点。比如登录接口本身不能依赖token注入,健康检查接口不需要签名,下载接口的响应不应该被解密。我设计了一个简单的“链路规则”配置:
const chainRules = [ { urls: ['/auth/login', '/auth/refresh'], skipRequest: ['injectToken'], skipResponse: ['authRefresh'] }, { urls: ['/file/download'], skipRequest: ['encryptSensitive'], skipResponse: ['decryptResponse'] }, ]; // 在对应拦截器开头判断是否需要跳过 async function injectToken(config) { if (matchRule(config.url, 'injectToken')) { return config; } config.headers['Authorization'] = `Bearer ${getToken()}`; return config; }除了在节点内部做开关,还可以用api.interceptors.request.use返回的id来动态移除拦截器:
const id = api.interceptors.request.use(encryptSensitive); // 某个账号异常时,临时移除加密逻辑 api.interceptors.request.eject(id);不过我个人不太推荐频繁eject,因为拦截器注册是全局的,某个请求临时移除会影响所有后续请求。开关配置在节点内部更可控,排查问题时也直观。
5. 链上的异常:错误传播、死循环与兜底设计
5.1 拦截器抛错后,后续节点为什么不执行
很多人在响应拦截器链里遇到一个怪现象:B2解密时抛错,结果B3、B4统统没有执行,直接到了业务层的catch。这就是promise链的特性——任何一个拦截器抛错,后续的fulfilled回调不会执行,但错误会继续向后传递,直到遇到rejected回调。
所以“统一错误处理”最好是放在每个可能抛错节点的rejected参数里,或者放在整条链的最外层。我在B4构造业务错误时,会把它封装成自定义错误类,并且如果需要后续脱敏或埋点继续执行,就不要用throw中断,而是返回一个带错误标记的对象:
async function codeDispatch(response) { const { code, message, data } = response.data; if (code === 0) { return response; } if (code === 401) { // 不中断,留给B5去刷新重放 return response; } // 业务失败:抛错,中断后续节点 throw new BusinessError({ code, message, data }); }可以看到,只有真正应该中断链路的情况才抛错。像401这类需要后续节点补救的情况,就放行。
5.2 错误分类与业务侧签约
20个拦截器链越复杂,越需要定义统一的错误类型。我定义了四种核心错误:
| 类型 | 含义 | 典型场景 |
|---|---|---|
| NetworkError | 网络层失败 | 超时、断网、CORS失败 |
| TimeoutError | 请求超时 | 超过设定时间无响应 |
| BusinessError | 业务失败 | 余额不足、库存不足 |
| AuthExpiredError | 登录失效 | token刷新失败、刷新后仍401 |
业务层只需要这么处理:
try { const data = await getUserList(params); } catch (error) { if (error instanceof TimeoutError) { // 提示重试 } else if (error instanceof AuthExpiredError) { // 跳转登录 } else if (error instanceof BusinessError) { // 展示error.message } else { // 通用兜底 } }这个约定的价值在于,业务层不再关心“status是401还是403”“code是10086还是500”。拦截器链已经把复杂性消化了。
5.3 重试风暴与超时降级
token重放最怕死循环。我用config.__retried保证最多重试一次,但还有一种隐蔽场景:刷新接口自身也走拦截器链。如果刷新接口返回401,B5会再次触发刷新,形成递归。解决办法是刷新请求用独立的authRequest实例,这个实例不注册authRefresh拦截器,或者通过URL白名单在B5里直接放行。
另一个和重试相关的坑是超时。刷新token本身需要时间,如果业务请求设置的timeout只有3秒,刷新过程中原请求已经超时,等B5拿到新token再重放,业务层可能早就放弃了。我的处理是,在B5收到401后,临时把config.timeout调整为15秒,重放完成后再恢复。虽然粗暴,但至少能让刷新流程走完。
async function authRefresh(response) { const config = response.config; if (body.code === 401 && !config.__retried) { config.__retried = true; config.__oldTimeout = config.timeout; config.timeout = 15000; try { await refreshToken(); const retried = await api(config); config.timeout = config.__oldTimeout; return retried; } catch (error) { config.timeout = config.__oldTimeout; throw new AuthExpiredError('登录已过期,请重新登录'); } } return response; }6. 给20节链条上保险:单测与链路测试
6.1 单拦截器的黑盒测试
拦截器本质是一个函数,输入config或response,输出新的config或response。所以单测非常直观。我习惯给每个拦截器建一个独立的测试文件,重点不只是“返回了什么”,还包括“没有改动不该碰的字段”。
比如测试R5 injectToken,我不会真的发请求,而是直接调用拦截器函数:
import { describe, it, expect } from 'vitest'; import { injectToken } from '../interceptors/request/injectToken'; describe('injectToken', () => { it('应该注入Authorization头', async () => { const config = { url: '/api/order/list', method: 'GET', headers: {}, __ctx: {}, }; const result = await injectToken(config); expect(result.headers['Authorization']).toBe('Bearer fake-token'); }); });测试的时候要把外部依赖mock掉,比如token管理器、加密函数、时钟等。否则签名里带了时间戳,断言就会不稳定。
6.2 用自定义adapter做整链集成测试
单测只能证明“每个节点单独工作正常”,无法证明“20个节点按顺序协作正常”。整链测试更好的手段是替换axios的adapter,用一个假的HTTP层来观察最终发出的请求和模拟返回的响应。
下面是一个简化示例:
import { api } from '../api'; const mockResponse = { status: 200, statusText: 'OK', headers: {}, config: {}, data: 'encrypted-fake-data', }; api.defaults.adapter = async (config) => { // 记录最终发出请求的参数,后续断言用 emit({ url: config.url, headers: config.headers, data: config.data }); return mockResponse; }; describe('拦截器整链', () => { it('请求侧按顺序执行并完成签名', async () => { const host = await api.get('/order/list', { params: { page: 1 }, }); // 断言发出的请求带有签名 expect(host.headers['X-Sign']).toBeDefined(); }); });如果要用整链测试验证401刷新重放,就在adapter里判断URL。第一次返回401,第二次返回正常数据,然后断言真实HTTP适配器只被调用了一次刷新接口。
6.3 三个让拦截器测试变脆弱的细节
第一个坑是config对象复用。多个测试共用一个config变量,上一个测试改过的header会残留到下一个测试里。解决办法是用一个工厂函数每次生成全新config。
第二个坑是时间戳和随机数。签名、nonce这类字段会变化,不要让断言期望具体值,而是断言“字段存在、格式正确”。必要时把随机数函数mock成固定值。
第三个坑是拦截器注册顺序被测试污染。如果每个测试文件都手动注册拦截器,切用例时可能会重复注册。我建议所有拦截器注册都收敛到一个统一函数里,测试环境只调用一次:
// setupInterceptorChain.js export function setupInterceptorChain(instance) { [...requestHooks].reverse().forEach((hook) => { instance.interceptors.request.use(hook); }); responseHooks.forEach((hook) => { instance.interceptors.response.use(hook); }); }测试里引入这个函数,就不会出现“A测试注册了一次,B测试又注册一次”的重复执行问题。
从那次线上事故到现在,这条20节点链路已经稳定跑了大半年。我最深的感受是,拦截器链的复杂度并不在于“单个拦截器怎么写”,而在于“20个拦截器如何在稳定顺序下互相协作”。请求侧用倒序注册还原逻辑顺序、响应侧用职责分离避免互相干扰、token刷新用单飞队列防止重试风暴,这三件事比任何单个拦截器的技巧都值钱。如果你正准备拆自己的“巨无霸拦截器”,建议先列一个逻辑顺序表,把每个节点要干的活写清楚,落到数组里,再倒序注册。这样在代码里看到的顺序就是真正执行的顺序,后续维护的人不会再被反直觉的注册模型坑到。