HTTP Client 与拦截器的边界
HTTP Client 与拦截器的边界
项目刚开始时,在组件里直接调用 fetch 往往就够了。接口多起来以后,相同的基础地址、请求头、超时和错误判断散落在各处,才会自然地想加一层请求封装。
问题通常出在封装继续变大之后。鉴权刷新、重试、缓存、弹窗、页面跳转和业务状态都被塞进同一个拦截器,调用代码看起来很短,实际行为却藏在一条越来越难追踪的管道里。
HTTP Client 的价值是收拢协议层的重复工作,不是替所有接口做决定。先划清这一点,后面的取消、错误和鉴权逻辑才不容易互相干扰。
请求代码可以分成几层
最底层是请求工具,例如浏览器原生的 fetch 或 Axios。它们负责发送 HTTP 请求,但默认行为并不完全相同。
再上一层是 HTTP Client。它统一基础地址、凭证、序列化、响应解析、超时和错误结构。这里处理的是“一个 HTTP 请求应该怎样发出和结束”。
业务 API 层则用领域语言描述操作,例如获取订单、更新用户资料。它知道接口路径和参数含义,可以把传输模型转换为应用实际使用的数据,但不应该让组件反复拼 URL 和猜状态码。
缓存、新鲜度、后台重新获取和请求去重属于服务端状态管理。它们可以建立在 HTTP Client 之上,却不是拦截器天然应该承担的职责。分层不需要追求形式上的目录数量,重点是不要让协议规则和页面行为混在一起。
HTTP Client 统一的是协议细节
基础地址、默认请求头、Cookie 策略和超时适合集中配置。查询参数怎样编码、日期如何传输、响应是否按 JSON 解析,也应该有稳定规则。这样业务 API 只需要描述这次调用与其他调用不同的部分。
不过,统一不等于假设所有接口都一样。上传 FormData 时,边界信息应交给运行时生成;下载文件或读取流式响应时,也不能先按 JSON 解析。204 No Content 没有响应体,如果解析器无条件调用 JSON 解析,成功响应反而会变成异常。
请求头也应按需设置。对没有请求体的读取请求强行添加 Content-Type 没有帮助,还可能触发额外的跨域预检。更合适的做法是让序列化方式决定请求头,而不是由全局配置覆盖所有请求。
拦截器是一条隐式管道
拦截器适合处理每个请求都需要经过的横切逻辑,例如附加短期凭证、请求 ID、语言信息,或者把不同来源的失败转换为统一错误。Axios 提供了请求和响应拦截器,其他请求库也常有类似扩展点。
它的麻烦在于行为不出现在调用处。一个请求可能先被改写,失败后被刷新令牌的逻辑重新发送,响应回来又经过多层转换。拦截器数量增加后,注册顺序、异常传播和共享状态都会影响结果。
因此,每个拦截器更适合只做一件事,并保持输入和输出明确。需要访问登录状态、日志器或追踪上下文时,通过创建 Client 时的依赖传入,比在内部随时读取全局 Store 更容易测试。动态注册的拦截器还要在不再使用时移除,避免开发热更新或重复挂载让同一逻辑执行多次。
统一错误,不要统一页面反应
网络断开、请求超时、主动取消、HTTP 错误和响应解析失败不是一回事。fetch 在收到 404 或 500 时仍会正常得到响应,需要主动检查状态;Axios 默认会把不符合成功条件的状态交给拒绝分支。HTTP Client 应先抹平这类工具差异,给上层一个稳定的错误结构。
错误结构可以保留类型、状态码、服务端错误码、请求 ID 和原始原因。业务层据此判断是提示字段错误、展示空状态,还是允许用户重试。拦截器直接弹 Toast 或跳转登录页看起来省事,却会让后台刷新、预加载和静默请求也产生界面副作用。
主动取消尤其不该被当成普通失败。用户切换页面或改变搜索条件时,旧请求已经失去意义;如果它仍进入全局错误提示和异常上报,只会制造噪声。
刷新令牌要处理并发
访问令牌过期时,响应拦截器可以尝试刷新凭证并重放原请求。更麻烦的是多个请求同时失败:如果每个请求都各自刷新一次,旧令牌可能被重复使用,新令牌也可能相互覆盖。
更可靠的做法是同一时间只保留一个刷新任务,其他失败请求等待它的结果。刷新成功后再各自重试,并标记请求已经重放过,避免新的 401 进入无限循环。刷新请求本身还应绕过这段逻辑,否则刷新接口失败也会再次触发刷新。
不是所有 401 都表示令牌过期,也不是所有请求都适合自动重放。登出、权限变化和服务端主动撤销凭证需要单独判断;创建订单、付款这类写操作还要确认服务端的幂等约束,不能仅凭一次认证失败就假设重复发送没有副作用。
取消和重试要能穿过所有层
浏览器通过 AbortController 表达取消,但只有组件、业务 API 和 HTTP Client 都继续传递同一个取消信号,它才能真的终止底层请求。Client 不应为了统一配置而把调用方传入的信号覆盖掉。
超时也可以建立在取消机制上,不过要保留原因,便于上层区分用户主动取消和等待超时。组件卸载、路由切换或新搜索替代旧搜索时,取消通常意味着结果已经无人需要,而不是网络出了问题。
重试同样不适合写成“失败就再发一次”的全局规则。只有瞬时网络故障、部分服务端错误和明确允许重试的请求,才适合结合退避策略重新发送。缓存、并发请求合并和更完整的重试边界放在缓存、重试与请求去重中讨论。
不同调用策略不要挤进一个实例
业务 API、文件服务和第三方接口可能使用不同的基础地址、凭证和错误格式。如果一个 Client 里到处根据 URL 判断该不该带令牌、怎样解析响应,规则很快会变成互相影响的条件分支。为不同安全边界创建独立实例通常更清楚。
服务端渲染环境还要避免修改全局实例上的用户凭证。进程会同时处理多个用户,请求级令牌如果写进共享默认配置,可能被另一个请求读到。Client 可以复用不含用户状态的配置,但身份信息应从当前服务端请求的上下文传入。
可观测性要跟着一次请求走到底
请求日志至少需要记录方法、目标、耗时、结果和请求 ID,重试时还要知道这是第几次尝试。服务端返回追踪标识后,HTTP Client 可以把它保留在错误对象中,让前端问题能够与后端日志对应起来。
日志不应原样记录授权头、Cookie、密码和完整响应数据。可观测性是为了还原请求经过了什么,不是把所有传输内容复制一份。
选择 fetch 还是 Axios,主要取决于运行环境和项目需要的便利能力。原生 fetch 已经足以覆盖很多场景,Axios 则提供实例、拦截器和较完整的默认封装。无论使用哪一个,请求层是否容易理解,最终仍取决于隐藏了多少行为,以及每一层能不能守住自己的边界。