微前端通用请求层设计:从鉴权到错误码归一的标准实践
去年下半年我们几个业务线陆续接入同一个微前端容器前后端联调还没出大问题倒是前端自己先“内讁”了五个子应用四套请求封装有的是 axios 实例有的是 fetch 包了一层还有个小团队直接裸写 XMLHttpRequest。容器这边规范化了半天 UI 和路由一碰到“发请求”这件事就各显神通。线上某个页面报 401A 应用跳登录页B 应用弹了个英文报错C 应用什么都不做用户卡在空白页干瞪眼。后来我们专门把“通用请求”当作容器公共能力的第一优先级来标准化才把这块彻底理顺。这篇文章就围绕这个方向展开记录一下我实际设计、落地、踩坑之后的完整思路。“通用请求”这四个字听起来简单本质上是把子应用与后端之间的所有 HTTP 交互收口到容器统一管理统一注入鉴权信息、统一处理错误码、统一挂埋点、统一灰度参数、统一 traceId。它解决的不只是代码复用问题更是线上问题排查效率问题。适合正在做微前端容器、或者准备设计公共能力的前端同学参考也可以当作一份“请求层标准化”的 checklist 来用。1. 微前端里的请求层为什么会变成一座孤岛1.1 从一次线上问题说起token 过期后的“各显神通”当时线上出了一个典型的故障登录态过期之后用户触发了一个批量操作一瞬间发出去七八个请求全部返回 401。正常的做法是统一刷新 token刷新成功之后把在途请求重放。但实际上发生了什么A 应用在 axios 拦截器里自己写了刷新逻辑用的是单例 promise勉强没出大事B 应用只在响应拦截器里做了一个window.location.href /login结果用户正在填写的一个表单直接丢了C 应用什么都没做报错一直堆在 console 里用户看到一个永远转圈的按钮。同一个容器里同一个登录态失效三种完全不同的用户体验。这不是子应用团队水平的问题是容器压根没有给它们提供“统一的处理入口”。类似的问题还有有的团队把服务端业务码10001当成登录失效有的团队认为只有 HTTP 401 才算两边对“登录失效”的定义都不同。底层都通到一个后端网关网关没变变的是各个团队自己的理解。所以请求层标准化最重要的不是“少写点代码”而是“把容易产生分叉的决策点全部集中到一个地方”。1.2 公共能力到底该管到哪一层如果你去翻微前端相关文章很多人会告诉你“子应用不需要关心请求直接用容器提供的 request 方法就行”。但真做起来有一个边界问题通用请求到底管到哪一层我自己的切分方式是这样的把请求能力分成两层。第一层是传输层也就是真正发 HTTP 请求的fetch/xhr这一层不要收走。第二层是业务编排层包括鉴权注入、错误处理、降级、埋点、灰度参数、traceId 串联这一层必须收口到容器。为什么传输层不收因为子应用本地开发的时候是独立运行的它直接请求 dev 环境接口并没有经过容器。如果传输层也被容器接管本地开发就变成“必须先把容器跑起来”这对一个专注业务的子应用团队来说是很大的负担。业务编排层收口意味着子应用只要调用容器提供的request函数传输层用什么、怎么发子应用不用关心子应用本地开发时直接用一个本地 mock 或者轻量封装的 fetch 也能跑只是少了鉴权、埋点这些能力不影响功能开发。这套边界确认之后后续所有设计就有了一个基本原则容器管业务语义不管传输细节。2. 通用请求的四种供给形态对比确定了“要收口业务编排层”之后下一个问题是这个能力以什么形态交给子应用我梳理了市场上常见的做法也自己试验过几种列出来对比一下。2.1 形态 Aprops 注入 request 工厂函数主应用在注册子应用时通过 props 把一个request对象或者工厂函数传给子应用。子应用在 bootstrap/mount 阶段接收这个 props存到自己的模块里后续发请求都调它。这是实现成本最低的做法也非常直观。生命周期里传参数本来就是微前端框架的标准能力。但有个问题子应用内部可能有很多模块不能保证每个模块都能方便地拿到 props。比较常见的解法是子应用在入口处把 request 挂到自己内部的一个全局对象上变相实现“模块内随时可用”。这么做可以但等于把“容器标准化”的压力又推回了一部分给子应用。2.2 形态 B沙箱全局变量容器启动时往 window 上挂一个window.__CONTAINER_REQUEST__子应用直接window.__CONTAINER_REQUEST__.get(...)。这个做法最直接子应用接入成本几乎为零。但问题也非常明显沙箱隔离被绕过了。很多微前端方案会做 window 代理隔离你直接塞一个全局变量子应用拿到的可能是被代理后的 window也可能因为 strict mode 报错或者变量丢失。另外变量命名冲突、类型定义缺失、调试不直观这些都是潜在坑。我自己的观点是这个形态只适合“快速给一个老旧子应用打补丁”不适合作为长期标准。2.3 形态 Cnpm 包 容器初始化容器提供一个container/request这样的 npm 包里面暴露一个initRequest(config)方法。主应用启动时调用initRequest把容器上的鉴权、埋点等公共逻辑注册进去子应用安装同一个包直接调用它暴露的request方法。这个形态的好处是子应用在本地开发时也能用因为 npm 包装完即用不需要依赖容器是否启动。加上 TypeScript 类型很好维护是最接近“标准 SDK”的做法。代价是npm 包升级时需要子应用同步发版版本管理上要做一点约束。如果容器能力和子应用版本差异过大可能出现行为不一致。2.4 形态 D自定义事件桥接主应用监听container:request自定义事件子应用通过 dispatchEvent 发起请求。因为有事件机制存在可以做到完全解耦主应用和子应用都不需要互相感知。但实际体验非常糟糕请求是异步的事件监听没法直接拿到返回值你必须再监听一个container:request-response事件并且带上 requestId 做关联。这套链路写起来繁琐错误处理也不直观Promise 语义完全丢失。除非某个场景必须跨应用解耦而且不能用 npm 包否则我不建议碰这个形态。2.5 四种形态的对比与最终选择对比维度props 注入沙箱全局变量npm 包初始化自定义事件子应用接入成本低最低中高本地开发可用否视沙箱而定是是类型支持弱无强无行为统一保证中低高中版本管理无需无需需要无需推荐度备选不推荐首选不推荐最终我们采用的是 C 为主、A 为辅。npm 包负责提供能力props 只用来传递一些“容器运行时才确定的动态配置”比如当前用户的基础信息、当前环境的灰度标签。这样既保证了本地开发体验也保住了运行时动态性。3. 请求生命周期中的公共钩子设计形态确定之后真正核心的设计就是请求的生命周期。一个请求从发起到结束中间要经过哪些公共处理我把完整链路拆成了四段请求前预处理、鉴权注入与刷新竞争、响应错误码归一、降级与重试。3.1 请求前预处理请求配置补全和 meta 注入子应用调用request({ url, method, data })时容器内部先做拦截器链的“请求前”阶段。这个阶段要干的活有补全公共 query 参数、注入当前环境信息、改写 baseURL。比较容易被忽略的一点是“请求上下文”。比如一个请求是从哪个子应用发出来的、当前路由是什么、用户当前所属的灰度分组是什么这些信息应该在请求发出之前就塞进配置里而不是等后端查不到日志再来补。我们用了一个meta字段请求前统一合并interface RequestMeta { appName: string; routePath: string; grayTag?: string; traceId: string; } function beforeRequest(config: RequestConfig): RequestConfig { const meta: RequestMeta { appName: getCurrentAppName(), routePath: getCurrentRoute(), grayTag: getGrayTag(), traceId: generateTraceId(), }; return { ...config, meta, headers: { ...config.headers, X-App-Name: meta.appName, X-Trace-Id: meta.traceId, X-Gray-Tag: meta.grayTag || , }, }; }这样做的直接好处是后端联调的时候不用反复问“这个请求是哪个页面发的”traceId 一查就能定位到完整的调用链。算是投入产出比非常高的一环。3.2 登录态注入与刷新竞争在途请求队列的解法请求前预处理的另一半是鉴权信息注入。常规做法是从容器内存里取 token放到Authorization头里。只要容器和登录态是统一维护的这一步就不复杂。真正复杂的是 401 之后的刷新竞争。场景还是开头那个用户在一个页面里触发批量操作并发 8 个请求全部带着过期 token 发出去后端统一返回 401。如果每个请求都各自去调 refresh 接口那就会产生 8 次刷新请求而且可能刷新 token 的接口自己也带旧 token越刷越乱。解法是在容器内部维护一个“单例刷新任务 在途等待队列”第一个请求收到 401发现当前没有刷新任务就创建刷新 promise并把当前请求挂到等待队列后续请求收到 401发现刷新任务已存在就不重新发起刷新只把自己的回调挂到等待队列刷新完成后统一用新 token 重放等待队列里的所有请求刷新失败则清空队列统一触发登录失效逻辑。这个机制用代码描述是这样let refreshTask: Promisestring | null null; const pendingQueue: Array() void []; async function handleAuthError(response: Response, config: RequestConfig) { if (response.status ! 401) return response; if (!refreshTask) { refreshTask doRefreshToken() .then((newToken) { setToken(newToken); return newToken; }) .finally(() { refreshTask null; }); } return new Promise((resolve, reject) { pendingQueue.push(() { config.headers.Authorization Bearer ${getToken()}; resolve(doRequest(config)); }); refreshTask?.catch((err) { pendingQueue.length 0; handleLoginExpired(); reject(err); }); }); }注意一个细节请求重放时仍然要走到最外层的request方法确保重新走一遍钩子链而不是直接拿着旧配置再发一次。有些人在这一步偷懒直接重新 fetch结果新 token 也没进去埋点也没补。这个机制我建议随请求封装内置不要交给子应用自己处理。因为只要有一个子应用处理方式不一样用户的体验立刻就不一致。3.3 错误码归一把服务端错误翻译成业务错误后端接口报错返回的信息千奇百怪HTTP 状态码有 200 但 body 里带code: 50001的有 HTTP 直接 500 的有网关层 504 的有业务自定义 403 的。如果子应用各自解析迟早会出现同一个错误两种文案的情况。容器做的事是把这些全部翻译成统一的错误类型class ContainerRequestError extends Error { constructor( public errorType: AUTH_EXPIRED | FORBIDDEN | NOT_FOUND | SERVER_ERROR | NETWORK_ERROR | BIZ_ERROR, public code: string | number, public message: string, public raw?: unknown ) { super(message); } }翻译规则放在容器内部统一维护子应用只需要instanceof判断类型或者查errorType字段。比如HTTP 状态服务端业务码统一 errorType子应用应对话术401任意AUTH_EXPIRED不自行处理容器统一刷新或跳登录40310003FORBIDDEN提示无权限20050001BIZ_ERROR按 code 挂对应业务文案504任意NETWORK_ERROR提示网络异常请重试子应用业务代码里只需要关心业务码50001这种“需要前端给出差异化交互”的情况其余公共错误直接交给容器弹全局提示。这么一收后面的排查成本会低很多至少不会出现“同一个错五个应用五句话”的尴尬。3.4 降级与重试公共逻辑里最容易被忽略的部分再往后的一个钩子是降级和重试。这个经常被忽略但线上出问题的时候特别有用。比如后端某个接口超时了容器可以在请求前做一次“站点模式判断”如果当前处于维护态或者降级态直接返回降级数据根本不发请求。再比如“接口超时自动重试一次”这个逻辑如果每个子应用自己写很容易写得不一致有的重试三次有的重试零次。我建议容器提供三种基础能力超时默认值统一为 10 秒特殊接口通过配置调整幂等 GET 请求支持一次性自动重试非幂等 POST 不自动重试全局维护开关一旦开启“全站只读数源”所有非必须写操作直接返回失败而不发请求。这些逻辑放在容器里最大的意义不是省代码而是“站点被限流时你可以通过一个开关快速止血”不用逼着十几个子应用连夜发版。4. 从“能发请求”到“发好请求”治理、灰度与可观测4.1 链路标识 traceId让跨应用排查不再靠猜前面提到请求前预处理会生成 traceId这一节单独展开说一下用法。单应用时代排查问题看后端日志就能定位到具体请求。但微前端下一个用户操作可能先经过 A 应用A 应用调用 B 应用提供的一个跨应用接口B 应用又向后端发请求。如果没有一个贯穿始终的 traceId排查的时候就要靠“时间 用户 接口路径”去猜效率极低。容器的做法是统一生成 traceId并保证它在同一个用户操作链路里不变。实现上不复杂用户点击操作时会触发一个“会话内 traceId”或者每次进入一个业务流程时生成一个新的 traceId后续所有子应用的请求都复用同一个。子应用之间互相调用接口时也把 traceId 放在请求头里传递下去。这样一来后端日志、网关日志、前端上报日志三边都能通过同一个 traceId 关联起来。排查问题从“大海捞针”变成“直接按图索骥”。这个能力如果不放在通用请求里靠各子应用自己约定 header 名称大概率是约不齐的。4.2 灰度实验分流参数注入业务不用各自感知微前端容器往往承载着灰度发布和实验分流的能力。同一个接口A 用户走新逻辑B 用户走旧逻辑后端需要前端在请求里带上分组标识。这个标识哪里来应该是容器从灰度配置中心拉取后注入到每个请求的 header 里而不是让每个子应用自己去查灰度结果。实际做的时候容器启动时会先拉取当前用户所属的灰度分组配置缓存到内存。请求发出前把分组 ID 放进X-Gray-Tag头。如果配置更新了容器负责刷新缓存。子应用全程无感只是在联调遇到“为什么这个用户看到的接口数据不一样”时跟后端对一下X-Gray-Tag就能澄清问题。4.3 公共埋点与采样上报数据统一才能对比微前端下做埋点很容易出现重复统计或者统计口径不一致。有的子应用上报api_success有的上报api_ok数据分析的同学合并数据时头都大了。通用请求能把“所有子应用的接口请求日志”统一走一条上报通道。上报内容包括应用名、接口路径、HTTP 状态码、耗时、是否命中缓存、是否降级、traceId。上报时机放在请求 finalized 阶段无论成功失败都上报。需要注意的是采样率如果全量上报量会很恐怖我们按 10% 采样错误请求则 100% 上报。既控制了成本又不丢关键错误信息。5. 落地过程中的典型坑与解法5.1 沙箱逃逸子应用绕过容器自己发请求容器做得再好也架不住子应用“不走门”。最常见的情况是子应用为了图省事直接在代码里fetch(/api/xxx)绕过了容器注入的所有公共逻辑。带来的后果是没有 traceId没有灰度参数没有统一错误处理后端排查的时候这个请求在日志里就是“孤儿请求”。怎么解决我是用“堵 疏”结合的方式。堵容器在初始化时给当前 window 下的fetch和XMLHttpRequest做一次受控包装。子应用直接调用它们也能注入基础 headertraceId、appName但不经过完整业务编排。疏在规范文档里明确要求“业务请求必须走容器 request”并提供一个质检工具构建时扫描代码里是否有裸fetch(调用有就告警。前端代码不可能百分百拦住但至少要把问题暴露出来。5.2 事件监听器泄漏导致的内存问题和重复请求如果用了自定义事件哪怕只作为辅助一定要在子应用卸载时移除监听。我们刚开始踩过一次主应用监听了子应用发来的某个事件子应用卸载之后监听器没清结果下一次子应用重新挂载时主应用又加了一个新的监听器同一事件被触发两次请求自然就发了两遍。这个坑对 props 注入形态也一样成立只是没那么隐蔽子应用每次 mount 时如果都在模块级导出的对象上挂 request重复挂载也不会产生重复监听风险小很多。但事件桥接类方案就是重灾区。建议代码评审时专门检查生命周期里的 addEventListener 有没有对应的 removeEventListener。5.3 本地开发与线上行为的割裂npm 包形态下子应用本地开发时容器并不在场没法提供运行时动态配置。我们的做法是请求模块区分container和standalone两种运行模式。container模式请求配置从容器 props 读取走完整钩子链standalone模式子应用本地跑用本地配置文件里的 mock token、mock 灰度标签简单请求到 dev 环境接口即可。这个区分通过入口处的一个环境变量来决定子应用团队不感知具体逻辑。但要想清楚standalone 模式毕竟没有容器提供的重试、降级、埋点等能力。如果子应用在本地开发时发现“明明接口报错但页面没反应”八成就是绕过容器之后缺了错误处理遇到这种情况建议直接联调环境排查。5.4 公共配置缓存更新不及时容器的请求模块会缓存用户的灰度分组、token 等配置。缓存更新不及时会造成一种很诡异的现象用户明明已经重新登录了但是请求头里还是旧 token导致所有请求持续 401。解决思路是“缓存失效要广播”。容器这边一旦发现登录态变化比如刷新成功、用户切换要主动通知所有子应用的请求模块清掉对应缓存。因为同一时刻可能多个子应用都持有 token 副本必须有一个清晰的刷新事件让所有副本同步失效。这个事件用 npm 包内部自带的消息机制做就行不需要暴露到全局。6. 什么请求不该放进容器通用请求不是万能的也不是所有请求都适合收口。这个边界我和很多团队聊过大家比较容易“收得太过”。列一下我不建议放进容器的几类请求。6.1 大文件上传与分片进度上传大文件通常需要精细的进度控制、分片逻辑、断点续传。这类请求如果也走通用请求很容易被默认超时拦截掉或者因为统一错误处理把“上传中断”弹成全屏报错。我们的做法是文件上传单独做一个上传组件/工具放在公共能力包里但由子应用直接独立调用不走通用请求的钩子链。6.2 实时/流式连接SSE、WebSocketWebSocket 是长连接生命周期跟普通 HTTP 完全不同不适合塞进“请求-响应”模型里。容器能做到的是提供“建立连接时的统一鉴权头”但连接之后的收发消息、心跳重连就是另一个层面的问题了。硬收进来公共逻辑反而会成为负担。6.3 第三方域名的独立链路对接第三方平台的接口比如支付回调、第三方登录、地图服务这些请求往往有独立的签名规则、独立的超时策略跟容器内的后端网关完全是两套体系。统一收口会造成签名逻辑和容器逻辑互相干扰。第三方请求建议仍然由各子应用自己管理只要保证请求里不透传敏感的容器 token 就行。判断一个请求要不要进容器可以看它三个特征是否走后端网关是否需要统一鉴权和错误处理是否需要参与灰度、埋点和 traceId。三个都是“是”的才进。一旦有一个不是就得考虑独立链路。现在回看整个落地过程我最大的体会是通用请求的公共能力设计第一版不要追求“完全统一”先把鉴权、错误码归一、traceId 这三件事做扎实后面再逐步扩大范围。如果一开始就想把所有请求场景都收进来很容易陷入“上传、图片、第三方都各有各的特殊逻辑”的泥潭标准反而立不起来。另外它本质上是一份需要持续维护的内部规范不只是某段代码——前后端联调规范、字段命名、错误码列表都要跟着它一起沉淀才能真正从“能发请求”进化到“发好请求”。