一、技术难点
Day24 讲的是怎么把子应用挂上去——沙箱怎么造、样式怎么隔离、路由怎么接管。这一篇往上走一层:挂上去之后,六个团队按各自的节奏发布。
微前端的地基不是 single-spa 的 registerApplication,也不是 qiankun 的 activeRule。它是三张表:
- 路由所有权表 —— 一段 URL 空间属于谁,归属必须唯一,且与装配顺序无关;
- 契约兼容表 —— 谁提供什么、谁消费、兼容窗口多宽、这次变更到底破不破;
- 共享依赖单例表 —— 哪些库必须单实例、单实例的口径是"版本一致"还是"模块实例一致"。
三张表任何一张错了,症状都不在表里:第一张错了表现成"白屏"或"这个页面被另一个应用渲染了";第二张错了表现成"某次发布之后某个页面崩了";第三张错了表现成 Invalid hook call、插件注册失效、缓存分裂。全是只在别人的发布里、只在部分用户上出现的现象。
所以我不用任何第三方依赖,从零写了一套最小架构治理工具链(都在 day37-assets/):routes.mjs(路由所有权 / 重叠检测 / 顺序无关仲裁 / 死路由与影子路由枚举)、contract.mjs(schema 差分 / 版本号判据对照 / 发布组合矩阵)、deps.mjs + semver.mjs(单例契约 / 最小实例划分)、compose.mjs(装载拓扑 / 故障隔离 / 超时预算)、health.mjs(拆分维度量化 / 迁移阶段判据)、gate.mjs(把判据做成 CI 门禁),配 test-day37.mjs(44 条断言全绿,含真实 ESM 双实例探针)和 bench.mjs(7 组实测)。环境:Node v22.23.3 / V8 12.4.254.21-node.57 / darwin-arm64。
先把结论摆出来。读之前先说清口径:计数类结论是确定性的(重跑逐字节一致,我用 diff 验过);耗时类数字是单次实测,同机重跑抖动 ±1020%(120121ms、5.7~6.0x 这类,看量级别看小数点)。
| 流传的说法 | 实测结果 | 差在哪 |
|---|---|---|
| 「路由谁先注册谁生效,配一下就好」 | 6 种装配顺序 × 16 个 URL:会因顺序改变归属的 URL 2 个 | 归属变成了部署脚本/网络顺序的函数,同一 URL 在不同用户那里归不同应用 |
| 「最长前缀匹配就能仲裁一切」 | 两个应用都精确声明 /trade/legacy:最长前缀也仲裁不了,歧义 1 处 | 相同具体度没有胜负,必须有显式兜底规则 |
| 「URL 没人认领就 404,至少是个错」 | 死路由 4/16(/member、/settle、/report、/ops——只声明了 /x/* 没声明 /x) | /* 通配至少吃一段,裸前缀谁都不认领,静默空白 |
| 「每个页面都有归属就安全」 | 影子路由 3 处:归属指向本次未部署的应用 → 白屏 | 所有权表和部署矩阵是两张表,缺回落表就白屏 |
| 「版本号 major 就是破坏性变更」 | 30 次真实变更:命中 7 / 漏报 8 / 误报 2,召回 46.7%、精确 77.8%、准确 66.7% | 版本号由人打,兼容性由结构决定,两个独立的东西被当成一个 |
| 「新增可选字段一定安全」 | schema 差分 30/30 与构造标签一致,但另有 8 处严格消费者警告 | 对"字段名穷举"的消费者(zod .strict()、exhaustive switch)新增字段就是破坏 |
| 「应用版本一致就兼容」 | 8 次应用发布只有 2 次动了契约,用应用版本判会多喊 6 次升级 | 狼来了 6 次之后,真升级也没人看 |
| 「共享依赖取最高版本给一份」 | react 全局给 18.2.0:1 个应用破约(ops 声明 ^17.0.2);最小实例划分需要 2 个实例 | 单例不是"选个版本",是"承认双实例存在并把它关进一个应用" |
| 「并行装配就快了」 | 一次子应用加载失败:Promise.all 可用区 0.00/6;挂起时 900ms 后仍未返回 → 整页白屏 | 并行把可用性变成乘积,不是和 |
| 「挂起加个 timeout 就行」 | 边界隔离 + 120ms 预算:120~121ms 出骨架,可用区 5/6 | 降级要落到具体区域,否则只是把白屏换成整页骨架 |
| 「串行装更保险」 | 6 个子应用各 40ms:串行 246~252ms vs 并行 41~43ms(≈5.7~6.0x) | 保险的代价是 Σ latency,而且它并没有更保险 |
| 「按技术栈拆粒度更整齐」 | 40 页面 / 120 条调用:按域拆跨边界 37.5%(8 个应用);按栈拆 85.0%(4 个应用) | 应用数少 2.25x,跨界调用多 2.3x——"团队少=耦合少"是错觉 |
| 「我们已经在跑微前端了」 | 判据检查:S0 ✅ S1 ✅ S2 ✗(独立发布/降级不全绿) | 能挂上去 ≠ 到了运行时并行阶段 |
1.1 难点一:路由所有权,是"先到先得"的随机函数
绝大多数接入方式是"每个应用注册一组 activeRule,第一个命中的赢":
// 朴素实现:第一个命中的赢(qiankun / 手写 activeRule 的常见写法)
export function resolveNaive(table, path) {
for (const row of table) {
if (match(row.route, path)) return row.app;
}
return null;
}
table 的顺序就是 registerApplication 的调用顺序,而调用顺序取决于子应用的 JS 谁先下载完。我把 6 个应用的注册顺序做了 6 种轮转,跑 16 个探针 URL:
E1.3 6 种装配顺序 × 16 个 URL:顺序会改变归属的 URL 2 个
· /trade/legacy → shell / ops
· /report/export → settle / report
两个 URL 的归属不稳定。注意这不是"两个应用都声明了同一路由"的简单重复——而是真实项目里最常见的两种写法冲突:基座里留了一个老页面的 shim(shell 认领 /trade/legacy),业务应用后来也把这个页面收编了(ops 认领同一个路径);报表域同理,settle 把导出页挂在了 /report/export,而 report 用 /report/* 覆盖整个域。
于是同一个 URL,用户 A 打开是基座的老页面,用户 B 打开是新页面,取决于哪个应用的 chunk 先到。这类 bug 的复现成本极高,因为它在开发机上永远稳定(本地两个应用顺序固定)。
1.2 难点二:死路由和影子路由,两种不同来源的白屏
白屏经常被归成同一类问题,实际上是两个方向的漏:
死路由——声明缺失,谁都不认领。探针 16 个 URL 里有 4 个:
E1.4 死路由(声明缺失,谁都不认领):4/16 → /member, /settle, /report, /ops
根因是 '*' 通配的语义:/member/* 要求至少再吃一段,所以 /member 本身不命中。业务同学写 activeRule: '/member' 时心里想的是"整个会员域",写进配置的却是"会员域下的所有子路径"。/report、/settle、/ops 全中这个坑。
影子路由——归属正确,但那个应用这次没部署:
E1.6 影子路由(归属指向本次未部署的应用 → 白屏):3
· /settle/report 归 settle(未部署)→ 回落 report
· /report/export 归 settle(未部署)→ 回落 report
· /ops/audit 归 ops(未部署)→ 回落 shell
这次部署的是 shell / trade / member / report 四个,settle、ops 缺席。所有权表说"/settle/* 归 settle"是对的——错的是没人回答"settle 没部署的时候这个 URL 怎么办"。这就是为什么必须有一张 FALLBACK 表,它不属于任何应用,属于架构:
// 显式回落:未部署的子应用,其路由应该回落到谁(架构上必须写下来的那张表)
export const FALLBACK = { settle: 'report', ops: 'shell' };
漏了这张表,白屏就变成了"只有在那次发布、那个区域、那个入口"才出现的问题。
1.3 难点三:契约的"破坏性"不是版本号能表达的
跨应用契约(trade-api 之类的 BFF 数据形状、跨应用 props、事件 payload)最常见的治理方式,是让提供方按 semver 打版本号,消费方按 range 依赖。听上去很工程化,问题在判据:"这次变更破不破"和"这次版本号怎么 bump"是两个人、两件事。
我构造了 30 次契约变更(13 破坏 / 17 兼容,标签由"构造方式"决定,跟差分实现无关),每次给一个"人打的版本号"(其中 6 次破坏性变更只 bump 了 minor/patch,2 次纯新增却 bump 了 major),然后用两种判据去判:
E2.2 "major 就是破坏性"判据:命中 7 / 漏报 8 / 误报 2 / 正确判兼容 13
召回 46.7%,精确 77.8%,准确 66.7%
· 漏报(破坏性却只 bump minor):member: 删掉 nickname
· 漏报(破坏性却只 bump minor):member: level 枚举收窄
· 漏报(破坏性却只 bump minor):member: 新增必填 phone
· 漏报(破坏性却只 bump patch):member: nickname 变成必填
· 漏报(破坏性却只 bump minor):order: amount 改 string
· 漏报(破坏性却只 bump patch):order: status 收窄
· 误报(仅新增兼容内容却 bump major):member: 新增枚举 shape
· 误报(仅新增兼容内容却 bump major):member: 加两个可选字段
E2.3 schema 差分判据:30/30 与构造标签一致;另有 8 处"新增可选字段"警告
漏报 8 次里有 6 次是同一类错觉:"我只是加了个字段"。但"新增必填字段"对每个消费方都是运行时报错;"枚举收窄"(status 从 created|paid|closed 变 created|paid)会让消费方 switch 落空;**"可选变必填"**同样。这些都不是"新功能",都是"接口换了"。
误报同样有害:两个只加可选字段的变更加了 major,所有消费方被要求排期升级——6 次假警报(见 E3.2)之后,真的破坏性变更也没人当回事了。这是治理系统最典型的失效方式:不是判错一次,而是让判据失去信任。
还有一层更细的:schema 差分虽然 30/30,但它自己吐了 8 条警告——added-optional-property 对"穷举字段名"的消费者是破坏性的(zod 的 .strict()、对象形状断言、TS 的 exhaustive switch)。所以正确答案不是"新增可选一定安全",而是"默认兼容,但对严格消费者报警"。
1.4 难点四:共享依赖,单例是契约不是配置
react 这类库必须单实例,理由不是"省体积",而是模块级状态必须唯一:两份 React → hook 的 dispatcher 分裂;两份 mitt → 事件总线分裂;两份自己写的 store → 两份状态。这是 Day36 已经验证过的结论(模块实例判据),在微前端里它以另一种方式回归:monorepo 的依赖去重救不了独立部署。
子应用各自打包、各自部署,天然带自己的 copy。工具链里最流行的做法是"取所有声明里最高的版本,全局只给一份":
// 策略一(流行做法):取所有声明中最高的下界,全局只给一份
export function globalHighest(apps, lib) {
const specs = apps.filter((a) => a.shared && lib in a.shared)
.map((a) => ({ app: a.name, spec: a.shared[lib] }));
if (specs.length === 0) return { version: null, rows: [], broken: [] };
specs.sort((x, y) => cmp(rangeOf(y.spec).lo, rangeOf(x.spec).lo));
const version = rangeOf(specs[0].spec).lo; // 最高下界
const rows = specs.map((s) => ({ ...s, ok: satisfies(version, s.spec) }));
return { version, rows, broken: rows.filter((r) => !r.ok) };
}
实测(6 个应用,ops 还在 react@^17):
E4 react:全局最高版本一份 → 18.2.0,违反声明的应用 1 个 ops(^17.0.2)
最小实例划分 → 2 个实例:17.0.2[ops] , 18.2.0[shell+trade+member+settle+report]
E4 design-system:全局最高版本一份 → 3.4.0,违反声明的应用 0 个
最小实例划分 → 2 个实例:3.1.0[shell+trade+settle+ops] , 3.4.0[member]
E4.singleton react(6 个使用方):冲突 5 对
E4.inst 两个模块实例(真实 ESM):A 侧状态 3,B 侧 0;跨实例 instanceof false
三个结论:
- "取最高版本"是一种静默的违约。它嘴上说"给你 18.2.0",实际把
ops的^17.0.2破了——而ops里ReactDOM.render的调用在新版本上只是警告,不会崩,所以这个问题会以"偶发 UI 不更新"的形式活很久。 - 最小实例划分的答案是"允许 2 个实例",然后把它们关进具体应用:
ops留在 17,其余 5 个用 18。注意minInstances的口径:实例 = 一个具体版本(witness被钉住),所以即使^3.1.0与^3.4.0区间相交,先钉在 3.1.0 上就没法同时服务^3.4.0的成员,只能再开一个。这恰恰是 CDN / import-map 的真实语义——你指定的是一个 URL、一个版本。 - 单例冲突要按"对"报,不按"库"报。
react5 对冲突里只有ops是原因,但报告要给全 5 对,因为每一对都是一个迟早要处理的升级工单。
E4.inst 那一行是用 import('./singleton-mod.mjs?build=A') 与 ?build=B 模拟"两个子应用各自打包了同一份源码":同一个文件,两个 URL,两个模块实例,状态 3 vs 0,跨实例 instanceof 为 false。这就是 hook 报错背后的物理事实。
1.5 难点五:故障隔离,Promise.all 把可用性变成乘积
装 6 个区域,最直觉的写法:
export async function composeNaive(apps, behaviors) {
const results = await Promise.all(apps.map((a) => loadOne(a, behaviors[a.name])));
return { usable: results.length, failed: [], mounted: results.map((r) => r.app) };
}
60 次实验,每次注入 1 个子应用加载失败(CDN 抖动、entry 404、chunk 版本错位):
E5.1 60 次实验,每次注入 1 个子应用加载失败:
Promise.all 平均可用区 0.00/6,边界隔离平均可用区 5.00/6
E5.2 有一个子应用接口挂起(模拟 5s 无响应):
边界隔离 + 120ms 预算 → 120~121ms 出骨架,可用区 5/6,降级区 report
Promise.all 在 900ms 后仍未返回(still-hanging)→ 整页白屏
可用区 0.00 是必然的:Promise.all 是全或无,任何一个 reject 就整页 throw。而挂起(比失败更常见:接口慢、entry 的 DNS 卡住)连 reject 都不会给你,Promise.all 就一直等——用户看到的是白屏,且没有任何错误上报。
反面也不对:
E5.3 6 个子应用各 40ms:串行装配 246~252ms vs 并行装配 41~43ms(≈5.7~6.0x)
有人为了"顺序正确"改成串行 await,代价是 Σ latency,而且它并没有更安全——串行只是把"一崩全崩"变成"崩到一半,前面已经渲染的东西还得回滚"。
1.6 难点六:拆分维度与迁移阶段
"按什么拆"通常靠组织架构直觉。我用一张调用图把直觉量化:8 个业务域 × 5 页面 = 40 个页面,120 条页面间调用,域内聚系数 0.7(真实系统里大部分跳转发生在域内):
E6.1 图:40 个页面 / 120 条页面间调用(域内聚系数 0.7)
按业务域拆 → 8 个应用,跨边界调用 45/120 = 37.5%,最大扇出 5
按技术栈拆 → 4 个应用,跨边界调用 102/120 = 85.0%,最大扇出 3
域拆法的 top 边界:risk-app->promo-app(5), content-app->promo-app(3), trade-app->report-app(2)
按技术栈拆(列表应用、详情应用、表单应用、看板应用)看起来"粒度整齐、复用度高",应用数从 8 降到 4,跨边界调用反而从 37.5% 涨到 85.0%。因为跨边界调用的成本不在数量上,而在它穿过的那条线:跨应用 = 跨加载(要等另一个应用的 chunk)、跨沙箱(要过通信层)、跨团队(要协调发布日期)。按技术栈拆等于把每一次"列表点进详情"都变成一次跨团队远程协作。
最后是阶段判据。微前端最容易自欺的地方,是"能跑起来"就当"架构成立":
E6b ✓ S0 单体 判据:统一构建 / 路由集中在一处
E6b ✓ S1 拆分预备 判据:契约清单存在且非空 / 契约有版本号 / 路由所有权表唯一
E6b ✗ S2 运行时并行 判据:每个子应用可独立构建 / 独立发布至少发生过 1 次 / 加载失败有降级而非白屏
E6b 结论:这套组合只能宣称到达「S1 拆分预备」
S2 卡在哪一条很清楚:"独立发布至少发生过 1 次"没发生过。没有独立发布,微前端就只是"一个仓库里分了几个目录",一切收益(隔离发布、独立回滚、并行迭代)都还没兑现。
二、完整解法
全部代码在 day37-assets/,零依赖,node bench.mjs 直接跑。
day37-assets/
├── fixtures.mjs # 6 个应用的真实感组合:路由、契约、共享依赖、部署矩阵
├── rng.mjs # 确定性 PRNG(同 seed 逐字节一致)
├── semver.mjs # ^ ~ 精确 * 的区间、交集、bump 判定
├── routes.mjs # 第一张表:路由所有权
├── contract.mjs # 第二张表:契约差分 + 发布组合矩阵
├── deps.mjs # 第三张表:单例契约 + 最小实例划分
├── compose.mjs # 装配拓扑:隔离 / 超时预算 / 降级
├── health.mjs # 拆分维度量化 + 迁移阶段判据
├── gate.mjs # 门禁:退出码即结论
├── bench.mjs # 7 组实测
└── test-day37.mjs # 44 条断言
2.1 第一张表:路由所有权
所有权表只做两件事:匹配和判重叠。匹配要处理三种段:字面量、:param、*(rest 至少吃一段):
function segKind(seg) {
if (seg === '*') return 'rest';
if (seg.startsWith(':')) return 'param';
return 'literal';
}
export function match(pattern, path) {
const p = segments(pattern), t = segments(path);
let i = 0;
for (; i < p.length; i++) {
const k = segKind(p[i]);
if (k === 'rest') return t.length > i; // 至少还要剩一段
if (i >= t.length) return false;
if (k === 'literal' && p[i] !== t[i]) return false;
}
return i === t.length;
}
重叠检测是"两个 pattern 是否可能命中同一个 path",逐段推进,遇到 rest 就结束:
export function overlap(r1, r2) {
const a = segments(r1), b = segments(r2);
let i = 0;
while (i < a.length && i < b.length) {
const ka = segKind(a[i]), kb = segKind(b[i]);
if (ka === 'rest' || kb === 'rest') {
const other = ka === 'rest' ? b : a;
return other.length > i; // 对方在这之后还有段
}
if (ka === 'literal' && kb === 'literal' && a[i] !== b[i]) return false;
i++;
}
return i === a.length && i === b.length;
}
仲裁不能用"第一个命中",要用具体度排序,并把并列显式报出来:
export function resolveCanonical(table, path) {
const hits = table.filter((row) => match(row.route, path));
if (hits.length === 0) return null;
const scored = hits.map((row) => ({ ...row, s: specificity(row.route) }));
scored.sort((x, y) => {
if (y.s.len !== x.s.len) return y.s.len - x.s.len; // 段数多者胜
if (x.s.rest !== y.s.rest) return x.s.rest - y.s.rest; // 通配少者胜
if (y.s.lit !== x.s.lit) return y.s.lit - x.s.lit; // 字面量多者胜
return x.app < y.app ? -1 : x.app > y.app ? 1 : 0; // 唯一化:应用名
});
const top = scored[0];
const tie = scored.filter((r) => r.app !== top.app && r.s.len === top.s.len
&& r.s.rest === top.s.rest && r.s.lit === top.s.lit);
return { app: top.app, route: top.route, ambiguousWith: tie.map((r) => r.app), hits: scored.length };
}
三个失败模式的对应关系:
| 症状 | 判据 | 修复 |
|---|---|---|
| 归属随顺序变化 | findConflicts() > 0 | 所有权表收敛,禁跨应用重复声明 |
死路由(/member 空白) | 探针 URL 里 resolveCanonical() 返回 null | 裸前缀显式声明:['/member', '/member/*'] |
| 影子路由(白屏) | 归属应用不在本次部署矩阵里 | 显式 FALLBACK 表 + 部署矩阵校验 |
治理后的对比:
E1.7 原始所有权与治理后所有权不一致的 URL:1/16
E1.8 治理后:死路由 0/16,跨应用重叠 0 处,归属唯一(最长前缀 + 具体度)
/trade/legacy 的归属从"随机"变成了"ops"——这是架构决策(老页面归业务团队),必须显式写在表里,而不是由加载顺序决定。
2.2 第二张表:契约兼容
判据要换成 schema 差分,版本号只用来表达意图,不用来判断事实:
export function diffSchema(prev, next) {
const breaking = [], additive = [], warn = [];
const p = prev.properties || {}, n = next.properties || {};
const preq = new Set(prev.required || []), nreq = new Set(next.required || []);
for (const key of Object.keys(p)) {
if (!(key in n)) { breaking.push({ path: key, rule: 'removed-property' }); continue; }
const a = p[key], b = n[key];
if (a.type !== b.type) {
breaking.push({ path: key, rule: `type-changed ${a.type}->${b.type}` }); continue;
}
if (a.enum && b.enum) {
const lost = a.enum.filter((v) => !b.enum.includes(v));
const gained = b.enum.filter((v) => !a.enum.includes(v));
if (lost.length) breaking.push({ path: key, rule: `enum-narrowed -[${lost}]` });
if (gained.length) additive.push({ path: key, rule: `enum-widened +[${gained}]` });
} else if (a.enum && !b.enum) additive.push({ path: key, rule: 'enum-dropped(open)' });
else if (!a.enum && b.enum) breaking.push({ path: key, rule: 'enum-added(closed)' });
}
for (const key of Object.keys(n)) {
if (key in p) continue;
if (nreq.has(key)) breaking.push({ path: key, rule: 'added-required-property' });
else additive.push({ path: key, rule: 'added-optional-property' });
}
for (const key of nreq) {
if (key in p && !preq.has(key)) breaking.push({ path: key, rule: 'optional->required' });
}
for (const key of preq) {
if (key in n && !nreq.has(key)) additive.push({ path: key, rule: 'required->optional' });
}
// 关键提醒:新增可选字段对"字段名做穷举校验"的消费者仍是破坏性的
for (const a of additive) {
if (a.rule === 'added-optional-property') warn.push({ path: a.path, rule: 'strict-consumer-risk' });
}
return { breaking, additive, warn, breakingCount: breaking.length };
}
有了真判据,版本号才有意义:版本号是结论的载体,不是结论本身。正确流程是 CI 先跑差分 → 得到 breaking 结论 → 校验人打的 bump 与结论一致 → 不一致就拒绝合并。
再往上还有一层:单次变更是"破不破",多次发布是"组合合不合法"。消费者声明的 range × 提供者实际版本:
export function publishMatrix(consumers, providerName, providerVersion) {
const rows = consumers
.filter((c) => c.consumes && providerName in c.consumes)
.map((c) => ({ app: c.name, spec: c.consumes[providerName],
ok: satisfies(providerVersion, c.consumes[providerName]) }));
return { providerName, providerVersion, rows, broken: rows.filter((r) => !r.ok) };
}
实测这类"跨应用组合"的非法率:
E3.x trade-api@2.0.0:2 个消费者,非法 2 → settle(^1.4.0), report(^1.2.0)
E3.x member-api@1.5.0:3 个消费者,非法 0
E3.x settle-api@2.1.0:1 个消费者,非法 1 → report(^1.0.0)
E3.x trade-api@1.4.1:2 个消费者,非法 0
E3.1 4 个发布场景共 3 个非法组合
E3.2 8 次应用发布里只有 2 次动了契约;用"应用版本"当兼容判据会多喊 6 次升级
settle-api@2.1.0 那一条最值得看:它只有一个消费者 report,声明 ^1.0.0,提供方升到 2.x 就破了。提供方自己升级,永远不会在自己仓库里发现这个问题——它只能被"组合矩阵"发现。这就是架构层校验和包层校验的分界线。
2.3 第三张表:共享依赖与单例口径
// 单例契约:声明为 singleton 的库,所有使用方的 range 必须两两相交
export function checkSingletons(apps, singletonLibs) {
const out = [];
for (const lib of singletonLibs) {
const users = apps.filter((a) => a.shared && lib in a.shared);
const conflicts = [];
for (let i = 0; i < users.length; i++) {
for (let j = i + 1; j < users.length; j++) {
if (!intersect(users[i].shared[lib], users[j].shared[lib])) {
conflicts.push({ a: `${users[i].name}@${users[i].shared[lib]}`,
b: `${users[j].name}@${users[j].shared[lib]}` });
}
}
}
out.push({ lib, users: users.length, conflicts, ok: conflicts.length === 0 });
}
return out;
}
配合最小实例划分(区间贪心,把 witness 钉成具体版本):
export function minInstances(apps, lib) {
const specs = apps.filter((a) => a.shared && lib in a.shared)
.map((a) => ({ app: a.name, spec: a.shared[lib] }));
const sorted = [...specs].sort((x, y) => {
const a = rangeOf(x.spec), b = rangeOf(y.spec);
return cmp(a.lo, b.lo) || cmp(a.hi || '999.0.0', b.hi || '999.0.0');
});
const instances = [];
for (const s of sorted) {
let placed = false;
for (const inst of instances) {
const meet = intersect(inst.witness, s.spec); // witness 是具体版本
if (meet) { inst.members.push(s); inst.witness = meet.witness; placed = true; break; }
}
if (!placed) instances.push({ witness: rangeOf(s.spec).lo, members: [s] });
}
return instances;
}
于是架构层要落的三条硬约束变成可校验的:
singleton列表里的库,两两区间必须相交(否则架构上就承认无法单例,得先统一版本再谈共享);- 非单例的库允许分裂,但分裂数要最小化,且每个实例要能指名成员;
- 装配时按
witness(具体版本)去 import map 里取,不许"取最高"——取最高是对^17的静默违约。
2.4 装配层:隔离 + 超时预算 + 显式降级
Promise.all 换成"每个边界独立 settle,另加一个超时计时器":
export async function composeIsolated(apps, behaviors, { timeout = 200 } = {}) {
const slots = await Promise.all(
apps.map(async (a) => {
const b = behaviors[a.name];
const task = loadOne(a, b).then(
(r) => ({ app: a.name, state: 'ready', value: r }),
(e) => ({ app: a.name, state: 'error', error: e.message })
);
const timer = new Promise((resolve) =>
setTimeout(() => resolve({ app: a.name, state: 'fallback', reason: 'timeout' }), timeout)
);
return Promise.race([task, timer]);
})
);
const usable = slots.filter((s) => s.state === 'ready').length;
return { slots, usable, degraded: slots.filter((s) => s.state !== 'ready').map((s) => s.app) };
}
关键点有三个,缺一个就会退化成白屏:
.then(onOk, onErr)而不是.catch:reject 被就地消化,绝不让它冒到Promise.all;Promise.race加计时器:挂起(永远不 resolve)也会在预算内落到fallback;- 降级要落到"区域":
degraded: ['report']这个信息必须能驱动 UI——报表区显示"数据加载中/重试",而不是整页骨架。
2.5 把判据做成门禁
架构文档没人看,门禁有人看。gate.mjs 的逻辑就是四句话,退出码即结论:
const checks = [
{ name: '路由所有权唯一', ok: R.findConflicts(APPS).length === 0 },
{ name: '契约变更过 schema 差分', ok: schemaOk === C.CONTRACT_CHANGES.length },
{ name: '发布组合合法', ok: illegalTotal === 0 },
{ name: '共享单例无冲突', ok: singles.every((s) => s.ok) },
];
let failed = 0;
for (const c of checks) {
if (!c.ok) failed++;
console.log(`${c.ok ? '✅ PASS' : '❌ FAIL'} ${c.name}`);
}
process.exit(failed === 0 ? 0 : 1);
对着当前这套组合跑,退出码 1:
E7 · FAIL 路由所有权唯一 (2 处重叠)
E7 · PASS 契约变更过 schema 差分 (30/30)
E7 · FAIL 发布组合合法 (3 个非法组合)
E7 · FAIL 共享单例无冲突 (1 个库冲突)
E7 结论:1/4 通过 → 退出码 1
接到流水线上,就是"任何一个子应用要合入主干,先跑组合校验":
- name: 微前端架构门禁
run: node day37-assets/gate.mjs
2.6 别在这些地方花时间
诚实地说清楚边界,比多写两条规则有用:
- 别指望沙箱解决架构问题。JS 沙箱(Proxy window)和 CSS 隔离解决的是"同时在一页里跑"的技术冲突;路由所有权的随机性、契约的破坏性、单例的双实例,沙箱一律看不见。
- 别用 iframe 逃避治理。iframe 给的隔离是硬的,代价是路由/弹窗/滚动/性能/共享状态全断,你得重造一遍这些能力,架构层的三张表一张不少。
- 别把"独立部署"当口号。判据是"独立发布发生过",不是"配置上支持"。
- 别一开始就上 Module Federation。它解决的是构建期共享,前提是你已经想清楚哪些库是单例(2.3)——顺序反了会放大双实例问题。
三、应用场景
3.1 什么时候该上微前端
实测数字反过来读,就是选型判据:
| 信号 | 该上 | 不该上 |
|---|---|---|
| 团队数 × 发布节奏 | ≥3 个团队,发布节奏互相阻塞 | 1~2 个团队,一个发版窗口能协调 |
| 页面间调用密度 | 域内聚高(跨边界调用 <40%) | 页面间高度互调(跨边界 >70%,拆了只是把耦合搬远) |
| 存量系统 | 老系统渐进迁移,边界天然在路由上 | 全新项目,边界还没长出来 |
| 技术栈 | 必须共存(Vue2 老页面 + React 新页面) | 技术栈统一,上微前端只是想要代码分割 |
| 掉线容忍度 | 单个区域可降级、可重试 | 整页强一致(一个区域挂了页面就无意义) |
最后一行特别重要:如果业务上"报表区挂了整页就没用",那故障隔离带来的 5/6 可用区没有商业价值,反而多了一层复杂度。
3.2 与现成框架的关系
qiankun / wujie / single-spa / Module Federation 解决的是运行时装载(1.5 与 2.4 那一层),以及沙箱、样式隔离。它们不解决:路由所有权的唯一性、契约的破坏性判定、单例的版本划分、部署矩阵与回落表、拆分维度是否合理。
也就是说:换框架不会改善 Day37 的任何一条数字。你没写所有权表,resolveNaive 顺序敏感的 2 个 URL 在 qiankun 里一样随机;你的契约只 bump minor,换 Federation 也一样崩。反过来,三张表做完了,运行时用哪套都是替换成本很低的决策。
3.3 发布流水线里最容易翻车的三处
- entry 的 HTML 被 CDN 强缓存:子应用新版发了,基座还拿着旧
entry,于是"某些用户永远停在旧版"。entry 必须no-store,chunk 用内容 hash。 - 路由表与部署矩阵不同步:新应用上线前先把路由登记进所有权表,用户先拿到新表 → 影子路由白屏。顺序必须是"部署 → 校验探针 URL → 切路由"。
- 某应用私自升级单例库:
ops把react从 17 升到 18 想"顺手清理技术债",本地一切正常(它自己一份),上线后和基座 18.2.0 拼在一起才炸。这条只能靠门禁拦——人类的评审看不见package.json里的一个^。
3.4 与前后篇咬合
- Day08 微前端样式隔离:CSS 的边界是"在同一页里共存"的问题;
- Day24 微前端实现:沙箱、路由接管、装载的实现骨架,是这一篇的运行时底座;
- Day36 Monorepo:单实例判据(模块实例而非路径)在这里以"独立部署"的形式回归;
- Day38 流水线:把 2.5 的门禁接进 CI,让判据在合入时生效而不是在复盘时生效。
四、总结
微前端不是技术选型,是组织边界的显式化。它的失败几乎从不来自"装载实现不好",而来自三张表没人写:
- 路由所有权表:归属唯一 + 顺序无关 + 裸前缀显式 + 回落表。缺了它,2/16 的 URL 归属随机、4/16 是死路由、3 处是影子路由。
- 契约兼容表:兼容性由 schema 差分决定,版本号只是载体。靠"major 就是破坏性"判,召回 46.7%、准确 66.7%;换成差分 30/30,且对严格消费者另给 8 条警告。
- 共享依赖单例表:单例是契约,口径是模块实例。"取最高版本"是静默违约(1 个应用破约);正确做法是最小实例划分(2 个实例,关进具体应用)。
- 装配层:每个边界独立 settle + 超时预算 + 区域级降级。
Promise.all的可用区是 0.00/6,隔离版是 5.00/6,挂起场景 120ms 出骨架。 - 拆分维度:按域拆 37.5% 跨边界,按栈拆 85.0%——跨界调用是最贵的边,用调用图量化,别用组织架构猜测。
- 阶段判据:S0/S1/S2/S3 各有硬判据,没发生过独立发布就不能宣称运行时并行。
一句话记住口径:"能挂上去"是 Day24 的问题,"挂上去之后谁负责、坏了怎么办"是 Day37 的问题。 前者靠 API,后者靠表。
复现方式:
cd day37-assets
node test-day37.mjs # 44 条断言
node bench.mjs # 7 组实测(末尾写 bench-log.txt)
node gate.mjs; echo "exit=$?" # 门禁:1/4 通过 → 1
附录:完整代码
以下脚本均来自
day37-assets/(完整源码)。 运行:node test-day37.mjs验证契约/版本/路由/健康全部断言;node bench.mjs跑编排基准。
compose.mjs —— 应用编排与生命周期(mount/unmount/activate/deactivate)
// 组合层:故障隔离、超时预算、加载拓扑(串行 vs 并行)
import { mulberry32 } from './rng.mjs';
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 一个子应用的真实行为:加载 + 挂载,可能失败、可能挂起
async function loadOne(app, opts) {
if (opts.fail) {
await sleep(opts.latency);
throw new Error(`${app.name}: chunk load failed`);
}
if (opts.hang) {
await sleep(opts.hangFor);
return { app: app.name, ok: true };
}
await sleep(opts.latency);
return { app: app.name, ok: true };
}
// 朴素组合:Promise.all —— 一个子应用失败/挂起,整个页面陪葬
export async function composeNaive(apps, behaviors) {
const results = await Promise.all(apps.map((a) => loadOne(a, behaviors[a.name])));
return { usable: results.length, failed: [], mounted: results.map((r) => r.app) };
}
// 串行组合(很多人为了"顺序正确"这么写):TTI = Σ latency
export async function composeSerial(apps, behaviors) {
const mounted = [];
for (const a of apps) {
const r = await loadOne(a, behaviors[a.name]);
mounted.push(r.app);
}
return { usable: mounted.length, mounted };
}
// 边界隔离 + 超时降级:每个区域独立成败,超时先出骨架
export async function composeIsolated(apps, behaviors, { timeout = 200 } = {}) {
const slots = await Promise.all(
apps.map(async (a) => {
const b = behaviors[a.name];
const task = loadOne(a, b).then(
(r) => ({ app: a.name, state: 'ready', value: r }),
(e) => ({ app: a.name, state: 'error', error: e.message })
);
const timer = new Promise((resolve) =>
setTimeout(() => resolve({ app: a.name, state: 'fallback', reason: 'timeout' }), timeout)
);
return Promise.race([task, timer]);
})
);
const usable = slots.filter((s) => s.state === 'ready').length;
return { slots, usable, degraded: slots.filter((s) => s.state !== 'ready').map((s) => s.app) };
}
// 批量实验:每次随机注入 1 个故障,统计可用区域数
export function faultTrials(appNames, trials, seed = 7) {
const rnd = mulberry32(seed);
const rows = [];
for (let t = 0; t < trials; t++) {
const victim = appNames[Math.floor(rnd() * appNames.length)];
rows.push({ victim });
}
return rows;
}
contract.mjs —— 子应用契约(manifest、能力声明、版本协商)
// 应用间契约:schema 差分(真兼容判据) vs 版本号(流行判据)
import { bumpKind, satisfies } from './semver.mjs';
const clone = (x) => JSON.parse(JSON.stringify(x));
// ---- schema 差分:只覆盖跨应用契约真正会用到的子集 ----
// { type:'object', required:[], properties:{ k: {type, enum?} } }
export function diffSchema(prev, next) {
const breaking = [];
const additive = [];
const warn = [];
const p = prev.properties || {}, n = next.properties || {};
const preq = new Set(prev.required || []), nreq = new Set(next.required || []);
for (const key of Object.keys(p)) {
if (!(key in n)) { breaking.push({ path: key, rule: 'removed-property' }); continue; }
const a = p[key], b = n[key];
if (a.type !== b.type) { breaking.push({ path: key, rule: `type-changed ${a.type}->${b.type}` }); continue; }
if (a.enum && b.enum) {
const lost = a.enum.filter((v) => !b.enum.includes(v));
const gained = b.enum.filter((v) => !a.enum.includes(v));
if (lost.length) breaking.push({ path: key, rule: `enum-narrowed -[${lost}]` });
if (gained.length) additive.push({ path: key, rule: `enum-widened +[${gained}]` });
} else if (a.enum && !b.enum) {
additive.push({ path: key, rule: 'enum-dropped(open)' });
} else if (!a.enum && b.enum) {
breaking.push({ path: key, rule: 'enum-added(closed)' });
}
}
for (const key of Object.keys(n)) {
if (key in p) continue;
if (nreq.has(key)) breaking.push({ path: key, rule: 'added-required-property' });
else additive.push({ path: key, rule: 'added-optional-property' });
}
for (const key of nreq) {
if (key in p && !preq.has(key)) breaking.push({ path: key, rule: 'optional->required' });
}
for (const key of preq) {
if (key in n && !nreq.has(key)) additive.push({ path: key, rule: 'required->optional' });
}
// 关键提醒:新增可选字段对"字段名做穷举校验"的消费者仍是破坏性的
for (const a of additive) {
if (a.rule === 'added-optional-property') warn.push({ path: a.path, rule: 'strict-consumer-risk' });
}
return { breaking, additive, warn, breakingCount: breaking.length };
}
// ---- 两种判据 ----
export function judgeByVersion(prevV, nextV) {
const k = bumpKind(prevV, nextV);
return { verdict: k === 'major' ? 'breaking' : 'compatible', bump: k };
}
export function judgeBySchema(prevSchema, nextSchema) {
const d = diffSchema(prevSchema, nextSchema);
return { verdict: d.breakingCount > 0 ? 'breaking' : 'compatible', detail: d };
}
// ---- 契约变更数据集:标签由"构造方式"决定,与 diff 实现无关 ----
export const BASE_MEMBER = {
type: 'object',
required: ['id', 'level'],
properties: {
id: { type: 'string' },
level: { type: 'string', enum: ['normal', 'silver', 'gold'] },
nickname: { type: 'string' },
},
};
export const BASE_ORDER = {
type: 'object',
required: ['orderId', 'amount'],
properties: {
orderId: { type: 'string' },
amount: { type: 'number' },
status: { type: 'string', enum: ['created', 'paid', 'closed'] },
remark: { type: 'string' },
},
};
function change(name, base, mutate, truth, versionBump) {
const next = clone(base);
mutate(next);
return { name, base, next, truth, versionBump };
}
// 13 破坏 + 17 兼容;版本号由"人"打,其中 6 个破坏性变更被打成 minor/patch,2 个非破坏被打成 major
export const CONTRACT_CHANGES = [
change('member: 删掉 nickname', BASE_MEMBER, (s) => { delete s.properties.nickname; }, 'breaking', 'minor'),
change('member: level 枚举收窄', BASE_MEMBER, (s) => { s.properties.level.enum = ['normal', 'gold']; }, 'breaking', 'minor'),
change('member: level 改成 number', BASE_MEMBER, (s) => { s.properties.level = { type: 'number' }; }, 'breaking', 'major'),
change('member: 新增必填 phone', BASE_MEMBER, (s) => { s.properties.phone = { type: 'string' }; s.required.push('phone'); }, 'breaking', 'minor'),
change('member: nickname 变成必填', BASE_MEMBER, (s) => { s.required.push('nickname'); }, 'breaking', 'patch'),
change('member: 新增可选 avatar', BASE_MEMBER, (s) => { s.properties.avatar = { type: 'string' }; }, 'compatible', 'minor'),
change('member: level 枚举放宽', BASE_MEMBER, (s) => { s.properties.level.enum.push('platinum'); }, 'compatible', 'minor'),
change('member: nickname 变成可选', BASE_MEMBER, (s) => { s.required = s.required.filter((k) => k !== 'nickname'); }, 'compatible', 'patch'),
change('member: 无变化', BASE_MEMBER, () => {}, 'compatible', 'patch'),
change('member: 删掉 nickname(major)', BASE_MEMBER, (s) => { delete s.properties.nickname; }, 'breaking', 'major'),
change('member: id 改 number', BASE_MEMBER, (s) => { s.properties.id = { type: 'number' }; }, 'breaking', 'major'),
change('member: 新增枚举 shape', BASE_MEMBER, (s) => { s.properties.tier = { type: 'string', enum: ['a'] }; }, 'compatible', 'major'),
change('member: 加两个可选字段', BASE_MEMBER, (s) => { s.properties.tags = { type: 'string' }; s.properties.avatar = { type: 'string' }; }, 'compatible', 'major'),
change('member: required 清空', BASE_MEMBER, (s) => { s.required = []; }, 'compatible', 'minor'),
change('order: 删掉 remark', BASE_ORDER, (s) => { delete s.properties.remark; }, 'breaking', 'major'),
change('order: amount 改 string', BASE_ORDER, (s) => { s.properties.amount = { type: 'string' }; }, 'breaking', 'minor'),
change('order: status 收窄', BASE_ORDER, (s) => { s.properties.status.enum = ['created', 'paid']; }, 'breaking', 'patch'),
change('order: 新增必填 channel', BASE_ORDER, (s) => { s.properties.channel = { type: 'string' }; s.required.push('channel'); }, 'breaking', 'major'),
change('order: remark 变必填', BASE_ORDER, (s) => { s.required.push('remark'); }, 'breaking', 'minor'),
change('order: 新增可选 trace', BASE_ORDER, (s) => { s.properties.trace = { type: 'string' }; }, 'compatible', 'patch'),
change('order: status 放宽', BASE_ORDER, (s) => { s.properties.status.enum.push('refunded'); }, 'compatible', 'minor'),
change('order: remark 变可选', BASE_ORDER, (s) => { s.required = s.required.filter((k) => k !== 'remark'); }, 'compatible', 'patch'),
change('order: status 改成开放枚举', BASE_ORDER, (s) => { delete s.properties.status.enum; }, 'compatible', 'patch'),
change('order: status 加封闭枚举', BASE_ORDER, (s) => { s.properties.remark = { type: 'string', enum: ['a', 'b'] }; }, 'breaking', 'minor'),
change('order: 无变化', BASE_ORDER, () => {}, 'compatible', 'patch'),
change('order: 删备注改 id 类型', BASE_ORDER, (s) => { delete s.properties.remark; s.properties.orderId = { type: 'number' }; }, 'breaking', 'major'),
change('order: 加可选 gift', BASE_ORDER, (s) => { s.properties.gift = { type: 'string' }; }, 'compatible', 'patch'),
change('order: 加可选 gift+note', BASE_ORDER, (s) => { s.properties.gift = { type: 'string' }; s.properties.note = { type: 'string' }; }, 'compatible', 'minor'),
change('order: amount 变必填(原本已必填)', BASE_ORDER, (s) => {}, 'compatible', 'patch'),
change('order: status 枚举清空为开放并加必填 tag', BASE_ORDER, (s) => { delete s.properties.status.enum; s.properties.tag = { type: 'string' }; s.required.push('tag'); }, 'breaking', 'major'),
];
export const PREV_VERSION = '1.4.0';
export function nextVersionFor(change) {
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(PREV_VERSION);
const [maj, min, pat] = [+m[1], +m[2], +m[3]];
if (change.versionBump === 'major') return `${maj + 1}.0.0`;
if (change.versionBump === 'minor') return `${maj}.${min + 1}.0`;
return `${maj}.${min}.${pat + 1}`;
}
// ---- 发布组合矩阵:消费者声明的 range × 提供者实际版本 ----
export function publishMatrix(consumers, providerName, providerVersion) {
const rows = consumers
.filter((c) => c.consumes && providerName in c.consumes)
.map((c) => ({
app: c.name,
spec: c.consumes[providerName],
ok: satisfies(providerVersion, c.consumes[providerName]),
}));
return { providerName, providerVersion, rows, broken: rows.filter((r) => !r.ok) };
}
routes.mjs —— 路由接管(history 劫持、冲突解决、basename 隔离)
// 路由所有权:装配顺序无关性 + 跨应用冲突检测
export function normalize(route) {
let r = String(route).trim();
if (!r.startsWith('/')) r = '/' + r;
if (r.length > 1 && r.endsWith('/')) r = r.slice(0, -1);
return r === '' ? '/' : r;
}
export function segments(route) {
const r = normalize(route);
if (r === '/') return [];
return r.slice(1).split('/');
}
function segKind(seg) {
if (seg === '*') return 'rest';
if (seg.startsWith(':')) return 'param';
return 'literal';
}
// 一个具体 path 是否被 pattern 命中(rest 至少吃一段)
export function match(pattern, path) {
const p = segments(pattern), t = segments(path);
let i = 0;
for (; i < p.length; i++) {
const k = segKind(p[i]);
if (k === 'rest') return t.length > i; // 至少还要剩一段
if (i >= t.length) return false;
if (k === 'literal' && p[i] !== t[i]) return false;
}
return i === t.length;
}
// 两个 pattern 是否可能命中同一个 path(rest 语义下逐段推进)
export function overlap(r1, r2) {
const a = segments(r1), b = segments(r2);
let i = 0;
while (i < a.length && i < b.length) {
const ka = segKind(a[i]), kb = segKind(b[i]);
if (ka === 'rest' || kb === 'rest') {
// rest 在末尾,且至少吃一段:只要对方在这个位置之后还有段
const other = ka === 'rest' ? b : a;
return other.length > i;
}
if (ka === 'literal' && kb === 'literal' && a[i] !== b[i]) return false;
i++;
}
return i === a.length && i === b.length;
}
// 配置表:{ app, routes: [] }[],按插入顺序
export function buildTable(apps) {
const rows = [];
for (const app of apps) {
for (const route of app.routes) {
rows.push({ app: app.name, route: normalize(route) });
}
}
return rows;
}
// 朴素实现:第一个命中的赢(qiankun / 手写 activeRule 的常见写法)
export function resolveNaive(table, path) {
for (const row of table) {
if (match(row.route, path)) return row.app;
}
return null;
}
// 规范实现:最长前缀优先 + 具体度优先 + 应用名兜底(保证顺序无关)
export function specificity(route) {
const segs = segments(route);
let lit = 0, param = 0, rest = 0;
for (const s of segs) {
const k = segKind(s);
if (k === 'literal') lit++;
else if (k === 'param') param++;
else rest++;
}
return { len: segs.length, lit, param, rest };
}
export function resolveCanonical(table, path) {
const hits = table.filter((row) => match(row.route, path));
if (hits.length === 0) return null;
const scored = hits.map((row) => ({ ...row, s: specificity(row.route) }));
scored.sort((x, y) => {
if (y.s.len !== x.s.len) return y.s.len - x.s.len; // 段数多者胜
if (x.s.rest !== y.s.rest) return x.s.rest - y.s.rest; // 通配少者胜
if (y.s.lit !== x.s.lit) return y.s.lit - x.s.lit; // 字面量多者胜
return x.app < y.app ? -1 : x.app > y.app ? 1 : 0; // 唯一化:应用名
});
// 并列且应用不同 -> 歧义
const top = scored[0];
const tie = scored.filter((r) => r.app !== top.app && r.s.len === top.s.len && r.s.rest === top.s.rest && r.s.lit === top.s.lit);
return { app: top.app, route: top.route, ambiguousWith: tie.map((r) => r.app), hits: scored.length };
}
// 跨应用冲突:同一 path 空间被两个应用同时声称
export function findConflicts(apps) {
const rows = buildTable(apps);
const out = [];
for (let i = 0; i < rows.length; i++) {
for (let j = i + 1; j < rows.length; j++) {
if (rows[i].app === rows[j].app) continue;
if (overlap(rows[i].route, rows[j].route)) {
out.push({ a: rows[i], b: rows[j], kind: rows[i].route === rows[j].route ? 'exact' : 'prefix' });
}
}
}
return out;
}
deps.mjs —— 共享依赖(单例库、版本一致性、去重)
// 共享依赖的架构决策:单例契约 vs 多实例;最小实例数;"取最高版本一份"的代价
import { rangeOf, satisfies, intersect, cmp } from './semver.mjs';
// 策略一(流行做法):取所有声明中最高的下界,全局只给一份
export function globalHighest(apps, lib) {
const specs = apps.filter((a) => a.shared && lib in a.shared).map((a) => ({ app: a.name, spec: a.shared[lib] }));
if (specs.length === 0) return { version: null, rows: [], broken: [] };
specs.sort((x, y) => cmp(rangeOf(y.spec).lo, rangeOf(x.spec).lo));
const version = rangeOf(specs[0].spec).lo;
const rows = specs.map((s) => ({ ...s, ok: satisfies(version, s.spec) }));
return { version, rows, broken: rows.filter((r) => !r.ok) };
}
// 策略二:按声明分区,最小化实例数(区间贪心)
export function minInstances(apps, lib) {
const specs = apps.filter((a) => a.shared && lib in a.shared).map((a) => ({ app: a.name, spec: a.shared[lib] }));
const sorted = [...specs].sort((x, y) => {
const a = rangeOf(x.spec), b = rangeOf(y.spec);
return cmp(a.lo, b.lo) || cmp(a.hi || '999.0.0', b.hi || '999.0.0');
});
const instances = [];
for (const s of sorted) {
let placed = false;
for (const inst of instances) {
const meet = intersect(inst.witness, s.spec);
if (meet) {
inst.members.push(s);
// 收紧 witness 保持对所有成员成立
inst.witness = meet.witness;
for (const m of inst.members) if (!satisfies(inst.witness, m.spec)) {
// 回溯:换下一个候选版本(取交集下界)
inst.witness = meet.lo;
}
placed = true;
break;
}
}
if (!placed) instances.push({ witness: rangeOf(s.spec).lo, members: [s] });
}
return instances;
}
// 单例契约:声明为 singleton 的库,所有使用方的 range 必须两两相交
export function checkSingletons(apps, singletonLibs) {
const out = [];
for (const lib of singletonLibs) {
const users = apps.filter((a) => a.shared && lib in a.shared);
const conflicts = [];
for (let i = 0; i < users.length; i++) {
for (let j = i + 1; j < users.length; j++) {
if (!intersect(users[i].shared[lib], users[j].shared[lib])) {
conflicts.push({ a: `${users[i].name}@${users[i].shared[lib]}`, b: `${users[j].name}@${users[j].shared[lib]}` });
}
}
}
out.push({ lib, users: users.length, conflicts, ok: conflicts.length === 0 });
}
return out;
}
semver.mjs —— 版本语义(兼容区间、破坏性变更检测)
// 极简 semver:只覆盖微前端组合校验需要的口径(^ ~ 精确 *)
export function parse(v) {
const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v).trim());
if (!m) throw new Error(`bad version: ${v}`);
return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || null };
}
export function fmt(v) {
return `${v.major}.${v.minor}.${v.patch}`;
}
export function cmp(a, b) {
const p = parse(a), q = parse(b);
for (const k of ['major', 'minor', 'patch']) {
if (p[k] !== q[k]) return p[k] < q[k] ? -1 : 1;
}
if (p.pre === q.pre) return 0;
if (p.pre === null) return 1;
if (q.pre === null) return -1;
return p.pre < q.pre ? -1 : 1;
}
export function bumpKind(from, to) {
const a = parse(from), b = parse(to);
if (cmp(from, to) === 0) return 'none';
if (a.major !== b.major) return 'major';
if (a.minor !== b.minor) return 'minor';
return 'patch';
}
// 规范化成 [lo, hi) —— 用字符串边界避免浮点
export function rangeOf(spec) {
const s = String(spec).trim();
if (s === '*' || s === '' || s === 'latest') return { lo: '0.0.0', hi: null };
let m = /^\^(\d+\.\d+\.\d+)$/.exec(s);
if (m) {
const v = parse(m[1]);
let hi;
if (v.major > 0) hi = `${v.major + 1}.0.0`;
else if (v.minor > 0) hi = `0.${v.minor + 1}.0`;
else hi = `0.0.${v.patch + 1}`;
return { lo: fmt(v), hi };
}
m = /^~(\d+\.\d+\.\d+)$/.exec(s);
if (m) {
const v = parse(m[1]);
return { lo: fmt(v), hi: `${v.major}.${v.minor + 1}.0` };
}
m = /^(\d+\.\d+\.\d+)$/.exec(s);
if (m) return { lo: fmt(parse(m[1])), hi: null, exact: true };
throw new Error(`bad range: ${spec}`);
}
export function satisfies(version, spec) {
const r = rangeOf(spec);
if (r.exact) return cmp(version, r.lo) === 0;
if (cmp(version, r.lo) < 0) return false;
if (r.hi && cmp(version, r.hi) >= 0) return false;
return true;
}
// 两个 range 是否有公共版本(决定"能不能共享同一个实例")
export function intersect(aSpec, bSpec) {
const a = rangeOf(aSpec), b = rangeOf(bSpec);
if (a.exact || b.exact) {
const ex = a.exact ? a.lo : b.lo;
if (!satisfies(ex, aSpec) || !satisfies(ex, bSpec)) return null;
return { lo: ex, hi: null, witness: ex };
}
const lo = cmp(a.lo, b.lo) >= 0 ? a.lo : b.lo;
let hi;
if (a.hi && b.hi) hi = cmp(a.hi, b.hi) <= 0 ? a.hi : b.hi;
else hi = a.hi || b.hi;
if (hi && cmp(lo, hi) >= 0) return null;
return { lo, hi, witness: lo };
}
gate.mjs —— 发布门禁(契约校验、灰度准入)
#!/usr/bin/env node
// 微前端架构门禁:退出码即结论(0 = 可发布)
import * as R from './routes.mjs';
import * as C from './contract.mjs';
import * as D from './deps.mjs';
import { APPS, CONTRACTS } from './fixtures.mjs';
const checks = [];
const add = (name, ok, detail) => checks.push({ name, ok, detail });
// 1. 路由所有权唯一
const conflicts = R.findConflicts(APPS);
add('路由所有权唯一', conflicts.length === 0,
conflicts.map((c) => `${c.a.app}"${c.a.route}"×${c.b.app}"${c.b.route}"`).join(' '));
// 2. 每个应用都声明了自己的契约版本
const noVersion = APPS.filter((a) => !a.provides || Object.keys(a.provides).length === 0);
add('每个应用都登记契约', noVersion.length === 0, noVersion.map((a) => a.name).join(','));
// 3. 单例库无不兼容声明
const singles = D.checkSingletons(APPS, ['react', 'design-system']);
add('单例依赖无冲突', singles.every((s) => s.ok),
singles.filter((s) => !s.ok).map((s) => `${s.lib}:${s.conflicts.length}对`).join(' '));
// 4. 本次要发布的契约变更必须过 schema 差分(模拟:一次删字段的变更)
const demo = C.CONTRACT_CHANGES[0];
const schemaJudge = C.judgeBySchema(demo.base, demo.next);
add('契约变更经 schema 差分', schemaJudge.verdict === 'breaking',
`样例变更「${demo.name}」→ ${schemaJudge.verdict}`);
// 5. 发布组合合法(示例:trade-api 升 2.0.0)
const matrix = C.publishMatrix(APPS, 'trade-api', '2.0.0');
add('发布组合合法', matrix.broken.length === 0,
matrix.broken.map((b) => `${b.app}(${b.spec})`).join(' '));
let failed = 0;
for (const c of checks) {
if (!c.ok) failed++;
console.log(`${c.ok ? '✅ PASS' : '❌ FAIL'} ${c.name}${c.detail ? ' — ' + c.detail : ''}`);
}
console.log(`\n${checks.length - failed}/${checks.length} 通过,退出码 ${failed === 0 ? 0 : 1}`);
process.exit(failed === 0 ? 0 : 1);
health.mjs —— 健康检查(子应用可用性、降级兜底)
// 拆分维度:跨边界调用率——"按域拆"和"按技术栈拆"的量化差异
import { mulberry32 } from './rng.mjs';
export const DOMAINS = ['trade', 'member', 'settle', 'report', 'ops', 'risk', 'promo', 'content'];
export const STACKS = ['list', 'detail', 'form', 'dashboard'];
// 生成 40 个页面(8 域 × 5 页),120 条页面间调用边
// 域内聚系数 p=0.7:真实系统里大部分调用发生在域内,这是"按域拆"的前提
export function buildCallGraph({ pagesPerDomain = 5, edges = 120, pInDomain = 0.7, seed = 20261028 } = {}) {
const pages = [];
for (const d of DOMAINS) {
for (let i = 0; i < pagesPerDomain; i++) {
pages.push({ id: `${d}/${i}`, domain: d, stack: STACKS[(i + DOMAINS.indexOf(d)) % STACKS.length] });
}
}
const rnd = mulberry32(seed);
const calls = [];
for (let e = 0; e < edges; e++) {
const from = pages[Math.floor(rnd() * pages.length)];
let to;
if (rnd() < pInDomain) {
const pool = pages.filter((p) => p.domain === from.domain && p.id !== from.id);
to = pool[Math.floor(rnd() * pool.length)];
} else {
const pool = pages.filter((p) => p.domain !== from.domain);
to = pool[Math.floor(rnd() * pool.length)];
}
calls.push({ from: from.id, to: to.id });
}
return { pages, calls };
}
// 给定"页面 -> 应用"的归属函数,统计跨边界调用占比与扇出
export function evaluateSplit({ pages, calls }, appOf) {
const appOfPage = new Map(pages.map((p) => [p.id, appOf(p)]));
let cross = 0;
const fanout = new Map();
const edges = new Map();
for (const c of calls) {
const a = appOfPage.get(c.from), b = appOfPage.get(c.to);
if (a !== b) {
const k = `${a}->${b}`;
edges.set(k, (edges.get(k) || 0) + 1);
cross++;
if (!fanout.has(a)) fanout.set(a, new Set());
fanout.get(a).add(b);
}
}
const apps = new Set([...appOfPage.values()]);
return {
apps: apps.size,
total: calls.length,
cross,
crossRatio: cross / calls.length,
maxFanout: Math.max(0, ...[...fanout.values()].map((s) => s.size)),
edges: [...edges.entries()].sort((x, y) => y[1] - x[1]),
};
}
export const splitByDomain = (p) => `${p.domain}-app`;
export const splitByStack = (p) => `${p.stack}-app`;
// 迁移阶段门禁:四个阶段,每阶段有"能不能进下一阶段"的判据
export const MIGRATION_STAGES = [
{
stage: 'S0 单体',
gate: ['统一构建', '路由集中在一处'],
},
{
stage: 'S1 拆分预备',
gate: ['契约清单存在且非空', '契约有版本号', '路由所有权表唯一'],
},
{
stage: 'S2 运行时并行',
gate: ['每个子应用可独立构建', '独立发布至少发生过 1 次', '加载失败有降级而非白屏'],
},
{
stage: 'S3 自治',
gate: ['跨应用契约变更走 schema 门禁', '共享依赖单例冲突为 0', '任一子应用回滚不影响其它'],
},
];
fixtures.mjs —— 测试夹具(多子应用场景)
// 一套贴近真实的中后台微前端组合:6 个应用、真实的抢路由与契约关系
export const APPS = [
{
name: 'shell', team: '平台组', domain: 'platform',
entry: '/apps/shell/index.html',
routes: ['/', '/home', '/trade/legacy'], // 老交易页的 shim 还留在基座里
provides: { 'shell-api': '1.2.0' },
consumes: {},
shared: { react: '^18.2.0', 'design-system': '^3.1.0' },
singleton: ['react', 'design-system'],
},
{
name: 'trade', team: '交易组', domain: 'trade',
entry: '/apps/trade/index.html',
routes: ['/trade', '/trade/order', '/trade/order/detail'],
provides: { 'trade-api': '1.4.0' },
consumes: { 'shell-api': '^1.2.0', 'member-api': '^1.3.0' },
shared: { react: '^18.2.0', 'design-system': '^3.1.0' },
singleton: ['react'],
},
{
name: 'member', team: '会员组', domain: 'member',
entry: '/apps/member/index.html',
routes: ['/member/*', '/member/coupon'],
provides: { 'member-api': '1.4.0' },
consumes: { 'shell-api': '^1.2.0' },
shared: { react: '^18.2.0', 'design-system': '^3.4.0' },
singleton: ['react'],
},
{
name: 'settle', team: '结算组', domain: 'settle',
entry: '/apps/settle/index.html',
routes: ['/settle/*', '/report/export'], // 报表路径下挂了导出页
provides: { 'settle-api': '2.0.0' },
consumes: { 'trade-api': '^1.4.0', 'member-api': '^1.0.0' },
shared: { react: '^18.2.0', 'design-system': '^3.1.0' },
singleton: ['react'],
},
{
name: 'report', team: '数据组', domain: 'report',
entry: '/apps/report/index.html',
routes: ['/report/*'],
provides: { 'report-api': '1.1.0' },
consumes: { 'trade-api': '^1.2.0', 'settle-api': '^1.0.0' },
shared: { react: '^18.2.0', 'echarts': '^5.4.0' },
singleton: ['react'],
},
{
name: 'ops', team: '运营组', domain: 'ops',
entry: '/apps/ops/index.html',
routes: ['/ops/*', '/trade/legacy'], // 老交易页两边都在认领
provides: { 'ops-api': '1.0.0' },
consumes: { 'shell-api': '^1.2.0', 'member-api': '^1.3.0' },
shared: { react: '^17.0.2', 'design-system': '^3.1.0' },
singleton: ['react'],
},
];
// 探针 URL:覆盖每个应用的实际页面 + 三个必出事的位置
export const PROBE_PATHS = [
'/',
'/home',
'/trade',
'/trade/order',
'/trade/order/detail',
'/trade/legacy',
'/member',
'/member/coupon',
'/member/points/rule',
'/settle',
'/settle/report',
'/report',
'/report/export',
'/report/monthly',
'/ops',
'/ops/audit',
];
// 本构建里实际部署的子应用(客观现实:不可能六个永远同版本上线)
export const DEPLOYED = ['shell', 'trade', 'member', 'report'];
// 显式回落:未部署的子应用,其路由应该回落到谁(架构上必须写下来的那张表)
export const FALLBACK = {
settle: 'report',
ops: 'shell',
};
// 治理之后的样子:所有权唯一、裸前缀显式声明、跨域页面归位
export const FIXED_APPS = APPS.map((a) => ({ ...a, routes: [...a.routes] }));
FIXED_APPS.find((a) => a.name === 'shell').routes = ['/', '/home'];
FIXED_APPS.find((a) => a.name === 'settle').routes = ['/settle', '/settle/*', '/settle/export'];
FIXED_APPS.find((a) => a.name === 'member').routes = ['/member', '/member/*'];
FIXED_APPS.find((a) => a.name === 'report').routes = ['/report', '/report/*'];
FIXED_APPS.find((a) => a.name === 'ops').routes = ['/ops', '/ops/*', '/trade/legacy'];
// 契约登记表:谁提供、谁是消费者、兼容窗口(只支持 N-2)
export const CONTRACTS = [
{ name: 'shell-api', provider: 'shell', window: 2 },
{ name: 'trade-api', provider: 'trade', window: 2 },
{ name: 'member-api', provider: 'member', window: 2 },
{ name: 'settle-api', provider: 'settle', window: 2 },
{ name: 'report-api', provider: 'report', window: 2 },
{ name: 'ops-api', provider: 'ops', window: 2 },
];
rng.mjs —— 确定性随机(复现实验)
// 确定性 PRNG:让所有"随机"实验都可复现(同一 seed 逐字节一致)
export function mulberry32(seed) {
let a = seed >>> 0;
return function () {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
singleton-mod.mjs —— 单例模块探针
// 用来证明"两个模块实例"的真实后果:状态分离 + instanceof 失效
export const state = { count: 0 };
export class Registry {
constructor() { this.id = Math.random().toString(36).slice(2, 8); }
}
export function bump() { state.count++; return state.count; }
test-day37.mjs —— 微前端架构全套断言
// 断言集:node test-day37.mjs
import assert from 'node:assert/strict';
import * as R from './routes.mjs';
import * as C from './contract.mjs';
import * as D from './deps.mjs';
import * as X from './compose.mjs';
import * as H from './health.mjs';
import { satisfies, intersect, bumpKind } from './semver.mjs';
import { APPS, FIXED_APPS, PROBE_PATHS, DEPLOYED } from './fixtures.mjs';
let n = 0;
const t = (name, fn) => {
try { fn(); n++; } catch (e) { console.error(`❌ ${name}\n ${e.message}`); throw e; }
};
const eq = (a, b, m) => assert.deepEqual(a, b, m);
// ---- 路由 ----
t('normalize', () => { eq(R.normalize('trade/'), '/trade'); eq(R.normalize('/trade//'), '/trade/'); eq(R.normalize('/'), '/'); });
t('match 精确', () => { assert.ok(R.match('/trade', '/trade')); assert.ok(!R.match('/trade', '/trade/x')); });
t('match rest 至少吃一段', () => { assert.ok(R.match('/report/*', '/report/a')); assert.ok(!R.match('/report/*', '/report')); });
t('match param', () => { assert.ok(R.match('/order/:id', '/order/9')); assert.ok(!R.match('/order/:id', '/order')); });
t('overlap 前缀', () => { assert.ok(R.overlap('/report/*', '/report/export')); });
t('overlap 互斥字面量', () => { assert.ok(!R.overlap('/trade/*', '/member/*')); });
t('overlap 裸前缀与通配不重叠', () => { assert.ok(!R.overlap('/report', '/report/*')); });
t('跨应用冲突检出 2 处', () => { eq(R.findConflicts(APPS).length, 2); });
t('治理后冲突为 0', () => { eq(R.findConflicts(FIXED_APPS).length, 0); });
t('最长前缀优先', () => {
const table = R.buildTable([{ name: 'a', routes: ['/trade/*'] }, { name: 'b', routes: ['/trade/order/detail'] }]);
eq(R.resolveCanonical(table, '/trade/order/detail').app, 'b');
eq(R.resolveNaive(table, '/trade/order/detail'), 'a'); // 朴素实现被顺序骗了
});
t('顺序无关性:规范解析在任意顺序下一致', () => {
const all = [];
for (let k = 0; k < APPS.length; k++) {
const rotated = [...APPS.slice(k), ...APPS.slice(0, k)];
const table = R.buildTable(rotated);
all.push(PROBE_PATHS.map((p) => R.resolveCanonical(table, p)?.app ?? null));
}
for (let k = 1; k < all.length; k++) eq(all[k], all[0]);
});
t('歧义可被报告出来', () => {
const table = R.buildTable(APPS);
const r = R.resolveCanonical(table, '/trade/legacy');
eq(r.ambiguousWith.length, 1);
});
t('影子路由可枚举', () => {
const table = R.buildTable(APPS);
const shadow = PROBE_PATHS.filter((p) => { const o = R.resolveNaive(table, p); return o && !DEPLOYED.includes(o); });
eq(shadow.length, 3);
});
// ---- semver ----
t('^1.2.0 区间', () => { assert.ok(satisfies('1.9.9', '^1.2.0')); assert.ok(!satisfies('2.0.0', '^1.2.0')); });
t('^0.2.3 只允许 patch', () => { assert.ok(satisfies('0.2.9', '^0.2.3')); assert.ok(!satisfies('0.3.0', '^0.2.3')); });
t('~1.2.3 只允许 patch', () => { assert.ok(satisfies('1.2.9', '~1.2.3')); assert.ok(!satisfies('1.3.0', '~1.2.3')); });
t('intersect 有解', () => { assert.ok(intersect('^18.2.0', '^18.0.0')); });
t('intersect 无解', () => { eq(intersect('^18.2.0', '^17.0.2'), null); });
t('bumpKind', () => { eq(bumpKind('1.4.0', '2.0.0'), 'major'); eq(bumpKind('1.4.0', '1.5.0'), 'minor'); eq(bumpKind('1.4.0', '1.4.1'), 'patch'); });
// ---- 契约差分 ----
t('删字段 = breaking', () => { eq(C.diffSchema(C.BASE_MEMBER, C.CONTRACT_CHANGES[0].next).breakingCount, 1); });
t('枚举收窄 = breaking', () => { eq(C.judgeBySchema(C.BASE_MEMBER, C.CONTRACT_CHANGES[1].next).verdict, 'breaking'); });
t('新增可选 = compatible', () => { eq(C.judgeBySchema(C.BASE_MEMBER, C.CONTRACT_CHANGES[5].next).verdict, 'compatible'); });
t('新增必填 = breaking', () => { eq(C.judgeBySchema(C.BASE_MEMBER, C.CONTRACT_CHANGES[3].next).verdict, 'breaking'); });
t('可选变必填 = breaking', () => { eq(C.judgeBySchema(C.BASE_MEMBER, C.CONTRACT_CHANGES[4].next).verdict, 'breaking'); });
t('枚举放宽 = compatible', () => { eq(C.judgeBySchema(C.BASE_MEMBER, C.CONTRACT_CHANGES[6].next).verdict, 'compatible'); });
t('无变化 = compatible', () => { eq(C.judgeBySchema(C.BASE_ORDER, C.CONTRACT_CHANGES[24].next).verdict, 'compatible'); });
t('schema 与构造标签 30/30 一致', () => {
for (const ch of C.CONTRACT_CHANGES) {
eq(C.judgeBySchema(ch.base, ch.next).verdict === 'breaking', ch.truth === 'breaking', ch.name);
}
});
t('版本号判据会漏报', () => {
let fn = 0;
for (const ch of C.CONTRACT_CHANGES) {
const jv = C.judgeByVersion('1.4.0', C.nextVersionFor(ch)).verdict === 'breaking';
if (!jv && ch.truth === 'breaking') fn++;
}
assert.ok(fn > 0, '应当存在漏报');
});
t('新增可选字段给出严格消费者警告', () => {
const d = C.diffSchema(C.BASE_MEMBER, C.CONTRACT_CHANGES[5].next);
assert.ok(d.warn.length >= 1);
});
t('发布矩阵能抓到破坏组合', () => {
const m = C.publishMatrix(APPS, 'trade-api', '2.0.0');
eq(m.broken.length, 2);
});
// ---- 依赖 ----
t('全局最高版本会破约', () => {
const g = D.globalHighest(APPS, 'react');
eq(g.broken.length, 1); eq(g.broken[0].app, 'ops');
});
t('最小实例数为 2', () => { eq(D.minInstances(APPS, 'react').length, 2); });
t('单例冲突可检出', () => {
const s = D.checkSingletons(APPS, ['react']);
eq(s[0].ok, false); eq(s[0].conflicts.length, 5);
});
t('design-system 单例不冲突', () => { eq(D.checkSingletons(APPS, ['design-system'])[0].ok, true); });
const m1 = await import('./singleton-mod.mjs?t=1');
const m2 = await import('./singleton-mod.mjs?t=2');
t('双实例:状态分离', () => { m1.bump(); eq([m1.state.count, m2.state.count], [1, 0]); });
t('双实例:instanceof 失效', () => { eq(new m1.Registry() instanceof m2.Registry, false); });
// ---- 组合 ----
const six = APPS.map((a) => ({ name: a.name }));
const okBeh = Object.fromEntries(six.map((a) => [a.name, { latency: 5 }]));
const naiveOk = await X.composeNaive(six, okBeh);
t('朴素组合全健康可用', () => { eq(naiveOk.usable, 6); });
const badBeh = { ...okBeh, trade: { latency: 5, fail: true } };
let naiveRejected = false;
try { await X.composeNaive(six, badBeh); } catch { naiveRejected = true; }
t('朴素组合一崩全崩(Promise.all 拒绝)', () => { eq(naiveRejected, true); });
const iso = await X.composeIsolated(six, badBeh, { timeout: 100 });
t('隔离组合保留 5/6', () => { eq(iso.usable, 5); eq(iso.degraded, ['trade']); });
const hangBeh = { ...okBeh, report: { latency: 5, hang: true, hangFor: 3000 } };
const isoHang = await X.composeIsolated(six, hangBeh, { timeout: 60 });
t('挂起应用被超时降级', () => { eq(isoHang.usable, 5); eq(isoHang.degraded, ['report']); });
const serial = await X.composeSerial(six, okBeh);
t('串行组合可用但慢', () => { eq(serial.usable, 6); });
// ---- 拆分 ----
t('调用图规模', () => { const g = H.buildCallGraph(); eq(g.pages.length, 40); eq(g.calls.length, 120); });
t('按域拆跨边界更少', () => {
const g = H.buildCallGraph();
const d = H.evaluateSplit(g, H.splitByDomain);
const s = H.evaluateSplit(g, H.splitByStack);
assert.ok(d.crossRatio < s.crossRatio, `${d.crossRatio} 应小于 ${s.crossRatio}`);
eq(d.apps, 8); eq(s.apps, 4);
});
t('确定性:同 seed 同结果', () => {
eq(H.buildCallGraph(), H.buildCallGraph());
});
console.log(`✅ ${n} 条断言全部通过(node ${process.version})`);
bench.mjs —— 基准主程序(编排开销/路由接管成本)
// Day37 全部实测:零依赖,node bench.mjs 直接跑
import * as R from './routes.mjs';
import * as C from './contract.mjs';
import * as D from './deps.mjs';
import * as X from './compose.mjs';
import * as H from './health.mjs';
import { APPS, FIXED_APPS, PROBE_PATHS, DEPLOYED, FALLBACK, CONTRACTS } from './fixtures.mjs';
const now = () => performance.now();
const pct = (x) => (x * 100).toFixed(1) + '%';
const out = [];
const say = (...a) => { const s = a.join(' '); out.push(s); console.log(s); };
say('==================== Day37 微前端架构 实测 ====================');
say(`Node ${process.version} / V8 ${process.versions.v8} / ${process.platform}-${process.arch}`);
say('');
// ---------------- E1 路由所有权 ----------------
say('---- E1 路由所有权 ----');
const conflicts = R.findConflicts(APPS);
say(`E1.1 6 个应用共 ${APPS.reduce((n, a) => n + a.routes.length, 0)} 条路由声明,跨应用重叠 ${conflicts.length} 处:`);
for (const c of conflicts) say(` · ${c.a.app} "${c.a.route}" × ${c.b.app} "${c.b.route}" (${c.kind})`);
say(`E1.2 治理后(FIXED_APPS)重叠 ${R.findConflicts(FIXED_APPS).length} 处`);
function rotate(arr, k) { return [...arr.slice(k), ...arr.slice(0, k)]; }
const ownersPerOrder = [];
for (let k = 0; k < APPS.length; k++) {
const table = R.buildTable(rotate(APPS, k));
ownersPerOrder.push({ k, map: new Map(PROBE_PATHS.map((p) => [p, R.resolveNaive(table, p)])) });
}
let orderSensitive = [];
for (const p of PROBE_PATHS) {
const set = new Set(ownersPerOrder.map((o) => o.map.get(p)));
if (set.size > 1) orderSensitive.push({ path: p, owners: [...set] });
}
say(`E1.3 6 种装配顺序 × ${PROBE_PATHS.length} 个 URL:顺序会改变归属的 URL ${orderSensitive.length} 个`);
for (const o of orderSensitive) say(` · ${o.path} → ${o.owners.join(' / ')}`);
const canonTable = R.buildTable(APPS);
let dead = [], ambiguous = [], shadow = [], mismatchFixed = 0;
const fixedOwners = new Map(PROBE_PATHS.map((p) => [p, R.resolveCanonical(R.buildTable(FIXED_APPS), p)?.app ?? null]));
for (const p of PROBE_PATHS) {
const canon = R.resolveCanonical(canonTable, p);
const naive = R.resolveNaive(canonTable, p);
if (!canon && !naive) { dead.push(p); continue; }
if (canon && canon.ambiguousWith.length) ambiguous.push(p);
if (naive && !DEPLOYED.includes(naive)) shadow.push({ path: p, owner: naive, fallback: FALLBACK[naive] || '404' });
if ((canon?.app ?? null) !== (fixedOwners.get(p) ?? null)) mismatchFixed++;
}
say(`E1.4 死路由(声明缺失,谁都不认领):${dead.length}/${PROBE_PATHS.length} → ${dead.join(', ')}`);
say(`E1.5 最长前缀也仲裁不了的歧义 URL:${ambiguous.length} → ${ambiguous.join(', ')}`);
say(`E1.6 影子路由(归属指向本次未部署的应用 → 白屏):${shadow.length}`);
for (const s of shadow) say(` · ${s.path} 归 ${s.owner}(未部署)→ 回落 ${s.fallback}`);
say(`E1.7 原始所有权与治理后所有权不一致的 URL:${mismatchFixed}/${PROBE_PATHS.length}`);
const fixedDead = PROBE_PATHS.filter((p) => !R.resolveCanonical(R.buildTable(FIXED_APPS), p)).length;
say(`E1.8 治理后:死路由 ${fixedDead}/${PROBE_PATHS.length},跨应用重叠 ${R.findConflicts(FIXED_APPS).length} 处,归属唯一(最长前缀 + 具体度)`);
// ---------------- E2 契约判据 ----------------
say('');
say('---- E2 契约兼容:版本号判据 vs schema 差分 ----');
let tp = 0, fp = 0, fn = 0, tn = 0, schemaOk = 0, warnings = 0;
const misses = [], falseAlarms = [];
for (const ch of C.CONTRACT_CHANGES) {
const nextV = C.nextVersionFor(ch);
const jv = C.judgeByVersion(C.PREV_VERSION, nextV);
const js = C.judgeBySchema(ch.base, ch.next);
const truthBreaking = ch.truth === 'breaking';
const pv = jv.verdict === 'breaking', ps = js.verdict === 'breaking';
if (pv && truthBreaking) tp++; else if (pv) { fp++; falseAlarms.push(ch); }
else if (truthBreaking) { fn++; misses.push(ch); }
else tn++;
if (ps === truthBreaking) schemaOk++;
warnings += js.detail.warn.length;
}
say(`E2.1 数据集:${C.CONTRACT_CHANGES.length} 次契约变更,其中破坏性 ${tp + fn} 次、兼容 ${tn + fp} 次(标签由构造方式决定,与差分实现无关)`);
say(`E2.2 "major 就是破坏性"判据:命中 ${tp} / 漏报 ${fn} / 误报 ${fp} / 正确判兼容 ${tn}`);
say(` 召回 ${pct(tp / (tp + fn))},精确 ${pct(tp / (tp + fp))},准确 ${pct((tp + tn) / C.CONTRACT_CHANGES.length)}`);
for (const m of misses) say(` · 漏报(破坏性却只 bump ${m.versionBump}):${m.name}`);
for (const f of falseAlarms) say(` · 误报(仅新增兼容内容却 bump ${f.versionBump}):${f.name}`);
say(`E2.3 schema 差分判据:${schemaOk}/${C.CONTRACT_CHANGES.length} 与构造标签一致;另有 ${warnings} 处"新增可选字段"警告(严格消费者仍会崩)`);
// ---------------- E3 发布组合 ----------------
say('');
say('---- E3 发布组合矩阵与兼容窗口 ----');
const scenarios = [
{ provider: 'trade-api', to: '2.0.0' },
{ provider: 'member-api', to: '1.5.0' },
{ provider: 'settle-api', to: '2.1.0' },
{ provider: 'trade-api', to: '1.4.1' },
];
let illegalTotal = 0, illegals = [];
for (const s of scenarios) {
const m = C.publishMatrix(APPS, s.provider, s.to);
illegalTotal += m.broken.length;
for (const b of m.broken) illegals.push(`${b.app}(${b.spec}) × ${s.provider}@${s.to}`);
say(`E3.x ${s.provider}@${s.to}:${m.rows.length} 个消费者,非法 ${m.broken.length} → ${m.broken.map((b) => `${b.app}(${b.spec})`).join(', ') || '—'}`);
}
say(`E3.1 4 个发布场景共 ${illegalTotal} 个非法组合,手工评审最容易漏的正是这类"跨应用"组合`);
const releases = [
{ app: 'trade', appVersion: '1.4.0→1.5.0', contractTouched: false },
{ app: 'trade', appVersion: '1.5.0→2.0.0', contractTouched: true },
{ app: 'member', appVersion: '1.3.2→1.3.3', contractTouched: false },
{ app: 'report', appVersion: '1.1.0→1.2.0', contractTouched: false },
{ app: 'settle', appVersion: '2.0.0→2.0.1', contractTouched: false },
{ app: 'ops', appVersion: '1.0.0→1.1.0', contractTouched: true },
{ app: 'shell', appVersion: '1.2.0→1.2.1', contractTouched: false },
{ app: 'report', appVersion: '1.2.0→1.3.0', contractTouched: false },
];
const touched = releases.filter((r) => r.contractTouched).length;
say(`E3.2 ${releases.length} 次应用发布里只有 ${touched} 次动了契约;用"应用版本"当兼容判据会多喊 ${releases.length - touched} 次升级 → 狼来了`);
// ---------------- E4 共享依赖 ----------------
say('');
say('---- E4 共享依赖:单例契约 vs 多实例 ----');
for (const lib of ['react', 'design-system']) {
const g = D.globalHighest(APPS, lib);
const inst = D.minInstances(APPS, lib);
say(`E4 ${lib}:全局最高版本一份 → ${g.version},违反声明的应用 ${g.broken.length} 个 ${g.broken.map((b) => `${b.app}(${b.spec})`).join(', ')}`);
say(` 最小实例划分 → ${inst.length} 个实例:${inst.map((i) => `${i.witness}[${i.members.map((m) => m.app).join('+')}]`).join(' , ')}`);
}
const singles = D.checkSingletons(APPS, ['react', 'design-system']);
for (const s of singles) {
say(`E4.singleton ${s.lib}(${s.users} 个使用方):${s.ok ? '无冲突' : '冲突 ' + s.conflicts.length + ' 对 → ' + s.conflicts.map((c) => `${c.a} vs ${c.b}`).join(' | ')}`);
}
const m1 = await import('./singleton-mod.mjs?build=A');
const m2 = await import('./singleton-mod.mjs?build=B');
m1.bump(); m1.bump(); m1.bump();
say(`E4.inst 两个模块实例(真实 ESM):A 侧状态 ${m1.state.count},B 侧 ${m2.state.count};跨实例 instanceof ${new m1.Registry() instanceof m2.Registry}`);
// ---------------- E5 故障隔离 ----------------
say('');
say('---- E5 故障隔离与超时预算 ----');
const six = APPS.slice(0, 6).map((a) => ({ name: a.name }));
const base = {}; for (const a of six) base[a.name] = { latency: 40 };
let naiveUsable = 0, isoUsable = 0, trials = 0;
for (let t = 0; t < 60; t++) {
const victim = six[t % six.length].name;
const beh = { ...base, [victim]: { latency: 20, fail: true } };
let n = 0;
try { n = (await X.composeNaive(six, beh)).usable; } catch { n = 0; }
const iso = await X.composeIsolated(six, beh, { timeout: 150 });
naiveUsable += n; isoUsable += iso.usable; trials++;
}
say(`E5.1 ${trials} 次实验,每次注入 1 个子应用加载失败:Promise.all 平均可用区 ${(naiveUsable / trials).toFixed(2)}/6,边界隔离平均可用区 ${(isoUsable / trials).toFixed(2)}/6`);
const hangBeh = { ...base, report: { latency: 40, hang: true, hangFor: 5000 } };
let t0 = now();
let naiveHangMs = null;
const hangRace = Promise.race([X.composeNaive(six, hangBeh), new Promise((r) => setTimeout(() => r('still-hanging'), 900))]).then((v) => v);
const isoHang = await X.composeIsolated(six, hangBeh, { timeout: 120 });
const isoHangMs = now() - t0;
say(`E5.2 有一个子应用接口挂起(模拟 5s 无响应):边界隔离 + 120ms 预算 → ${isoHangMs.toFixed(0)}ms 出骨架,可用区 ${isoHang.usable}/6,降级区 ${isoHang.degraded.join(',')}`);
const raceRes = await hangRace;
say(` Promise.all 在 900ms 后仍未返回(${String(raceRes)})→ 整页白屏`);
t0 = now(); await X.composeSerial(six, base); const serialMs = now() - t0;
t0 = now(); await X.composeIsolated(six, base, { timeout: 300 }); const parallelMs = now() - t0;
say(`E5.3 6 个子应用各 40ms:串行装配 ${serialMs.toFixed(0)}ms vs 并行装配 ${parallelMs.toFixed(0)}ms(≈${(serialMs / parallelMs).toFixed(1)}x)`);
// ---------------- E6 拆分维度 ----------------
say('');
say('---- E6 拆分维度:跨边界调用率 ----');
const g = H.buildCallGraph();
const byDomain = H.evaluateSplit(g, H.splitByDomain);
const byStack = H.evaluateSplit(g, H.splitByStack);
say(`E6.1 图:${g.pages.length} 个页面 / ${g.calls.length} 条页面间调用(域内聚系数 0.7)`);
say(` 按业务域拆 → ${byDomain.apps} 个应用,跨边界调用 ${byDomain.cross}/${byDomain.total} = ${pct(byDomain.crossRatio)},最大扇出 ${byDomain.maxFanout}`);
say(` 按技术栈拆 → ${byStack.apps} 个应用,跨边界调用 ${byStack.cross}/${byStack.total} = ${pct(byStack.crossRatio)},最大扇出 ${byStack.maxFanout}`);
say(` 跨边界调用是微前端里最贵的那条边:要跨应用加载、跨沙箱通信、跨团队协调发布日期`);
say(` 域拆法的 top 边界:${byDomain.edges.slice(0, 3).map(([k, v]) => `${k}(${v})`).join(', ')}`);
// ---------------- E6b 迁移阶段 ----------------
say('');
say('---- E6b 迁移阶段:现在到底在第几阶段 ----');
const stageEvidence = {
'S0 单体': true,
'S1 拆分预备': R.findConflicts(FIXED_APPS).length === 0 && schemaOk === C.CONTRACT_CHANGES.length,
'S2 运行时并行': singles.filter((s) => s.ok).length >= 1 && isoUsable / trials > 5,
'S3 自治': R.findConflicts(APPS).length === 0 && singles.every((s) => s.ok) && illegalTotal === 0,
};
let reached = 'none';
for (const st of H.MIGRATION_STAGES) {
const ok = stageEvidence[st.stage];
say(`E6b ${ok ? '✓' : '✗'} ${st.stage} 判据:${st.gate.join(' / ')}`);
if (ok) reached = st.stage; else break;
}
say(`E6b 结论:这套组合只能宣称到达「${reached}」,后面的阶段不是靠意愿,是靠上面四条判据全绿`);
// ---------------- E7 门禁 ----------------
say('');
say('---- E7 把判据做成门禁 ----');
const gate = [
{ name: '路由所有权唯一', ok: R.findConflicts(APPS).length === 0, detail: `${R.findConflicts(APPS).length} 处重叠` },
{ name: '契约变更过 schema 差分', ok: schemaOk === C.CONTRACT_CHANGES.length, detail: `${schemaOk}/${C.CONTRACT_CHANGES.length}` },
{ name: '发布组合合法', ok: illegalTotal === 0, detail: `${illegalTotal} 个非法组合` },
{ name: '共享单例无冲突', ok: singles.every((s) => s.ok), detail: `${singles.filter((s) => !s.ok).length} 个库冲突` },
];
for (const g2 of gate) say(`E7 · ${g2.ok ? 'PASS' : 'FAIL'} ${g2.name} (${g2.detail})`);
say(`E7 结论:${gate.filter((g2) => g2.ok).length}/${gate.length} 通过 → 退出码 ${gate.every((g2) => g2.ok) ? 0 : 1}`);
import { writeFileSync } from 'node:fs';
writeFileSync(new URL('./bench-log.txt', import.meta.url), out.join('\n') + '\n');
say('');
say('日志已写入 bench-log.txt');