跳转到内容

Remote 之间如何通信?不通信。

Remote 不彼此通信。它们与那些负责各自业务投影的数据源通信。

这并不意味着两个 Remote 之间永远不能出现任何技术 Signal。它意味着,一个 Remote 不应该订阅另一个职责范围内部的 Action、State Transition 或业务 Browser Event。即使技术上存在一个全局 Action Stream,它也不等于业务 Event Bus。

这不是为了“架构纯洁”而制定的任意规则,而是微前端之所以值得存在的业务封装所带来的直接结果。多个 Remote 可以同时出现在同一个浏览器窗口里,但这并不会把它们变成共享同一个 State Model 的相邻 Component。

视觉上靠得很近,不代表它们之间就存在通信关系。

在传统 Component 架构中,讨论“组件之间如何通信”很自然。Parent Component 传入 State,Child Component 发出 Event,相邻区域也可能通过一个共享 Store 协调。

这个思维模型经常被原封不动地搬到微前端:

Remote A
└── 传递 State 或 Event
Remote B

这样一来,Remote 只是被当成同一个应用中“特别大的 Component”。对于业务 State 来说,这正是错误的起点。

更合适的模型是:

Remote A ──▶ 负责它所需数据的数据源
Remote B ──▶ 负责它自己所需数据的数据源

两个 Remote 完全可以读取同一份服务端事实,也可以使用同一业务数据的不同 Projection。但它们不会在浏览器里横向同步彼此的本地 State。

把 Remote 分开,并不是为了之后再通过 Browser 把它们内部的 State Model 重新接回去。那样做只是拆开了 Bundle、Deployment Artifact 或 Repository 区域,紧接着又把业务耦合恢复了。

微前端不是某个“会神奇地一起响应”的大型应用中的 Component。它是一套能够独立运行、再被 Host 集成进产品的应用。

这种应用边界不仅仅意味着一个单独构建的 JavaScript Bundle。一个 Remote 应该拥有自己的 Lifecycle,能够单独启动和测试,并对自己的业务 State 负责。它可以比其他 Remote 更晚加载,也可以独立升级或回滚。它甚至可能出现在多个 Host 中,通过不同 Origin 发布,或者使用不同 Framework Version。

并不是每个系统都必须把这些独立性用到极致。但它们定义了架构应该尊重的边界。

一个独立应用不会因为同一浏览器窗口里的另一个应用修改了某个 State,就自动得到更新。Reload 之后,它也不知道此前执行过哪些 Action。如果它后来才加载,那么此前的 Browser Event 已经错过。如果它运行在另一个 Host 或 Tab 中,甚至可能不存在同一个 JavaScript Context。

这不是需要用一个“足够聪明的 Event Bus”修复的技术缺陷,而是真正独立之后的自然结果。

Remote 不应该直接通信,不是因为 Browser 做不到,而是因为业务上不应该依赖这种通信。

最明显的横向耦合,就是直接 Import:

Calendar-Remote
从 Profile-Remote 导入 API 或 State

也许 Profile-Remote 导出了一个 Service、Store,或者一个读取当前 Profile 数据的函数。Calendar-Remote 觉得两者反正同时运行在 Browser 中,于是直接使用这个接口。

从这一刻起,Profile-Remote 就变成 Calendar-Remote 的 Runtime Dependency。后者能否工作,取决于另一个 Remote 是否可用、是否被激活、版本是否兼容。本地开发和隔离测试突然需要额外 Container。Rollback 可能破坏已有假设。加载顺序开始重要。原本从未被设计为稳定 Contract 的 Profile 内部 API,事实上变成了一套 Public API。

Ownership 也开始模糊。Profile Team 还能自由修改那个导出函数吗?它必须知道所有 Consumer Remote 吗?当新数据模型需要在多个页面同时启用时,谁来协调 Release?

一个 Remote 不是其他 Remote 的 Runtime Library。

这并不排斥共享且有明确 Owner 的技术 Library。一个经过设计、明确发布、稳定维护的 Contract,与访问另一个正在运行的 Remote 的 State 或 Application Service,是两件完全不同的事。可复用 Component 与 Shared Library 值得单独讨论,但它们解决不了业务 State 横向同步的问题。

更隐蔽的耦合方式,是所有 Remote 共用一个 Dispatcher。

用户在 Profile-Remote 中修改姓名。保存成功后发出一条 Action:

Profile-Remote
└── dispatch: profileSaved
Calendar-Remote
└── reacts to profileSaved

乍看起来很干净。Calendar-Remote 没有 Import Profile Service,甚至不认识它的 Component,只监听一条 Action,然后刷新自己显示的姓名。

实际上,它已经知道了 Profile-Remote 的内部语言。它知道 Action 名称、Payload 和 State Transition。它假设某个技术动作会恰好在业务修改真正完成的时刻发生,也开始依赖顺序、投递和 Lifecycle。

如果 Calendar-Remote 在 Dispatch 时还没加载,它就错过了 Action。Reload 后,这条 Action 不会自动重放。另一个 Browser Tab 也不会自动收到它。旧版本 Remote 可能用不同方式理解 Payload。Action 被重命名或拆分,会变成 Breaking Change。Rollback 之后,Producer 与 Consumer 甚至可能持有不同契约假设。

更关键的是,Consumer Remote 把一个内部流程步骤当成了业务事实。profileSaved 首先只描述 Profile-Remote 做了什么。它并没有自动说明 Calendar-Remote 现在应该使用什么数据、需要哪种 Projection,也不能证明所有服务端处理都已经完成。

Action 属于负责该领域的 Remote 内部 State 与流程模型。即使底层技术提供了共享 Dispatcher,其他业务区域的 Action 也不是 Public Integration API。

全局 Action Stream 不是业务 Event Bus。

业务 Browser Event 也没有解决问题

Section titled “业务 Browser Event 也没有解决问题”

于是有团队会把 Store Action 换成听起来更中立的 Browser Event:

profileUpdated
appointmentChanged
patientSelected
invoiceCancelled

全局 Event Bus 一开始看起来很解耦。Producer 和 Consumer 不直接认识彼此。在 Proof of Concept 中,一层很小的 JavaScript 封装,就能让页面上的变化立即同步出来。

但系统活得越久,问题就越像任何其他分布式通信:晚到的 Subscriber 怎么办?Event 要不要 Replay?顺序如何保证?要不要确认或去重?Reload 或第二个 Tab 呢?Event 里只带 ID,还是包含完整 State?谁拥有 Schema 和 Versioning?它只是 Notification,还是已经承担业务真相?

如果 Event 只是一个可丢失的优化提示,其中很多问题可以不回答。但一旦要求持久、可靠、有序、可重复投递,Frontend 就开始自己重建一套分布式 Event System。

当 Browser Event 开始需要持久化、确认、重放或顺序处理时,Frontend 已经在实现一个更不可靠的 Event Broker 或 Outbox 机制。

业务一致性不应该依赖短暂的 Browser Event Bus。

全局业务 Store 是隐藏的 Frontend Monolith

Section titled “全局业务 Store 是隐藏的 Frontend Monolith”

第三种做法看起来完全避免了 Remote-to-Remote Communication:

Profile-Remote ──────┐
Calendar-Remote ─────┼── 共同拥有的业务 Store
Billing-Remote ──────┘

所有 Remote 都读写一个中央业务 State。变化立即可见,额外 Request 似乎也不再需要,集成看起来非常简单。

但横向耦合并没有消失,只是被搬进了一个共享 State Container。

现在这些 Remote 共享 Data Model、Action、Selector 和有效性规则。多个 Team 修改同一份业务真相。新版 Profile-Remote 可能写入旧版 Calendar-Remote 尚不能理解的 Store Schema。Rollback 也更困难,因为 Code 和 State 已经无法独立考虑。Test 必须准备全局 State Transition。单独启动某个 Remote 时,还依赖 Host 或其他 Remote 先把全局 Store 初始化好。

如果 Remote 只有依赖共同业务 Store 才能工作,那么被拆开的只是 Bundle,而不是应用。

当 Framework 不同时,问题还会进一步扩大。Angular、React 和 Vue Remote 当然可以通过同一个 JavaScript 抽象访问 Store,但这并没有回答 Reakivity、Subscription、Lifecycle、Error Handling 和 Compatibility 如何可靠协作。

所谓“中立基础设施”很快就会成长为一个自研的跨 Framework State Framework。团队除了维护各自应用,还要共同维护一个所有 Remote 都必须理解的 Runtime Contract。

横向耦合的 Remote 共享 Action、Event 与业务 State;独立的 Remote 则从负责事实的数据源获取自己的 Projection。

技术上全局,不等于业务上全局

Section titled “技术上全局,不等于业务上全局”

这里批评的是“多个独立 Remote 共同拥有一份业务 State”,而不是 Redux、NgRx 或技术上存在全局 Store Instance 本身。

传统应用很常见的一种结构,是中央提供一个 Root Store:

Root Store
├── Calendar Feature State
├── Profile Feature State
└── Billing Feature State

技术注册是全局的,但业务区域仍然可以彼此封装。每个 Feature 拥有自己的 Reducer、Action、Selector、Effect 和 State Model,其他 Feature 不需要订阅或修改这些内部实现。

微前端系统中也完全可以在 Remote 自己的 Container 中提供 Store:

Host
└── Calendar-Remote
└── own Store
└── Calendar State

这样,State 和 Lifecycle 仍然归 Remote 所有,Host 不必因此承担该业务模型的职责。

真正的 Anti-Pattern 不是“Store Instance 是全局的”,而是“独立 Remote 共同拥有业务 State”。

一个 Remote 内部当然可以使用 Redux、NgRx、Signal Store 或其他 State 方案。问题出现在多个 Remote 共同读写同一业务 State、把其他业务区域的 Action 或 Selector 当成 Integration Contract,或者离开 Host 的全局 State 就无法独立运行的时候。

近年来更常见的局部 Store,只是让这条边界更加明显。现代 Store 可以更靠近 Feature 或 Remote 提供,因此 Lifecycle 更清楚。这并没有推翻传统 Global Store Infrastructure,只是在提醒我们:技术 State 不需要因为“应用里只有一个 Store”就自动变成所有业务区域共同拥有的 State。

业务 State 应该生活在真正对它负责的边界内。

一个具体例子可以说明区别。

用户在 Profile-Remote 中修改姓名:

Profile-Remote
└── writes change to Backend

另一个位置的 Calendar-Remote 也展示这个用户姓名。最自然的第一反应通常是:保存成功后,Profile-Remote 必须把新姓名告诉 Calendar-Remote。

于是很容易得到 Action 或共享 Profile Object 两种方案:

Profile-Remote
└── profileSaved ──▶ Calendar-Remote

或者:

Profile-Remote
└── writes Profile object into global Store
Calendar-Remote

两种方案都要求 Calendar-Remote 接受 Profile-Remote 的 State Model。可 Calendar 根本未必需要一个完整 Profile。

Profile-Remote 处理姓名、联系方式、设置和其他可编辑 Profile 数据。Calendar-Remote 可能只需要显示名、缩写和可用性。Billing-Remote 关心的则是账单地址和付款方。Document-Remote 甚至可能只需要一个符合法律格式要求的姓名。

这些模型可以建立在同一底层数据之上,但它们并不是同一个模型。

Profile-Remote
└── 完整 Profile 数据
Calendar-Remote
└── 显示名、缩写、可用性
Billing-Remote
└── 账单地址、付款方

因此,一个 Remote 不是“把同样的数据再加载一次”,而是在读取适合自身 Use Case 的 Projection。

更稳健的模型是:

Profile-Remote ── writes ──▶ responsible source
Calendar-Remote ── reads when needed ──▶ own projection

写入的 Remote 拥有用户 Intent。负责该领域的服务端 Source of Truth 拥有业务事实。其他 Remote 则拥有适合各自 Use Case 的 Projection。

这样,没有任何 Remote 需要知道另一个 Remote 内部的 Action、Store Schema 或 State Transition。

Calendar-Remote 并不需要知道“Profile-Remote 刚刚执行了一次 Save”。它只需要能够识别自己的 Projection 什么时候可能已经过期。

实现策略可以很多。进入某个区域时加载 Projection,并在业务上合理的时间后 Revalidate;重新获得 Focus 时触发检查;只针对单条数据重新读取;通过 HTTP Cache、Query Cache 或本地 Store 避免不必要的传输;在适合的场景使用 Polling、Server-Sent Events 或 WebSocket。

关键不在具体机制,而在依赖方向。

例如服务端可以发送一个 Signal:

User data for user 4711 has changed.

Calendar-Remote 收到后,可以把自己关于用户 4711 的 Projection 标记为失效,并在需要时重新读取。这个 Signal 不携带完整 Profile Model,也不会把外部 State 写进 Calendar Store。

应该失效的是 Projection,而不是在 Remote 之间同步业务 State。

这个区别很重要:Calendar-Remote 不是响应 Profile-Remote 的内部流程,而是在响应“我自己的数据可能已经过期”这个事实。新的业务真相仍然通过它自己的 Backend、BFF 或 Integration Contract 读取。

Profile-Remote
└── writes change
Backend / responsible source
├── persists truth
└── optional invalidation signal
Calendar-Remote
└── reloads own projection

即使某个 Invalidation Signal 丢失,也不能导致业务真相永久丢失。因此 Remote 仍然需要一套可靠的 Revalidation Strategy。Signal 只是“可能已过期”的提示,不是负责事实的数据源本身。

Profile-Remote 写入变化,Backend 保存业务事实,Signal 使 Projection 失效,Calendar-Remote 再读取自己的 Projection。

那每个 Remote 都要不停重新加载吗?

Section titled “那每个 Remote 都要不停重新加载吗?”

Performance 方面的质疑很合理。每个 Remote 自己管理 Projection,的确可能带来更多 Request。网络成本和 Latency 不会因为边界设计得好就自动消失。

但独立并不意味着每次交互都无差别重载全部数据。它意味着 Remote 自己负责自己的数据需求和 Freshness Rule。

Calendar-Remote 知道自己需要什么数据,也知道在具体 Use Case 中,多长时间内可以把 Projection 视为足够新。它可以只 Revalidate 一条记录,而不是重建整个 Calendar;可以在本地或 HTTP 层 Cache Response;可以合并相同 Query;可以使用服务端预聚合 Projection;也可以把刷新延后到下一次真正需要这些数据的业务访问。

Remote-specific Projection 往往比全局 Model 更小。Calendar-Remote 如果只显示姓名,就没有理由拿到完整 Profile Object;同样,它也不必遵循“编辑联系方式”那个 Use Case 的有效性规则。

一个全局 Profile Object 看起来高效,因为只加载一次。实际上,它让所有 Consumer 依赖同一个 Schema、同一个 Version 和同一套 Freshness Rule。为了一个 Use Case 修改它,可能影响所有 Remote。

Performance Optimization 是各 Remote 及其 Backend Contract 的职责,而不是横向共享业务 State 的理由。

重复 Request 是 Performance 问题。共同拥有业务 State 是架构问题。不要为了消灭前者,轻率地引入后者。

传统应用里的内部 Feature 经常可以假设:Global Store 里已经有最新 State。微前端不应该不加思考地继承这个假设。

Remote 可以单独启动,可以运行在另一个 Host,可以晚些加载,也可以使用不同 Version。Reload 后,它会重新建立自己的 State。它并不知道自己启动之前另一个 Remote 发过哪些 Action。

因此,真正独立运行的应用不会神奇地收到所有业务更新。它需要自己的 Revalidation Strategy;Invalidation Signal 可以帮助这套策略,但不能替代它。

这不是架构的弱点,而是封装真正成立之后的结果。

如果团队无法接受这个结果,也许真正需要的并不是独立微前端,而是一个共同发布应用内部的模块化 Feature。这完全可能是更合适的架构。问题只在于:一边承诺独立 Remote,一边又让它们必须依赖同一个共享业务 Runtime Model 才能工作。

不是所有 Global Signal 都代表业务真相。

Host 可以为技术平台协调提供一小套稳定 Contract。根据系统需要,其中可能包括 Navigation、Logout、Locale、Theme 或 Remote 加载失败:

navigateTo
logout
localeChanged
themeChanged
remoteLoadFailed

这些东西也不一定必须做成 Event。重要的是它们属于哪一类:它们协调平台或 UI,而不是发布 Profile、Calendar 或 Billing Remote 的业务 State。

平台 Context 可以共享。业务真相不应该放进 Global Browser Store。

Global Event 的边界究竟应该画在哪里、Host 应该提供哪些 Contract,是另一个独立主题。对于业务 State 通信,这里只需要保持一个区别:技术平台协调,不等于横向 State Synchronization。

一个业务流程可以跨越多个 Capability

Section titled “一个业务流程可以跨越多个 Capability”

“Remote 不彼此通信”并不意味着一个业务流程永远不能跨越多个业务区域。一笔订单可能同时涉及购物车、客户、支付和配送。某些用户交互会在多个 Capability 之间显式流转,一个区域中的行为也可能对另一个区域产生业务影响。这并没有推翻前面的结论。

它只是说明:这里不是用 Remote-to-Remote Communication 解决问题的地方。

跨 Domain 的业务流程需要 Orchestration,但这并不等于需要 Remote 之间互相调用。

一种看似自然、实际上有问题的流程是:

Cart-Remote
→ sends order data to Payment-Remote
→ Payment-Remote sends result to Shipping-Remote

这样做,会按照屏幕上恰好存在的 Frontend Boundary,把业务 Process Logic 随机地分布出去。下一步由哪个 Remote 触发,取决于哪个 Remote 先被创建、哪个 Team 先需要接口,而不是取决于谁真正拥有这条业务流程。

因此,“Remote 到底能不能互相通信”其实还是问错了。更好的问题是:这条业务流程归谁负责?

跨 Domain 流程需要一个明确承担业务职责的 Orchestration 位置。它不自动属于参与流程的某一个 Remote,也不自动属于 Global Frontend Store 或 Browser Event Bus。根据产品形态,它可能是一个上层业务 Capability、Backend Orchestration、长期运行的 Workflow / Process Manager,或者一个只传递技术 Navigation / Intent 的显式平台 Contract——但不是横向业务 State Synchronization 本身。

没有哪一种形式是普遍正确的答案。选择取决于风险、一致性要求和现有 Backend Architecture。

参与流程的 Remote 仍然保持本文前面描述的边界:读取自己的 Projection,通过自己的 Contract 写入,不了解其他参与区域内部 State。Process 负责协调它们,而不是把它们重新融合成一个应用。

在第一个 Prototype 中,共享 Store 或 Browser Event Bus 通常非常有说服力:变化立即出现,少了一次 Request,技术成本低,Demo 也工作得很好。

真正的成本往往以后才出现。

Version 不能再独立激活。Rollback 必须考虑未知 Consumer 的假设。Test 需要多个 Remote 和全局 State Transition。Bug 开始取决于加载时间、顺序或 Browser Tab。多个 Team 修改共同 Model,却没人再能明确说谁拥有它。本地开发变难,因为离开 Host、Event Bus 或 Global Store 后,一个 Remote 已经不能完整运行。

Prototype 里省掉的一次 Request,最终可能用共同 Ownership 和耦合 Release 来偿还。

相反,独立 Projection 的模型可能带来额外 Read 和 Revalidation,但 Contract 保持显式。每个 Remote 控制自己的 State、Lifecycle 和 Freshness Rule。Profile Model 改变,不需要自动导致 Calendar 改变。Calendar-Remote 也可以独立 Rollback 或测试,不必重建 Profile-Remote 的内部流程。

这种分离不是免费的。它只是把工作放到了更正确的位置:明确的 Projection、有 Owner 的 Backend Contract,以及由业务需求决定的 Freshness Rule。

前面的论证会导出一些很明确、也很简单的后果。

Remote 不订阅另一个职责区域的业务 Action。业务 Browser Event 不作为横向 State Channel。多个 Remote 不通过 Global Store 共同拥有业务 State。一个 Remote 也不 Import 另一个运行中 Remote 的 Application Service 或 Store Internal。

相反,每个 Remote 拥有自己的 Projection,并通过自己的 Contract 读取它。可选 Signal 可以告诉它“这份 Projection 可能过期了”,但新的业务事实仍然来自真正负责该事实的数据源。

这并不意味着每个 Remote 都必须拥有独立 Backend。Frontend 和 Backend 的切分仍然是两项独立决策。多个 Remote 可以使用同一个 API、共享 BFF 或 Frontend-near Integration Layer。关键在于:它们的 Contract 与 Projection 不来自其他 Remote 的内部 State Model。

Remote 不需要知道另一个 Remote 内部发生了怎样的修改流程。它只需要能够判断自己的 Projection 什么时候不再可靠。

把 Remote 当成真正独立的应用,那么“它们如何通信?”最终会回到更正确的问题:State 属于谁?

Remote 不彼此通信。它们与那些负责各自业务投影的数据源通信。