思路:底层用Map做全局缓存(为了幂等)+ 重试,表层用React-query做真正缓存
底层用 Map 做全局幂等(single-flight),中间加 可判定的重试(retry with backoff),表层用 react-query 做状态与缓存编排。
我们要解决的真实问题
-
并发加载:同一个远程 URL,在页面上可能被多个组件几乎同时请求。不能重复发请求,更不能出现“一半成功、一半失败”的状态撕裂。
-
脆弱网络:CDN 抖动、浏览器缓存污染、历史前进/后退与 importmap 的交互……需要“可以重试但不瞎重试”。
-
状态编排:React 侧要有 loading/error/cached 的统一语义,还要能长期缓存(URL 带版本 hash 时,等价于“永不过期”)。
-
契约约束:远程导出的组件可能带 schema。我们希望在入口就把 校验做了,防止“参数半错半对”把锅甩给下游 UI。
上面四条,分别由:Map 幂等 → Retry → react-query → AJV 校验 对应承接。
1) 底座:为什么用 Map
做全局幂等(single-flight)
幂等=相同输入只执行一次。在浏览器里,一个最自然的“幂等单元”就是 Promise。
于是我们在 window.__REMOTE_IMPORT_CACHE__
上挂了一个 Map<string, Promise<Module>>
:
-
Key 用“模块 URL”(一般包含版本哈希),天然把“版本”与“缓存”绑定起来;
-
Value 是一次真实的动态导入 Promise。并发时,其他请求直接复用这条 Promise;
-
全局作用域意味着跨组件、跨调用点都能复用,不用在每个地方写“去重逻辑”。
这样做的结果是:多处同时发起同一个 URL 的加载,只会真正 import 一次。其余地方等这一次结果就行。
这既避免了重复网络开销,也避免了“有的拿到了 A 实例,有的拿到了 B 实例”的诡异分叉。
设计取舍:为什么不是 WeakMap?
我们需要以 URL 字符串 为 key(不是对象),并希望缓存贯穿页面生命周期,WeakMap 不合适。内存回收交给上层(react-query 的gcTime
)做生命周期管理。
2) 重试应该“聪明”,不应该“执拗”
不是所有失败都值得重试。我们把错误分成两类:
-
结构性错误(不可重试):
-
export "X" not found
(说明导入契约不匹配) -
SyntaxError / Unexpected token
(产物坏了或被非 ESM 缓存覆盖) -
react family / version mismatch
(运行时不兼容)
这类错误,无论重试多少次也没意义——应立即把错误冒泡出来。
-
-
瞬时错误(可重试):
-
CDN 短暂 5xx;
-
浏览器缓存竞态;
-
importmap-shim 在历史前进/后退时解析抖一下。
这类错误,指数退避 + 抖动能明显提高成功率:例如1s → 2s → 4s
,并在每步加一点随机抖动,上限控制在 10s 以内。
-
把 错误判定 放在 底层 loader(而不是 react-query)里,有两个好处:
-
去重 + 重试可以绑在同一条 Promise 上(single-flight 的同伴们共享命运);
-
策略高度内聚,上层不用关心“哪些错能重试、哪些不能”。
3) 为什么还要等“平台就绪”(waitForReactGlobal
)
远程模块往往依赖运行时桥(如 React/JSX runtime、importmap-shim、宿主注入的服务等)。
竞态很常见:如果你比这些桥更早去 import 远程包,失败概率会直线上升(甚至落到不可重试的结构性失败)。
所以我在 loader 的第一步就做了就绪门禁:
-
先判断(flag/probe),比如全局变量与注入标记;
-
再监听(事件,如
react-ready
/ss-services:ready
); -
必要时加 rAF 兜底,并支持 超时/AbortSignal。
把这一步放在 loader 内部而非调用方,能保证“所有路径都走一遍门禁”,减少 80% 的玄学失败。
4) 动态导入与 CSP:为什么用 importShim
-
直接
new Function()
/eval
在很多站点会触发 CSP: unsafe-eval; -
原生
import()
在“浏览器后退 + importmap-shim”场景下偶尔解析不稳; -
因此我选了
importShim
(与 importmap-shim 配套)作为统一入口,让运行时解析规则更可控。 -
同时标注
/* webpackIgnore */ /* @vite-ignore */
,确保它真的是运行时加载,而不是被打包器吞掉。
5) react-query 只是“表层编排”,但它很关键
底层 loader 已经具备:幂等 + 重试 + 就绪门禁。
为什么还要 react-query?因为它擅长做状态与生命周期这件事:
-
缓存键:
['remoteComponent', url, exportName]
,与远程产物一一对应; -
staleTime: Infinity
:URL 通常带版本哈希,视为“永不过期”; -
gcTime
:没人订阅后,延迟回收这条查询,避免页面来回切换时反复拉取; -
retry: false
:底层已重试,表层不再二次重试,避免“叠罗汉”; -
refetchOnWindowFocus: false
:远程组件不该因为“切回标签页”就突然刷新; -
networkMode: 'always'
:不与浏览器的“离线/在线”状态耦合(交由底层策略判断)。
一句话:react-query 负责“把它当状态管理”——loading、error、data 的语义化,以及缓存生命周期的管理。
6) 契约守护:为什么做 schema 校验(AJV 2020)
远程导出的模块,既可以是一个 React 组件,也可以是一个 { component, schema }
的对象。
如果给了 schema
,我们就地做 JSON Schema 校验:
-
只要拿到 schema,就尝试对
validateProps
进行校验; -
WeakMap 缓存编译好的 validator(以 schema 对象地址为 key),避免重复编译;
-
严格模式(dev):有 schema 但没传 props,可抛错帮助发现问题;
-
生产环境降为警告/日志,不让线上 UI 崩溃。
这一步把“契约正确性”提前到了“加载阶段”,减少“UI 层模糊错误”。
7) 为什么不是 Context?为什么不是 Redux?
-
Context:跨页面、跨远程包时容易出现“实例不一致”——Provider 与 Consumer 不是同一个 Context 身份,值会丢。且 Context 是树形作用域,容易被多 root/portal 搞出边界问题。
-
Redux:强调可序列化状态与时间旅行。把“服务能力”(函数、类、DOM 对象)塞进去,既违背约定,也破坏 DevTools。
所以:服务用 Service Registry(全局单例),状态用 react-query/Redux,局部策略用 Context 覆写。各司其职,系统更稳定。
8) 失败模式与处理原则
-
export "X" not found
→ 契约不匹配,不要重试,尽快把可读的错误抛给上层; -
语法错误 / 版本冲突 → 产物或运行时不兼容,不要重试;
-
网络/解析瞬时失败 → 可重试,指数退避并设置上限;
-
平台未就绪 → 先判定、再监听;尽量别让它变成“重试类错误”。
错误越“可判定”,重试策略越“克制”。这是稳定系统的关键。
9) 观察性与排障(建议一起上)
-
每次加载记录:URL、
import.meta.url
、版本、时延、是否 retry、最终状态; -
就绪事件里带 detail:
version / provided / ts
; -
控制台仅在 dev 打印堆栈与 schema 误用,prod 输出简洁结构化日志;
-
为 loader 本身加一个小型“断路器”(一段时间内失败率爆表时短暂停止,避免雪崩)。
10) 小结:三层同心圆
-
内环(Loader):Map 幂等 + 智能 Retry + 就绪门禁(importShim + 事件 + rAF)
-
中环(Cache):react-query 负责状态与生命周期(stale/gc/订阅)
-
外环(Contract):可选 schema 校验,尽早暴露契约问题
这三层使得“远程组件”这件看似混沌的事,变得可预期、可恢复、可维护。