Module Federation 是 webpack5 中振奋人心的新特性,也是号称能改变 JavaScript 架构游戏规则的功能。接下来让我们慢慢揭开 Module Federation 的神秘面纱。
模块共享的方案对比场景:目前拥有项目 A 和项目 B,我们发现它们存在一定的共性,比如公共 UI 组件、utils 等。那我们如何同享这些公共信息呢?
简单粗暴-CV大法
直接将项目 A 的组件,copy 到项目 B 中,这样的方式有时候是比较快的,但也存在维护性极低的问题,后续两个项目都各自维护一套。
抽象成 npm
我们可以将一些公共的模块抽象成 npm,每个项目都去安装该 npm 包,从而达到共享的目的
但是 npm 包的方式存在以下问题:
- 编译与构建:一些公共的工具库,框架以及 UI 库存在重复构建,造成性能低下。
- 版本更新:需要各个项目都去升级。“发布 -> 通知 -> 更新” 的方式是比较低效率的。
CDN + webpack externals
跟 npm 类似,只不过将其上传到 CDN,通过结合 webpack externals 进行加载,除了上面提到的问题,还有 externals 没有按需加载。
git submodule
子模块允许你将一个 Git 仓库作为另一个 Git 仓库的子目录。它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立。
其还是会存在重复构建的问题,而且还会一定的上手成本。
相关的命令:
- git submodule add <子模块repository>: 添加子模块。
- git submodule update --recursive --remote :拉取所有子模块的更新。
Module Federation 是什么?
官方文档解释其动机如下:
多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。这通常被称作微前端,但不仅仅限于此。
Module federation 使 JavaScript 应用得以从另一个 JavaScript 应用中动态地加载代码,这就解决了我们上面提到的模块共享的问题。
它不仅仅是微前端,而且场景粒度可以更加细,一般微前端更多的是应用级别,但它更偏向模块级别的共享。
Module Federation 配置
在实战之前,我们了解一下 Module Federation 的配置项
首先是两个基础角色的约定:
- Host。消费模块的一方。
- Remote。提供模块的一方。
每个应用都既可以作为 host,也可以作为 remote。
Module Federation 配置项如下:
- name: 必须且唯一。
- filename: 若没有提供 filename,那么构建生成的文件名与容器名称同名。
- remotes: 可选,作为引用方最关键的配置项,用于声明需要引用的远程资源包的名称与模块名称,作为 Host 时,去消费哪些 Remote。
- exposes: 可选,表示作为 Remote 时,export 哪些属性被消费。
- library: 可选,定义了 remote 应用如何将输出内容暴露给 host 应用。配置项的值是一个对象,如 { type: 'xxx', name: 'xxx'}。
- shared,可选,指示 remote 应用的输出内容和 host 应用可以共用哪些依赖。shared 要想生效,则 host 应用和 remote 应用的 shared 配置的依赖要一致。
Singleton: 是否开启单例模式。默认值为 false,开启后remote 应用组件和 host 应用共享的依赖只加载一次,而且是两者中版本比较高的。
requiredVersion:指定共享依赖的版本,默认值为当前应用的依赖版本。
eager:共享依赖在打包过程中是否被分离为 async chunk。设置为 true, 共享依赖会打包到 main、remoteEntry,不会被分离,因此当设置为true时共享依赖是没有意义的。
实战演示
这里我们用 Github 中 Module Federation Examples[1]进行演示。这里包含了基础的用法、高级用法以及和一些框架的结合实践。
注:该仓库使用 lerna 维护。所以你需要安装 lerna。
npm install lerna -g
通过 lerna bootstrap 安装依赖。
简单示例
来看 basic-host-remote 目录下有两个独立的 project,分别为 app1 和 app2。其中 app2 中实现了一个 Button 组件,现在 app1 要用这个 Button 组件。
import React from 'react';
const Button = () => <button>App 2 Button</button>;
export default Button;
app2 暴露组件
此时,app2 的角色就是 Remote,核心 webpack 配置:
const { ModuleFederationPlugin } = require('webpack').container;
// ...
plugins: [
new ModuleFederationPlugin({
name: 'app2',
library: { type: 'var', name: 'app2' },
filename: 'remoteEntry.js', // 生成的文件名
exposes: {
'./Button': './src/Button', // Export Button 组件
},
// 共享 react 和 react-dom
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
],
// ...
app1 消费组件
此时,app1 的角色是 Host,webpack 核心配置:
const { ModuleFederationPlugin } = require('webpack').container;
// ...
//http://localhost:3002/remoteEntry.js
plugins: [
new ModuleFederationPlugin({
name: 'app1',
remotes: {
// http://localhost:3002/remoteEntry.js
// 上面配置生成的模块文件
app2: `app2@${getRemoteEntryUrl(3002)}`,
},
// 共享模块
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
],
// ...
模块使用:
const RemoteButton = React.lazy(() => import('app2/Button'));
const App = () => (
<div>
<h1>Basic Host-Remote</h1>
<h2>App 1</h2>
<React.Suspense fallback="Loading Button">
<RemoteButton />
</React.Suspense>
</div>
);
export default App;
效果
而且可以看到 react 和 react-dom 也是加载了一次:
高级示例-动态加载远程模块
假如初始化的时候,不加载远程的模块,在一定的交互之后再去加载远程模块,该怎么实现呢?
本示例在 advanced-api/dynamic-remotes 中可以找到。
示例中有三个 project,app1/app2/app3。app1 是 Host,消费 app2 和 app3 提供的组件,而且点击相应按钮的时候才去加载对应的远程模块。另外 app2 和 app3 都用到了 moment.js。
app2 和 app3 暴露模块
两个 project 的配置是相似的,都是暴露了 Widget 组件,而且都同享了 react 和 react-dom,以及 moment.js。这里可以留意的是,假如不声明 requiredVersion,就会使用它能找到的当前大版本中最高的 version。
const deps = require('./package.json').dependencies;
// ...
new ModuleFederationPlugin({
name: 'app3',
library: { type: 'var', name: 'app3' },
filename: 'remoteEntry.js',
exposes: {
'./Widget': './src/Widget',
},
// adds react as shared module
// version is inferred from package.json
// there is no version check for the required version
// so it will always use the higher version found
shared: {
react: {
requiredVersion: deps.react,
import: 'react', // the "react" package will be used a provided and fallback module
shareKey: 'react', // under this name the shared module will be placed in the share scope
shareScope: 'default', // share scope with this name will be used
singleton: true, // only a single version of the shared module is allowed
},
'react-dom': {
requiredVersion: deps['react-dom'],
singleton: true, // only a single version of the shared module is allowed
},
// adds moment as shared module
// version is inferred from package.json
// it will use the highest moment version that is>=2.24and 小于 3
moment: deps.moment,
},
})
app1 消费模块
app1 作为 Host,这里都是常规配置,不再赘述。
主要来看它负责动态加载的代码,在点击相应的按钮的时候,会触发 useFederatedComponent 方法,入参中 remoteUrl 为远程地址,scope 为对应应用名称,module 为指定的模块。其中 useDynamicScript 负责加载的远程 JavaScript 脚本,加载完成之后,通过 loadComponent 方法动态加载组件。
export const useFederatedComponent = (remoteUrl, scope, module) => {
const key = `${remoteUrl}-${scope}-${module}`;
const [Component, setComponent] = React.useState(null);
const { ready, errorLoading } = useDynamicScript(remoteUrl);
React.useEffect(() => {
if (Component) setComponent(null);
// Only recalculate when key changes
}, [key]);
React.useEffect(() => {
if (ready && !Component) {
const Comp = React.lazy(loadComponent(scope, module));
componentCache.set(key, Comp);
setComponent(Comp);
}
// key includes all dependencies (scope/module)
}, [Component, ready, key]);
return { errorLoading, Component };
};
再来重点看下 loadComponent,其中 __webpack_init_sharing__ ,进行了初始化共享作用域,用提供的已知此构建和所有远程的模块填充它。然后获得远程容器 container,支持 get 和 init 方法。init 是一个兼容 async 的方法,调用时,只含有一个参数:共享作用域对象(shared scope object)——__webpack_share_scopes__.default。最后调用容器的 get 方法,获取到对应的模块。
function loadComponent(scope, module) {
return async () => {
// Initializes the share scope. This fills it with known provided modules from this build and all remotes
// 初始化共享作用域(shared scope)用提供的已知此构建和所有远程的模块填充它
await __webpack_init_sharing__('default');
const container = window[scope]; // or get the container somewhere else
// 初始化容器 它可能提供共享模块
await container.init(__webpack_share_scopes__.default);
const factory = await window[scope].get(module);
const Module = factory();
return Module;
};
}
效果演示
- 点击不同的按钮,加载不同的组件。
- moment.js 在首次加载后不用再重新加载。
你可以通过动态加载的方式,提供一个共享模块的不同版本,从而实现 A/B 测试。
Module Federation 的问题
谈了这么多 Module Federation 的优点,我们来看看它有哪些缺点
- 对环境要求略高,需要使用 webpack5,旧项目改造成本大。
- 拆分粒度需要权衡,虽然能做到依赖共享,但是被共享的 lib 不能做 tree-shaking,也就是说如果共享了一个 lodash,那么整个 lodash 库都会被打包到 shared-chunk 中。
- Webpack 为了支持加载 remote 模块对 runtime 做了大量改造,在运行时要做的事情也因此陡然增加,可能会对我们页面的运行时性能造成负面影响。
- 运行时共享也是一把双刃剑,如何去做版本控制以及控制共享模块的影响是需要去考虑的问题。
对于问题1,未来应该会慢慢变好。问题2 感觉还好,场景应该不会特别多,而且相比于共享模块,不重复编译的优点来讲,相对可以接受。问题3,感受不大。
问题4,算是比较头疼的一件事,比如几个项目,都需要版本 react/react-dom/antd 的版本一致,假如版本更新的话,怎么办?
我们可以使用 Module Federation 的能力,将一些核心的依赖例如 react、react-dom、antd,使用一个 remote 服务维护,然后每个项目分别引用这个服务导出的 library。我们只需要维护这个 remote 服务上依赖的版本,就能保证每个项目核心依赖的版本是一致的,而且升级的时候,也不用每个项目自己升级,大大提升了效率。
总结
使用 Module Federation,我们可以在一个应用中动态加载并执行另一个应用的代码,且与技术栈无关,并且能够共享模块,从而减小编译时间以及降低包体积。
但在使用 Module Federation 的时候也需要权衡模块拆分的粒度以及做好版本的控制。
参考
- 深入探索Webpack5之Module Federation的“奇淫技巧”[2]
- 官网 Module Federation[3]
- 浅析 Webpack Module Federation 在 React.js 中的实践[4]
参考资料
[1]Module Federation Examples: https://github.com/module-federation/module-federation-examples
[2]深入探索Webpack5之Module Federation的“奇淫技巧”: https://juejin.cn/post/6938975818659921957
[3]官网 Module Federation: https://webpack.docschina.org/concepts/module-federation/
[4]浅析 Webpack Module Federation 在 React.js 中的实践: https://juejin.cn/post/7012990703714172964