从构建时集成到运行时集成,前端架构发生了什么变化
很多团队做微前端,一开始想到的都是构建时集成。所有子应用共用一套工程配置,把各自的代码打成不同的 chunk,然后在主应用构建时组合到一起。在整个团队规模不大、发版节奏一致的时候,这么做没有太大问题。可一旦子应用多了,某个业务需要独立发版,构建时集成的缺陷就会暴露:一个两行代码的改动,可能要触发整个主应用重新构建、回归、发布。

这也是 Module Federation 2.0 这类运行时集成方案的现实背景。它的目标很直接:把代码组合的时间点从构建阶段推迟到浏览器运行时。子应用不必同时打包,而是分别部署,在用户访问时按需加载、组装。这是前端微服务真正能做到的形态,但代价也随之而来。
要理解运行时集成,先要建立一个新的认知:过去代码之间的关系是“导入”,现在变成了“协商”。在构建时,import 对应的是确定的文件路径和版本;在运行时,远程模块是否存在、版本是否兼容,都只有在浏览器执行到那一刻才能确定。这种不确定性带来了极大的灵活性,同时也把一部分构建期错误推到了运行期。
Module Federation 2.0 的运行时模型
Module Federation 1.0 解决了远程模块能不能加载的问题,2.0 解决的是如何让这套机制更可控、更接近一个可治理的运行时框架。如果只从配置看,它跟 1.0 有很多相似之处;但深入看,2.0 把远程容器、依赖共享和模块加载都统一到了一个完整的运行时模型中。
在这个模型里,一个典型应用分为 host 和 remote。host 是主入口应用,remote 是暴露模块的远程应用。remote 通过 exposes 声明哪些模块可以对外使用,host 通过远程加载拿到这些模块。它们之间并不存在构建时的强依赖,所有依赖关系都在运行时建立。
// remote 侧,以 Webpack 配置为例
module.exports = {
name: 'remote_checkout',
exposes: {
'./CheckoutPage': './src/CheckoutPage',
'./useCart': './src/useCart',
},
shared: {
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true },
},
};
name 是远程容器的标识,exposes 定义可对外暴露的模块,shared 声明希望共享的依赖。共享依赖是运行时集成的核心机制,稍后会重点说。
在 host 侧,2.0 提供了运行时 API,可以动态加载远程模块:
import { loadRemote } from '@module-federation/runtime';
const CheckoutPage = React.lazy(() =>
loadRemote('remote_checkout/CheckoutPage')
);
这种加载方式不再依赖构建时静态配置。远程地址可以存到配置中心,也可以根据用户环境动态切换。这就是 2.0 强调的“运行时”能力——远程模块的绑定关系在应用运行之后才完成。
MF2.0 与常见微前端方案的差异
很多团队会拿 MF2.0 与 iframe、qiankun 这类方案对比。它们都属于运行时集成,但粒度完全不同。iframe 是页面级隔离,qiankun 是应用级加载,MF2.0 则深入到模块级。这不是说模块级一定最好,而是它的灵活度更高,同时对架构治理的要求也更高。
我们可以从几个关键维度来看它们的取舍:
| 维度 | 构建时集成 | iframe | MF2.0 |
|---|---|---|---|
| 依赖共享 | 通过 npm 包间接共享 | 无法共享 | 运行时共享,版本可协商 |
| 独立发布 | 困难,一次构建整体发布 | 容易 | 容易,远程模块独立部署 |
| 集成粒度 | 应用级 | 页面级 | 模块级 |
| 调试体验 | 单仓库调试,相对简单 | 边界强,通信调试复杂 | 需要依赖契约和工具链支撑 |
| 故障边界 | 构建失败影响全体 | 隔离性好 | 需要额外设计容错策略 |
从这个表格能看出来,MF2.0 最大的优势是“模块级运行时集成”,但代价是系统复杂度上移。如果你只有两三个应用,且很少独立发版,用构建时集成反而更划算。MF2.0 更适合多团队、多发布节奏、业务边界清晰的场景。
共享依赖到底在协商什么
很多人对 shared 的第一个理解是:把公共依赖写进去,就不会重复加载了。实际上,shared 配置只是在声明一种共享意向,最终是否共享,取决于运行时版本协商的结果。
以 React 为例,如果所有应用都声明 shared: { react: { singleton: true, requiredVersion: '^18.0.0' } },运行时在加载新模块前会先检查当前已有的 React 实例是否符合版本范围。符合就复用,不符合只能再加载一份。如果配置了 singleton,运行时还会强制只保留一个实例,即使版本不匹配,也硬塞给远程模块。这时如果远程模块用到了较新版本的 API,就可能在运行时报错。
所以真正健康的依赖共享,不只是写配置,更要在团队层面形成依赖治理规则。通常需要考虑三个问题:
- 哪些依赖必须加入 shared?通常是框架、状态管理、路由这类需要保持单实例的库。
- 版本范围控制在多小?范围越宽,越容易复用,但兼容风险也越高。
- 谁有权限升级公共依赖?如果每个 remote 都可以随意升级 React,版本战场迟早会出现。
在真实项目里,我见过更微妙的场景:两个 remote 都声明了 shared 一个 UI 组件库,但版本分别是大版本 1 和 2。版本协商结果可能是各加载各的,于是页面上出现两套组件实例,CSS 错乱、事件绑定重复。这时“共享”反而成了问题源头。所以共享依赖不是越多越好,而是越受控越好。
动态加载只是第一步,容错才是工程关键
MF2.0 把模块加载变成了运行时行为,意味着远程模块的可用性不再像传统构建一样可控。任何一个 remote 发布错误、CDN 抖动、接口 404,都可能导致主应用崩溃。动态加载能力越强,对容错设计的要求越高。
在代码层面,至少要对远程加载做统一兜底:
async function loadRemoteModule(remoteId) {
const fallback = () => ({ default: () => null });
try {
return await loadRemote(remoteId);
} catch (err) {
reportError({ remoteId, err });
return fallback();
}
}
这只是基础设施。更重要的还有两类问题:一类是 remote 的发布版本兼容性,另一类是 remote 之间的通信契约。MF2.0 并没有提供像“接口文档自动校验”这样的功能,这些需要团队自己建立。最靠谱的做法,是把 exposed 模块的输入输出定义成 TypeScript 类型,并作为独立包在 host 侧校验,在运行时再做一层数据校验。
一个实际困扰是:remote 更新后,host 可能缓存了旧版本,或者用户访问的瞬间恰好遇到 remote 灰度发布。这种问题很难用单元测试覆盖,只能靠监控和可观测性来发现。我的经验是,至少要在日志里记录远程模块的加载耗时、加载版本和失败率,不然出了问题连定位都无从下手。
落地 MF2.0 前的几个判断
不是所有项目都适合用 Module Federation 2.0。如果团队只有一条产品线,发布节奏统一,个体应用之间几乎没有独立部署诉求,那用普通 monorepo 可能更简单。MF2.0 真正的价值出现在这些情况下:多个团队各自维护独立前端应用,需要共享一部分页面或组件,但发版时间表不同。
有一种改造路径相对稳妥:先选一个业务边界最清晰的远程模块,由主应用动态加载。这个模块不要太小,也不要太复杂,最好是一个完整的业务页面。先跑通远程加载、共享依赖、错误上报、灰度切换这一整条链路,然后再逐步把更多应用纳入。
在扩展过程中,下面这几件事往往决定最终成败:
- 明确远程模块的所有权和版本策略,绝不允许远程模块之间直接互相依赖。
- 把 shared 依赖当作公共 API 管理,升级前必须考虑所有消费方。
- 建立完善的 remote 监控大盘,加载成功率比构建通过率更重要。
另一个容易忽略的点是环境的动态性。MF2.0 的运行时集成让 remote 地址可以外部化,但如果你没有一套配置中心或动态注入机制,这些地址最终还是会写死在代码里,只是把构建时问题平移到了配置时。真正发挥“运行时”价值,需要配合前端发布系统,做到 remote 地址按环境注入、按版本灰度调整。
别把运行时集成当成银弹
Module Federation 2.0 是一个相当精巧的运行时集成方案,它把微前端的组合粒度细化到了模块级别,也让独立部署成为现实。但方案本身并不会自动带来一个好的微前端架构。代码是运行时组装了,团队是否准备好面对版本协商、故障链路和跨应用契约,才是真正的分水岭。
如果让我给一个建议:先不要急着把整个项目迁移到 MF2.0,而是先把它用在最需要独立发布的业务线上,把运行时集成的机制、监控、容错都跑通,证明团队能控制这种动态性。等这些基础打牢,你才有底气把更多应用交给它。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/636/