持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第1天,点击查看活动详情
最近在工作中有一种争论:是否可以使用 Module Federation 替换部分微前端加载的场景。在对一些 副作用 作出规定限制的场景下,使用 MF 加载子平台性能更好,同时也支持微前端平台嵌套微前端。 对于这个问题本文给出个人的一些思考,希望能抛砖引玉。
远程模块加载
一个最简单的远程模块加载实现需要考虑什么?其实啥也不用考虑,见 codesandbox
对于 ReactDraggable 这个“远程组件”,必要的 runtime 就是 react 和 react-dom ,只要加载了 runtime 就能加载组件。
我们一般把前端项目打包产物上传 CDN , publicPath 设为 CDN 地址,则所有静态资源都能算 “远程组件” ,运行异步js chunk必要的 runtime ( optimization.runtimeChunk ) 就是 Webpack CJS 和依赖模块的 bundle。
同时对于 ReactDraggable 这个组件,预期内它不包含任何特别的副作用,因而可以直接使用,不用考虑沙盒隔离问题。
Webpack Module Federation 思路
简单回顾下 webpack 作为一个 module bundler 打包流程就是:
- 读取入口文件
- 基于 AST 分析入口文件,产出依赖(就是递归找引用过的)文件路径
- 使用对应 loader 处理依赖文件模块,并对每个模块生成 chunkId
- 以 chunkId/处理后代码 为 key/value 把编译后的模块内容存在一个 JS 对象里,即 Webpack CJS 入参的 modules 对象。
如果把 vendor,runtime 等都配置了拆包,则业务代码的拆包文件可以清晰看到这样的对象结构。 之后 Webpack CJS runtime 的 webpack_require 就能用 chunkId 找到模块代码。再比如 webpack_require.ensure 就是 webpack 自己实现的异步模块逻辑。
基本原理
框架本身都倾向于解决从自己的角度看到的问题。webpack 作为 bundler ,它要提供的 MF 功能就是在不同的 Webpack CJS runtime 之间直接 share modules 对象内 chunk 代码的机制。
可看这篇文章深入浅出一下 MF 加载机制。 MF plugin 拓展了 Webpack CJS runtime 的能力,不管是共享模块还是远端模块,其实还是使用的 webpack_require.ensure 去加载一些异步chunk。另外因为 host / remote 应用预期都是完整的项目,则 MF 额外提供 sharedScope 复用共同依赖的能力。
从 bundler 的角度来说,对 runtime 提供这种 share chunk 代码的能力其实就足够了,至于 chunk 代码有什么副作用,能不能脱离沙盒 run 起来则不再处理的范畴内。
MF 远程模块类型提示
一个集成 MF 功能的 CLI 工具除了封装通用的API 用法之外,一般还需要处理 ts 问题。我们当然可以把 MF 公共组件的 TS 类型作为单独的 npm 包来维护(它可能很少变更),但这样就很不 cool 了。
较好的实践是在 CLI 里写一个 webpack plugin ,如 EMP 的实现 。 build 时自动生成 .d.ts 到 dist/types 文件夹下,引入远程组件时一键下载远程 dist/types 文件夹到本地,使用 webpack 做这种事比较轻车熟路,这里推荐一个实现思路了解一下细节。
典型场景:MF 作为热更新的 NPM 包
MF 最常见的业务落地是把公共组件提取成单独的项目入口,使用前端基建中台 Page Server 部署,提供一个稳定的入口文件地址,在别的项目中使用这个稳定的地址来引用公共组件。同时可以使用 Page Server 部署平台的能力来热更新,灰度上线,秒级回滚等功能,不用像使用 npm 包那样需要源码升级依赖包版本和 lock 文件来更新组件。
客观来说回到第一个例子,我们使用带版本号的 unpkg unpkg.com/react-dragg… 引入组件,其实也能不在 url 里带版本从而 使用latest tag ,则我也能秒级修改 npm 包的 latest tag 来热更新包版本,只是不如 Page Server 版本控制的功能强大,但假如公司内网又有一个 npm cdn 的中台也有类似功能就不好说了。因此热更新远程组件应该不算 MF 的一个特色功能。
MF 主要功能 sharedScope
MF remoteEntry 提供组件相比直接使用上面那种 umd npm cdn 最大的优点明显是 sharedScope ,通过共享部分依赖可以性能优化。但我认为性能优化并不是 sharedScope 的重点,重点应该是 singleton 来处理某些依赖模块单例的情况。npm 包的 js chunk 预期内没什么副作用,但一个完整项目通过 MF 直接分享的 chunk ? 可能的副作用可太多了。没有沙盒则至少给个 singleton 来让业务 run 起来。
当 host 和 remote shared 同一个 依赖包如 react 的同一个版本,实际上 host/remote 公用的是 remote 的 react ,即命中公用依赖逻辑后,webpack MF 固定的处理是 host 使用从 remote chunk 异步找来的依赖。从这个机制我们容易想到把大部分依赖整合成本地一个 remoteEntry 入口,就是 MFSU 的思路了。
这个机制的直接影响也体现在从项目入口文件同步执行的代码不能同步 import remote chunk ,因为要等remoteEntry.js 加载完。虽然理论上入口文件的依赖路径都不同步 import retmote , 入口文件就不用包 import,见这个示例 ,但这是弱约束,因而还是老实处理入口文件比较好。
微组件框架
MF 当然不和 webpack 绑定,别的 bundler 如 rollup 当然也能简单的对标实现。我们可以更进一步,这一套逻辑真的需要和 bundler 绑定吗?其实也不用。
MF 在 bundler 中的流程可以这样抽象: 项目A源码 => 项目A Webpack CJS => MF runtime => 项目B Webpack CJS => 项目B源码
因为 bundler 要为打包产物提供 runtime 代码(如 Webpack CJS),对 bundler 来说 transpile 源码是已经做过的事,剩下的只需要拓展 runtime 能力即可。因而 MF 逻辑集成在 bundler 中可能更加自然。
transpile + 提供 runtime 的逻辑也能提升到在源码层内,抽象出一套框架来处理,即现在(流行?)的微组件框架。(这让人想到 HTTP3 的 QUIC 也是在 UDP datagram payload 中,即应用层实现了一套 TCP 功能机制)
transpiler + runtime
这里假设新建一个微组件框架叫 MircoModule
,它首先要提供通用的组件 API 模式(即 transpiler),然后提供一个适配层(runtime),基本链路如下(函数名随便取的):
VueComponent => componentAdapter(VueComponent) => MircoModuleComponent => createReactComponent(MicroModuleComponent) => ReactComponent
从源码 VueComponent 生成 MircoModuleComponent 即 transpile 过程,提供静态函数来转换。
从 MircoModuleComponent 可以通过 createReactComponent 生成 React 组件,也可以提供别的工具函数生成其他技术栈,即提供使用微组件 的 runtime 。在 runtime 里也可以额外集成沙盒功能。
微组件框架配套沙盒
客观上来说沙盒能力和“微组件”没有直接的关系。在微组件框架内集成沙盒能力很大程度上是为了服务业务逻辑,如这个例子 。
我们都知道微前端框架的本质是实现 js/css 沙盒。可以考虑提取出微前端框架的沙盒能力集成到微组件框架中。这里有一个实践 修改了mirco-app 的入口加载方式,简单使用沙盒加载指定 UMD JS 文件。
runtime 额外能力 跨技术栈
假如某个微组件框架没有集成沙盒,它看上去会像一个打平前端技术栈差异的一个方案。
以 Magic Microservices 这个框架为例,它需要你封装组件时写好固定的生命周期函数(即提供 transpiler),然后提供
magic
函数协助你快速创建 Magic Module
并注册成为 HTML 原生支持的 Web Component (即提供 runtime)。
在 Webpack MF 里做 React in Vue 的逻辑 就是拿到 chunk 代码后简单包下 VueWrapper ,对微组件框架来说,这种逻辑当然可以集成到自己的 runtime 工具集里,像这样。
微组件框架对比直接使用 bundler 的 MF
以最近开源的 hel-micro 为例,这篇文章提出的使用微组件框架的优点:
- 工具链无关(即和webpack,rollup 这些 bundler 无关)
- 由编译时决定依赖关系转为运行时决定依赖关系(即支持热更新远程依赖版本)
结合上文,我们能很快理解上述优点的内部原理。
首先 bundler MF 的能力和它自身提供的 runtime 代码绑定,则 webpack 提供的 runtime (即 Webpack CJS) 和 rollup 不同,自然不能互通。而微组件框架的 runtime 逻辑是在源码里实现的,自然抹平了 bundler 差异。
同样因为 transpile + runtime 整套逻辑都在源码层实现了一遍,自然把依赖关系的确定从编译时修改到了运行时。
场景差异
同样结合上文,我们能很快想到微组件框架并不是只有优点,它的典型应用场景显然和 bundler MF 有明显差异。
-
bundler MF 场景:我有一个平台代码既要独立部署,同时也希望可以通过入口文件 share chunk 给其他平台使用。 因为项目代码被 bundler(如 webpack) transpile 并注入 runtime 是已经发生过的事,无需做更多特别的操作,配置 MF plugin 就完事了。
-
微组件框架场景:我想要 share 的代码并没有被微组件框架 transpile 过,所以我要引用微前端框架提供的工具函数/组件来封装包裹对应代码(通常可能要实现对于接口)。 所以最合理的方式反而应该是去新建一个 npm 包,去使用微组件框架包裹我想 share 代码的 unpkg js chunk,从而接入微组件框架的能力,详情可见这里。当然这里虽然新建了 npm 包, 仍然可以用 Page Server 控制包内具体业务代码的版本,因为是运行时加载的,这则是微组件框架做的主要工作。
- 这里一个 tip 在于可以使用微组件框架包裹 lodash 这样的基础库,则这种库我们可以不考虑副作用问题,即不用提供沙盒。
- 另外一个小 tip 在于这里都提供 npm 包了,所以微组件框架倾向于把组件类型文件也放进新建的 npm 包如 hel-lodash 里维护。这可能不如上文提到的 MF cli 提供工具直接下载对应产物入口的 types 文件到本地的方式,详见上文目录:MF 远程模块类型提示。当然微组件框架想提供相同的机制也没啥障碍。
但同样 bundler MF 最常见的实践场景即上文目录:MF 作为热更新的 NPM 包。如果单纯是这种场景,即公共组件代码已经提取出来单独存在了,那么 使用微组件框架来封装 相比使用 bundler MF 的确是更好的方式。
总结
先回答一下开头问题:
是否可以使用 Module Federation 替换部分微前端加载的场景。在对一些 副作用 作出规定限制的场景下,使用 MF 加载子平台性能更好,同时也支持微前端嵌套微前端。
文章的前半篇幅就能回答这个问题。无论性能更好还是支持微前端嵌套都是基于 MF 不使用任何沙盒机制的前提。使用 MF 嵌套加载微前端项目本质上类似于不使用沙盒地使用 html-entry 在一个项目页面里加载另一个项目的全部 chunk 。极端情况下,如果这几个项目的副作用都明确为 false时的确可能让项目 run 起来,但个人认为这种场景非常有限,没有很大的探索价值。
再由全文总结一下演进路线:
-
微前端框架:本质上是实现 CSS/JS 沙盒,在沙盒里可以像在 iframe 里一样加载任意代码 chunk。
-
Module Federation: 最初是从 bundler 工具的角度出发的机制。对 bundler 来说一定要对打包产物注入 runtime 代码,则 MF 旨在拓展这个 runtime 的能力,使不同项目打包产物之间通过相同的 runtime 能力直接 share chunk 代码。
-
微组件框架:将 MF 的逻辑从 bundler 层提升到源码里。在源码内(提供npm 包给用户从而)手动实现全套 MF 的机制,提供 transpiler + runtime 等逻辑的模块。这样的处理使得它的应用场景和 bundler MF 有较明显的差异。
微组件框架和沙盒没有直接关系,框架可以集成沙盒,也可以不集成。如果未集成沙盒,用户也可以手动和微前端框架的沙盒组合使用。使用微组件框架 和 直接使用 bundler MF 有场景差异的利弊。
业务上来说,只要和 UI 相关的模块副作用都无法忽视。大部分场景都很难存在无副作用的理想情况,仍需要 sharedScope singleton 或 沙盒机制 处理才能保证代码运行正常。
结语
写文章本身也是一个学习的过程,也请读者能指出文章中的疏忽错漏之处。如果本文对你有所帮助,欢迎点赞收藏。