去年下半年我们几个业务线陆续接入同一个微前端容器,前后端联调还没出大问题,倒是前端自己先“内讁”了:五个子应用,四套请求封装,有的是 axios 实例,有的是 fetch 包了一层,还有个小团队直接裸写 XMLHttpRequest。容器这边规范化了半天 UI 和路由,一碰到“发请求”这件事就各显神通。线上某个页面报 401,A 应用跳登录页,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 形态 A:props 注入 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 形态 C:npm 包 + 容器初始化
容器提供一个@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: Promise<string> | 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 | 不自行处理,容器统一刷新或跳登录 |
| 403 | 10003 | FORBIDDEN | 提示无权限 |
| 200 | 50001 | BIZ_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做一次受控包装。子应用直接调用它们也能注入基础 header(traceId、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、WebSocket
WebSocket 是长连接,生命周期跟普通 HTTP 完全不同,不适合塞进“请求-响应”模型里。容器能做到的是提供“建立连接时的统一鉴权头”,但连接之后的收发消息、心跳重连,就是另一个层面的问题了。硬收进来,公共逻辑反而会成为负担。
6.3 第三方域名的独立链路
对接第三方平台的接口,比如支付回调、第三方登录、地图服务,这些请求往往有独立的签名规则、独立的超时策略,跟容器内的后端网关完全是两套体系。统一收口会造成签名逻辑和容器逻辑互相干扰。第三方请求建议仍然由各子应用自己管理,只要保证请求里不透传敏感的容器 token 就行。
判断一个请求要不要进容器,可以看它三个特征:
- 是否走后端网关?
- 是否需要统一鉴权和错误处理?
- 是否需要参与灰度、埋点和 traceId。
三个都是“是”的才进。一旦有一个不是,就得考虑独立链路。
现在回看整个落地过程,我最大的体会是:通用请求的公共能力设计,第一版不要追求“完全统一”,先把鉴权、错误码归一、traceId 这三件事做扎实,后面再逐步扩大范围。如果一开始就想把所有请求场景都收进来,很容易陷入“上传、图片、第三方都各有各的特殊逻辑”的泥潭,标准反而立不起来。另外,它本质上是一份需要持续维护的内部规范,不只是某段代码——前后端联调规范、字段命名、错误码列表都要跟着它一起沉淀,才能真正从“能发请求”进化到“发好请求”。