微前端选型时容易忽略的构建差异:Module Federation 和 qiankun 在部署环节到底怎么取舍
选型时只看开发体验和运行时隔离,却忽略构建与部署环节,相当于买了一辆底盘有缺陷的车——跑起来才发现问题。Module Federation 和 qiankun 在部署策略上的本质差异,会直接决定你的 CI/CD 流水线复杂度、版本管理策略,甚至回滚方案。
部署粒度:谁拥有子应用的发布节奏?
Module Federation 的部署模型是"整体协调发布",而不是字面上的独立部署。微前端架构里最诱人的承诺就是子应用独立发布,但 MF 在这一点上存在明显的限制。当你暴露一个模块给宿主消费时,宿主构建时会将远程模块的接口契约编译进自己的产物。如果子应用改了 exposed module 的接口签名——比如改了函数参数、组件的 props 类型——宿主不重新构建,运行时就会直接抛错。这不是运行时兼容问题,是构建时的类型契约被写死了。
qiankun 不存在这个限制。子应用是一个完整的 HTML 入口,宿主通过 fetch 拉取 HTML、解析 JS/CSS 再挂载,构建产物之间完全解耦。子应用改了内部逻辑甚至路由结构,只要入口 HTML 的路径不变,宿主无感知。这是真正的独立部署:子应用团队按自己的节奏发版,不需要通知宿主团队。
但代价是 qiankun 在部署层面引入了"入口 HTML 的版本一致性"问题。子应用的 index.html 里引用的 JS/CSS 文件名带了 hash,如果部署时 HTML 先更新而 CDN 上的 JS 还没同步,宿主拉到的 HTML 指向不存在的资源,子应用就挂了。MF 没有这个问题,因为远程模块的加载是通过 Webpack 的 runtime chunk 管理的,资源列表直接内嵌在构建产物的 manifest 里。
版本策略:共享依赖的升级谁来负责?
MF 的 shared 配置是部署环节最容易出生产事故的地方。shared 依赖的版本协商发生在运行时,但决定哪些版本被纳入 shared 是在构建时。假设宿主和三个子应用都声明了 shared: ['react'],Webpack 会检查各自 node_modules 里 react 的实际版本,然后按语义版本规则决定加载哪个版本。问题出在:如果子应用 A 构建时用的是 react@18.2.0,子应用 B 用的是 react@18.3.1,宿主构建时用的是 react@18.2.0,最终运行时加载的版本取决于谁先被加载以及 requiredVersion 的配置。这个行为在不同 Webpack 版本里还不完全一致——5.74 之前和之后的 singleton 处理逻辑有改动。
更隐蔽的问题是:shared 依赖的补丁版本升级需要全部消费者重新构建。假设你发现 react-dom 有个安全漏洞需要从 18.2.0 升到 18.2.1,按照语义版本这是向后兼容的补丁。但 MF 的 shared 模块判定"相同版本"的方式是精确匹配 version 字段,如果你设置了 singleton: true 且 strictVersion: true,版本不匹配就会在控制台报警告。要消除这个警告,所有声明了该 shared 依赖的应用都要重新构建部署。这就变成了一个"协调发布"问题——和独立部署的初衷背道而驰。
qiankun 处理共享依赖的方式简单粗暴得多:要么通过 externals 把 react、react-dom 这些基础库挂到 window 上,所有子应用共享同一个实例;要么每个子应用各自打包依赖,完全独立。前者省体积但限制了子应用的技术栈自由度,后者浪费带宽但真正做到了技术栈无关。在部署层面,externals 方案需要确保 CDN 上的基础库 URL 永远可用且版本不被误删——这听起来简单,但经历过 2023 年 1 月 unpkg 宕机事件的人都知道把基础库托管在公共 CDN 上的风险。
回滚与灰度:线上出问题时的止损能力
MF 的部署回滚有个反直觉的陷阱:子应用回滚了,但宿主没回滚,远程模块的接口契约可能对不上。假设子应用的 v2.0 改了一个 exposed 函数的返回值结构,宿主针对 v2.0 构建的。子应用因为 Bug 回滚到 v1.0,宿主代码里调用的那个函数返回的结构和 v1.0 不一致,运行时直接报错。正确的做法是宿主和子应用一起回滚,或者子应用保持接口向后兼容。这意味着 MF 架构下的回滚粒度是"整个应用系统",而不是单个子应用。
qiankun 的回滚就简单了:子应用回滚就是把入口 HTML 指回上一个版本的路径,或者把 CDN 上子应用目录的符号链接切回旧版本。宿主完全不感知,因为宿主只认入口 HTML 的 URL,拉到的 JS/CSS 是什么版本它不关心。灰度策略也一样——qiankun 可以在网关层根据 cookie 或 header 把不同用户的子应用请求路由到不同的版本目录,MF 要实现同样的效果需要在构建层面做更多工作,比如用 Webpack 的 ModuleFederationPlugin 结合 runtime 的动态 remote 配置。
但 qiankun 在回滚时要注意一个细节:子应用入口 HTML 里如果引用了带 hash 的资源文件,回滚时必须确保旧版本的 HTML 和旧版本的 JS/CSS 一起存在。有些团队把子应用的静态资源部署到按版本号命名的目录下(比如 /subapp/v1.2.3/),入口 HTML 直接指向这个目录,回滚就是改网关配置指向旧目录。这种做法比依赖 CDN 缓存回滚靠谱得多。
CI/CD 流水线设计:构建产物结构决定复杂度
MF 架构下的 CI/CD 需要解决"构建顺序依赖"问题。宿主构建时需要知道远程模块的入口地址,这个地址通常是子应用部署后的 actual URL。如果子应用还没部署,宿主构建时 remote 地址是占位符,部署后再替换——这引入了额外的配置管理步骤。实际做法一般有两种:一是在构建时使用子应用的开发环境地址,部署后通过环境变量替换;二是使用 Webpack 的 promise-based dynamic remotes,在运行时动态加载 remoteEntry.js 的地址列表。
第一种做法的问题是:如果子应用的开发环境挂了或者网络不通,宿主的 CI 流水线就卡住了。第二种做法更健壮,但需要额外维护一个 remote 地址的配置服务,且在运行时加载 remoteEntry 会增加首屏的串行请求。qiankun 没有这个负担,子应用的入口 HTML 地址是配置在宿主的运行时配置里的,构建阶段宿主完全不需要知道子应用的存在。
还有一个容易被忽略的差异:构建产物的缓存策略。MF 的 remoteEntry.js 文件不能用强缓存(Cache-Control: max-age=31536000),因为它的内容很小但变化频繁——任何 exposed module 的内容变了,remoteEntry 的 hash 就变。如果 remoteEntry 被 CDN 缓存了,宿主加载的还是旧版本的模块列表,可能导致加载不到最新的 chunk。通常做法是给 remoteEntry 设置协商缓存(ETag/Last-Modified),但 CDN 厂商对协商缓存的支持参差不齐。qiankun 的入口 HTML 也是同样的缓存问题,但 HTML 文件通常不会设置强缓存,运维团队对此已经有成熟的处理方案。
监控与排障:线上报错时能追溯到哪个版本?
MF 架构下,线上报错堆栈里的文件都是 Webpack 构建产物,文件名类似 src_components_Button_tsx.chunk.js,版本信息需要额外注入。如果你在构建时通过 Webpack 的 DefinePlugin 注入版本号到 process.env.APP_VERSION,这个版本号会被打进 chunk 里。但问题是,当宿主加载了多个远程模块时,一个页面可能同时运行着不同版本子应用的代码,报错堆栈里看不出是哪个子应用、哪个版本出的问题。
qiankun 的排障相对清晰:子应用的 JS 文件 URL 里通常包含版本 hash 或版本路径,从报错的文件名就能定位到具体版本。而且 qiankun 的沙箱机制让每个子应用的全局变量隔离,出现问题时更容易判断影响范围。
MF 在这方面需要额外的基础设施:要么在构建时给每个 chunk 文件名注入子应用名称和版本号(修改 Webpack 的 output.chunkFilename),要么通过运行时上报把当前页面上所有远程模块的版本信息定时发送到监控平台。这两种做法都需要团队在项目初期就规划好,后期补成本很高。
常见问题
Module Federation 到底能不能实现子应用独立部署?
可以,但有前提条件。子应用必须保证 exposed module 的接口向后兼容——改了接口就要通知宿主团队重新构建。如果团队能做到严格的接口版本管理和契约测试,MF 可以实现接近独立部署的效果。但实际项目中,跨团队协调接口变更的成本往往被低估。另一个方案是使用 Webpack 的 @module-federation/enhanced 插件(2024 年发布),它支持 runtime plugin 机制,可以在运行时动态加载不同版本的远程模块,部分缓解了构建时契约绑定的问题。
qiankun 和 MF 能混用吗?
技术上可行,但不建议。宿主用 qiankun 加载子应用,子应用内部再使用 MF 暴露模块给其他子应用——这就形成了两层微前端架构,调试和排障的复杂度翻倍。更实际的做法是:如果主场景是多个独立子系统拼装成一个大平台,用 qiankun;如果主场景是一个重型应用需要动态加载不同的功能模块,且这些模块由不同团队维护但共享基础库,用 MF。选一个就够了,混用是给自己找麻烦。
共享依赖到底是 externals 好还是 shared 好?
取决于你的运维能力和容忍度。externals 方案简单可控,但要求所有子应用使用相同版本的基础库,升级时需要协调所有团队一起切版本——这和微前端独立部署的理念冲突。shared 方案允许不同版本共存,但在 Webpack 5 的实际表现中,shared 的版本协商逻辑有时会加载多个实例(比如 react 和 react-dom 各自 singleton 但加载了两份),排查这种问题需要深入理解 Webpack 的 runtime 模块联邦代码。如果团队没有专门的人维护构建配置,externals 是更稳妥的选择。