一看就懂的 Module Federation(微前端进阶必读)
一文看懂 Module Federation
作者: 谢国度(心伦) | 发布时间: 2022-05-09 来源: 阿里巴巴前端技术
前言
一直在听说 Webpack5 的新特性 Module Federation(简称 MF)可以很好解决代码共享的问题,但实际在这两年并没有在团队中使用起来——现有项目都不是 Webpack5,小范围落地又有局限性,而团队在微前端探索中已经有了跨子应用代码共享的解决方案。
目前为了探索 MF 与微前端方案结合的可能性,决定深入了解其底层原理。
核心概念
什么是 Module Federation
Webpack 官网描述:
Multiple separate builds should form a single application. These separate builds should not have dependencies between each other, so they can be developed and deployed individually. This is often known as Micro-Frontends, but is not limited to that.
简译: 一个应用可以由多个独立的构建组成,这些独立构建之间没有依赖关系,可以独立开发、部署——这就是常说的微前端,但不仅限于此。
MF 和微前端解决的问题类似:把一个应用拆分成多个应用,每个可独立开发、独立部署,一个应用可以动态加载并运行另一个应用的代码,并实现应用间的依赖共享。
MF 在设计上提出了三个核心概念:
1. Container
一个被 ModuleFederationPlugin 打包出来的模块被称为 Container。
通俗讲:使用了 ModuleFederationPlugin 构建的应用就是一个 Container,它可以加载其他的 Container,也可以被其他的 Container 所加载。
2. Host & Remote
从消费者和生产者的角度看 Container,Container 又可被称作 Host 或 Remote:
| 角色 | 含义 |
|---|---|
| Host | 消费方,动态加载并运行其他 Container 的代码 |
| Remote | 提供方,暴露属性(如组件、方法等)供 Host 使用 |
注意:Host 和 Remote 是相对的——一个 Container 既可以作为 Host,也可以作为 Remote。
3. Shared
一个 Container 可以 Shared 它的依赖(如 react、react-dom)给其他 Container 使用,即共享依赖。
使用实践
效果演示
两个应用 app1 和 app2,app2 共享它的 Hello 组件给 app1 使用,它们共享一份 react 和 react-dom 依赖。
app1/src/app.js
import React from 'react'; import App2Hello from 'app2/Hello'; const RootComponent = () => { return ( <div> <div>app1</div> <App2Hello /> </div> ); }; export default RootComponent;
app1/src/bootstrap.js
import React from 'react'; import ReactDOM from 'react-dom'; import App from './app'; ReactDOM.render(<App />, document.getElementById('app'));
app1/src/index.js
import('./bootstrap');
app2/src/Hello.js
import React from 'react'; const Hello = () => { return ( <div>app2 hello</div> ) }; export default Hello;
效果: app1 引用了 app2 的 Hello 组件,在渲染时异步下载了 app2 的远程模块入口代码和 Hello 组件代码,并且只下载了 app1 的 react 和 react-dom 代码,app2 直接使用 app1 提供的依赖——实现了应用动态加载并运行另一应用的代码,同时实现应用间依赖共享。
如何配置插件
实现跨应用代码共享,主要借助了 Webpack5 提供的 ModuleFederationPlugin。
app2 作为提供方(Remote)的配置:
// app2/webpack.config.js const { ModuleFederationPlugin } = require('webpack').container; module.exports = { plugins: [ new ModuleFederationPlugin({ name: 'app2', filename: 'app2RemoteEntry.js', exposes: { './Hello': './src/Hello', }, shared: { react: { singleton: true }, 'react-dom': { singleton: true } }, }), ] }
app1 作为消费方(Host)的配置:
// app1/webpack.config.js const { ModuleFederationPlugin } = require('webpack').container; module.exports = { plugins: [ new ModuleFederationPlugin({ name: 'app1', filename: 'app1RemoteEntry.js', remotes: { 'app2': 'app2@http://127.0.0.1:8002/app2RemoteEntry.js', }, shared: { react: { singleton: true }, 'react-dom': { singleton: true } }, }) ] }
核心字段详解
name
当前应用的别名。当应用作为 Remote 给 Host 使用时,作为引用前缀:
import xx from 'name/expose'
filename
当前应用作为 Remote 时,提供的远程模块入口文件名,如 app2RemoteEntry.js
exposes
当前应用作为 Remote 时,可提供哪些属性(如组件、方法,甚至一个值)可消费:
exposes: { './Hello': './src/Hello', }
- key:在 Host 使用时的相对路径
- value:当前应用暴露的属性的相对路径
同步引用:import App2Hello from 'app2/Hello';
异步引用:const App2Hello = React.lazy(() => import('app2/App1Hello'));
remotes
当前应用作为 Host 时,需要消费哪些 Remote 应用:
remotes: { 'app2': 'app2@http://127.0.0.1:8002/app2RemoteEntry.js', }
使用 Remote 应用的格式为 import * from {name}{path}
注意:这里的 name 是引用别名,可以跟 Remote 应用定义的 name 不一致。
shared
当前应用无论是作为 Host 还是 Remote,可以共享的三方库依赖有哪些:
shared: { react: { singleton: true }, 'react-dom': { singleton: true } }
| 配置项 | 含义 |
|---|---|
singleton: true | 开启单例模式,共享依赖只加载一次(优先取版本高的) |
requiredVersion | 指定共享依赖的版本 |
版本冲突处理:
- app1 的 react 版本为 16.13.0,app2 的 react 版本为 16.14.0
- 双方配置
singleton: true→ 共同使用 16.14.0(app2 提供) - app1 配置
requiredVersion: 16.13.0→ app1 用 16.13.0,app2 用 16.14.0,各自下载自己的
工作原理
构建上的差异
使用 MF 之后,打包文件中新增了四种 chunk:
| Chunk 类型 | 生成原因 |
|---|---|
| remoteEntry-chunk | 配置了 ModuleFederationPlugin 后自动生成 |
| shared-chunk | 开启 shared 功能后,共享依赖被分离出来 |
| expose-chunk | 配置了 exposes 后,暴露的属性生成的独立 chunk |
| async-chunk | 手动代码分割产生的异步 chunk |
为什么需要 bootstrap.js 异步加载
这是实现 MF 功能的关键限制。入口代码必须放到 bootstrap.js,index.js 使用 import('./bootstrap') 异步加载。
原因:
如果 bootstrap.js 不是异步加载,而是直接打包在 main.js 里,那么:
import App2Hello from 'app2/Hello';
这行语句会立刻执行,此时 app2 的资源根本没有被下载,会报错。同样,import React from 'react' 同步执行也会报错,因为共享依赖还未初始化。
正确顺序:
- 先加载
main.js - 异步加载
src_bootstrap_tsx.js - 前置加载好远程应用的资源 + 初始化共享依赖
- 最后执行
bootstrap.js模块
远程模块加载机制
当 app1 执行 import App2Hello from 'app2/Hello' 时,背后发生了什么?
完整流程:
- app1 加载
src_bootstrap_tsx模块,发现它依赖webpack/container/remote/app2/Hello - 先去下载
webpack/container/reference/app2(即app2RemoteEntry.js) - 返回 app2 全局变量
- 执行
app2.get('./Hello')异步获取 Hello 组件 - 远程资源 +
src_bootstrap_tsx资源全部下载完成 - 最后执行
src_bootstrap_tsx模块
核心代码在 __webpack_require__.f.remotes:
__webpack_require__.f.remotes = (chunkId, promises) => { if (__webpack_require__.o(chunkMapping, chunkId)) { chunkMapping[chunkId].forEach((id) => { var data = idToExternalAndNameMapping[id]; // ... handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1); // data[2] = 'webpack/container/reference/app2' }); } }
而 app2RemoteEntry.js 暴露了 get 和 init 方法:
var get = (module, getScope) => { __webpack_require__.o(moduleMap, module) ? moduleMap[module]() : Promise.reject(new Error('Module does not exist in container.')); }; var init = (shareScope, initScope) => { __webpack_require__.S[name] = shareScope; // 合并共享作用域 return __webpack_require__.I(name, initScope); };
依赖共享原理
关键:__webpack_require__.S — 共享作用域(sharedScope)
在 Host 和 Remote 应用之间建立一个可共享的 sharedScope,包含所有可共享的依赖。
初始化流程
- app1 调用
__webpack_require__.I初始化sharedScope - app1 注册自己的共享依赖版本(react 16.14.0、react-dom 16.14.0)
- app2 也调用
__webpack_require__.I,注册自己的共享依赖 - app2 的
init方法将 app1 的__webpack_require__.S作为自己的__webpack_require__.S - 两者共用同一个 sharedScope
版本选择规则
singleton 模式(取最高版本):
var getSingletonVersion = (scope, scopeName, key, requiredVersion) => { var version = findSingletonVersionKey(scope, key); // 找最高版本 if (!satisfy(requiredVersion, version)) { console.warn('版本不兼容警告'); } return get(scope[key][version]); };
requiredVersion 模式(精确匹配):
var loadStrictVersionCheckFallback = (scopeName, scope, key, version, fallback) => { var entry = findValidVersion(scope, key, version); // 找精确版本 return entry ? get(entry) : fallback(); };
最终数据结构
__webpack_require__.S = { default: { // sharedScope 名称 react: { '16.14.0': { get: [Function], from: 'app1', eager: false }, '16.13.0': { get: [Function], from: 'app2', eager: false }, }, 'react-dom': { '16.14.0': { get: [Function], from: 'app1', eager: false } } } }
应用场景
场景一:代码共享
传统方案对比:
| 方案 | 问题 |
|---|---|
| 直接复制代码 | 维护困难 |
| 发布到 NPM | 效率低,版本管理复杂 |
| 微前端异步加载子应用 | 优雅但不成标准 |
MF 方案:
只需配置 exposes,使用方配置 remotes 即可引用:
// 同步引用 import ServiceInfo from 'optimus/ServiceInfo'; // 异步引用 const ServiceInfo = React.lazy(() => import('optimus/ServiceInfo'));
业务组件库新玩法
不需要 babel-plugin-import 就能实现按需加载+懒加载:
// webpack.config.js new ModuleFederationPlugin({ name: 'tracks', filename: 'tracksRemoteEntry.js', exposes: { './PageHeader': './src/components/PageHeader', './Address': './src/components/Address', './Empty': './src/components/Empty', }, shared: { react: { singleton: true }, 'react-dom': { singleton: true } }, }),
存在的两个问题
- 缺乏类型提示:即使 Remote 生成了类型文件,Host 引用时也获取不到
- 缺乏开发工具:没有官方工具支持多个应用同时启动、同时开发
场景二:公共依赖
传统方案对比:
| 方案 | 弊端 |
|---|---|
| webpack externals | 全量加载,依赖顺序需人工保证,多版本共存无法支持 |
| 微前端方案 | 模块管理不成标准,无法与社区方案融合 |
MF 方案:
所有公共依赖均可作为一个应用,依赖关系交给 Webpack 处理:
// react16 应用 new ModuleFederationPlugin({ name: 'react16', filename: 'react16RemoteEntry.js', exposes: { './index': './src/index', }, }),
使用方:
import React from 'react16/index';
存在的问题:
- 依赖别名问题:使用方需写
react16/index,体验不友好 - 性能问题:每个公共依赖一个 remoteEntry.js,启动时异步下载资源过多
折中方案: 建一个库应用存放所有公共依赖,合并为只有一个 remoteEntry.js(但无法解决多版本并存问题)。
优缺点总结
优点
- 框架无关:提供了一种拆分巨石应用的快速方式
- 代码共享:一个应用可以很方便共享模块给其他应用使用
- 依赖共享机制完善:支持多版本依赖共存
- 学习/改造成本低:基于 Webpack 生态,实施成本低
缺点
- 性能影响:资源需要各种异步加载,可能对页面性能造成负面影响
- 版本控制问题:远程应用的资源路径需显式配置,存在和 NPM 包管理一样的问题
- 缺乏类型提示:引用远程模块时没有类型提示,存在代码质量问题
- 缺乏官方开发工具:不支持多个应用一起启动、一起开发
思考
MF 极致地发挥了模块动态加载与依赖自动管理的优势,使得应用拆分和代码复用有了新思路。
与微前端的关系:互补而非替代。
- MF 专注于应用间代码共享和依赖共享,从原生构建层面解决模块依赖关系
- 微前端更专注于宏观角度:应用动态加载、生命周期管理、沙箱管理、版本管理、研发平台
当前 MF 仍处于相对不稳定、不完善的阶段,但值得长期关注。某些缺陷(如开发工具、多应用同时启动)的问题都是可以自行解决的——要不要在 MF 基础上投入解决,取决于投入产出比的权衡。
无论怎样,MF 绝对值得长期关注并投入时间去探索,它将与微前端很好地结合起来。