微前端架构

一、技术难点

Day24 讲的是怎么把子应用挂上去 ------沙箱怎么造、样式怎么隔离、路由怎么接管。这一篇往上走一层:挂上去之后,六个团队按各自的节奏发布。

微前端的地基不是 single-spa 的 registerApplication,也不是 qiankun 的 activeRule。它是三张表:

  1. 路由所有权表 ------ 一段 URL 空间属于谁,归属必须唯一,且与装配顺序无关;
  2. 契约兼容表 ------ 谁提供什么、谁消费、兼容窗口多宽、这次变更到底破不破;
  3. 共享依赖单例表 ------ 哪些库必须单实例、单实例的口径是"版本一致"还是"模块实例一致"。

三张表任何一张错了,症状都不在表里:第一张错了表现成"白屏"或"这个页面被另一个应用渲染了";第二张错了表现成"某次发布之后某个页面崩了";第三张错了表现成 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,第一个命中的赢":

js 复制代码
// 朴素实现:第一个命中的赢(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:

bash 复制代码
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 个:

bash 复制代码
E1.4 死路由(声明缺失,谁都不认领):4/16 → /member, /settle, /report, /ops

根因是 '*' 通配的语义:/member/* 要求至少再吃一段 ,所以 /member 本身不命中。业务同学写 activeRule: '/member' 时心里想的是"整个会员域",写进配置的却是"会员域下的所有子路径"。/report、/settle、/ops 全中这个坑。

影子路由------归属正确,但那个应用这次没部署:

bash 复制代码
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 表,它不属于任何应用,属于架构:

js 复制代码
// 显式回落:未部署的子应用,其路由应该回落到谁(架构上必须写下来的那张表)
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),然后用两种判据去判:

sql 复制代码
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。工具链里最流行的做法是"取所有声明里最高的版本,全局只给一份":

js 复制代码
// 策略一(流行做法):取所有声明中最高的下界,全局只给一份
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):

css 复制代码
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

三个结论:

  1. "取最高版本"是一种静默的违约 。它嘴上说"给你 18.2.0",实际把 ops 的 ^17.0.2 破了------而 ops 里 ReactDOM.render 的调用在新版本上只是警告,不会崩,所以这个问题会以"偶发 UI 不更新"的形式活很久。
  2. 最小实例划分的答案是"允许 2 个实例" ,然后把它们关进具体应用 :ops 留在 17,其余 5 个用 18。注意 minInstances 的口径:实例 = 一个具体版本 (witness 被钉住),所以即使 ^3.1.0 与 ^3.4.0 区间相交,先钉在 3.1.0 上就没法同时服务 ^3.4.0 的成员,只能再开一个。这恰恰是 CDN / import-map 的真实语义------你指定的是一个 URL、一个版本。
  3. 单例冲突要按"对"报,不按"库"报 。react 5 对冲突里只有 ops 是原因,但报告要给全 5 对,因为每一对都是一个迟早要处理的升级工单。

E4.inst 那一行是用 import('./singleton-mod.mjs?build=A') 与 ?build=B 模拟"两个子应用各自打包了同一份源码":同一个文件,两个 URL,两个模块实例,状态 3 vs 0,跨实例 instanceof 为 false。这就是 hook 报错背后的物理事实。

1.5 难点五:故障隔离,Promise.all 把可用性变成乘积

装 6 个区域,最直觉的写法:

js 复制代码
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 版本错位):

javascript 复制代码
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(真实系统里大部分跳转发生在域内):

scss 复制代码
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 直接跑。

bash 复制代码
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 至少吃一段):

js 复制代码
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 就结束:

js 复制代码
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;
}

仲裁不能用"第一个命中",要用具体度排序,并把并列显式报出来:

js 复制代码
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 差分,版本号只用来表达意图 ,不用来判断事实:

js 复制代码
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 × 提供者实际版本:

js 复制代码
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) };
}

实测这类"跨应用组合"的非法率:

sql 复制代码
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 第三张表:共享依赖与单例口径

js 复制代码
// 单例契约:声明为 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 钉成具体版本):

js 复制代码
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;
}

于是架构层要落的三条硬约束变成可校验的:

  1. singleton 列表里的库,两两区间必须相交(否则架构上就承认无法单例,得先统一版本再谈共享);
  2. 非单例的库允许分裂,但分裂数要最小化,且每个实例要能指名成员;
  3. 装配时按 witness(具体版本)去 import map 里取,不许"取最高" ------取最高是对 ^17 的静默违约。

2.4 装配层:隔离 + 超时预算 + 显式降级

Promise.all 换成"每个边界独立 settle,另加一个超时计时器":

js 复制代码
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 的逻辑就是四句话,退出码即结论:

js 复制代码
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:

scss 复制代码
E7 · FAIL  路由所有权唯一  (2 处重叠)
E7 · PASS  契约变更过 schema 差分  (30/30)
E7 · FAIL  发布组合合法  (3 个非法组合)
E7 · FAIL  共享单例无冲突  (1 个库冲突)
E7 结论:1/4 通过 → 退出码 1

接到流水线上,就是"任何一个子应用要合入主干,先跑组合校验":

yaml 复制代码
- 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 发布流水线里最容易翻车的三处

  1. entry 的 HTML 被 CDN 强缓存 :子应用新版发了,基座还拿着旧 entry,于是"某些用户永远停在旧版"。entry 必须 no-store,chunk 用内容 hash。
  2. 路由表与部署矩阵不同步:新应用上线前先把路由登记进所有权表,用户先拿到新表 → 影子路由白屏。顺序必须是"部署 → 校验探针 URL → 切路由"。
  3. 某应用私自升级单例库 :ops 把 react 从 17 升到 18 想"顺手清理技术债",本地一切正常(它自己一份),上线后和基座 18.2.0 拼在一起才炸。这条只能靠门禁拦------人类的评审看不见 package.json 里的一个 ^。

3.4 与前后篇咬合

  • Day08 微前端样式隔离:CSS 的边界是"在同一页里共存"的问题;
  • Day24 微前端实现:沙箱、路由接管、装载的实现骨架,是这一篇的运行时底座;
  • Day36 Monorepo:单实例判据(模块实例而非路径)在这里以"独立部署"的形式回归;
  • Day38 流水线:把 2.5 的门禁接进 CI,让判据在合入时生效而不是在复盘时生效。

四、总结

微前端不是技术选型,是组织边界的显式化。它的失败几乎从不来自"装载实现不好",而来自三张表没人写:

  1. 路由所有权表:归属唯一 + 顺序无关 + 裸前缀显式 + 回落表。缺了它,2/16 的 URL 归属随机、4/16 是死路由、3 处是影子路由。
  2. 契约兼容表:兼容性由 schema 差分决定,版本号只是载体。靠"major 就是破坏性"判,召回 46.7%、准确 66.7%;换成差分 30/30,且对严格消费者另给 8 条警告。
  3. 共享依赖单例表:单例是契约,口径是模块实例。"取最高版本"是静默违约(1 个应用破约);正确做法是最小实例划分(2 个实例,关进具体应用)。
  4. 装配层 :每个边界独立 settle + 超时预算 + 区域级降级。Promise.all 的可用区是 0.00/6,隔离版是 5.00/6,挂起场景 120ms 出骨架。
  5. 拆分维度:按域拆 37.5% 跨边界,按栈拆 85.0%------跨界调用是最贵的边,用调用图量化,别用组织架构猜测。
  6. 阶段判据:S0/S1/S2/S3 各有硬判据,没发生过独立发布就不能宣称运行时并行。

一句话记住口径:"能挂上去"是 Day24 的问题,"挂上去之后谁负责、坏了怎么办"是 Day37 的问题。 前者靠 API,后者靠表。

复现方式:

bash 复制代码
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)

javascript 复制代码
// 组合层:故障隔离、超时预算、加载拓扑(串行 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、能力声明、版本协商)

javascript 复制代码
// 应用间契约: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 隔离)

javascript 复制代码
// 路由所有权:装配顺序无关性 + 跨应用冲突检测
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 ------ 共享依赖(单例库、版本一致性、去重)

javascript 复制代码
// 共享依赖的架构决策:单例契约 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 ------ 版本语义(兼容区间、破坏性变更检测)

javascript 复制代码
// 极简 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 ------ 发布门禁(契约校验、灰度准入)

javascript 复制代码
#!/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 ------ 健康检查(子应用可用性、降级兜底)

javascript 复制代码
// 拆分维度:跨边界调用率------"按域拆"和"按技术栈拆"的量化差异
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 ------ 测试夹具(多子应用场景)

javascript 复制代码
// 一套贴近真实的中后台微前端组合: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 ------ 确定性随机(复现实验)

javascript 复制代码
// 确定性 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 ------ 单例模块探针

javascript 复制代码
// 用来证明"两个模块实例"的真实后果:状态分离 + 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 ------ 微前端架构全套断言

javascript 复制代码
// 断言集: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 ------ 基准主程序(编排开销/路由接管成本)

javascript 复制代码
// 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');
相关推荐
Linux-lucky2 小时前
45-Linux学习之旅之防火墙与WEB加固
linux·前端·网络·学习·ubuntu
风骏时光牛马4 小时前
生成一个移动端标题,只需要一个标题,不需要其他的任何文本。
前端
IT_陈寒4 小时前
Redis雪崩把我坑惨了,这是5年经验的教训
前端·人工智能·后端
SameX5 小时前
HarmonyOS ArkUI List 的侧滑删除 用 ListItem 的 swipeAction + endIcon 的做法
前端
usdoc文档预览7 小时前
USDOC系列之PPT文件在线编辑-VUE前端实现在线浏览器在线编辑PPT文档
前端·vue.js·编辑器·office365·ppt在线编辑·office文件在线编辑·文件在线编辑
用户2705077725247 小时前
Agent 调工具卡住?3 种超时与取消策略实测对比
前端
LuHang8 小时前
我一行代码没写,把领导派的活做成了公司内部系统😭
前端
明月_清风8 小时前
Meta 开源了一个 AI Native 组件库:Astryx,150+ React 组件,专为 AI Agent 而设计
前端·人工智能·ai编程
用户850869276158 小时前
秒杀系统中 Shell 脚本并发控制 Bug:究竟哪里出了
前端