解析 Webpack5 的 Module Federation
我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。
本文作者:贝儿
在前面的文章 基于 Webpack5 Module Federation 的业务解耦实践 中我们在业务中实现了对 Module Federation 的应用。
前言
本文主要以 webpack5 中一个特性 — Module Federation(模块联邦)为核心,介绍 Module Federation 的原理以及其在业务当中的应用场景。
Module Federation 强依赖 webpack 5
Module Federation 是什么
webpack 官网如此定义其动机:
多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。这通常被称作微前端,但并不仅限于此。
🤔 貌似通过 webpack 官网对此动机的阐述仍然无法理解 Module Federation 到底是什么?
最终,经过不断的操作实践,作者认为: Module Federation 是一种“允许应用动态加载另一个应用的代码,而且共享依赖项” 的能力。 我们通过借助 Module Federation 来解决业务中应用的共享依赖,实现独立部署,并远程引用更新依赖的问题。
基本概念
- 宿主容器 (host):消费方 , 它动态的加载并运行远程共享的代码。
- 远程依赖 (remote):提供方,它暴露出例如组件方法等供宿主容器进行使用。
- shared: 指定共享的依赖。
基本用法
有两个应用分别名为application(宿主),appMenus(远程),目的: application 远程获取到 appMenus,并且实现 appMenus 升级, application 加载最新的 appMenus。
其中有如下几个配置项具体作用,可以进行对照阅读
- name: 应用别名
- filename:入口文件名, remote 供 host 消费的时候, remote 提供远程文件的文件名字
- exposes: remote 暴露的组件以及组件的文件路径
- remotes: 是一个对象, key 为remote 的应用别名,value 为 remote的文件链接
- 格式必须严格遵守 “obj/@url”的格式,对应的 remote 的别名,url 为 remote 入口的链接
- shared: shared 配置项指示 remote 应用的输出内容和 host 应用可以共用哪些依赖。 shared 要想生效,则 host 应用和 remote 应用的 shared 配置的依赖要一致。
- singleton: 是否开启单例模式,默认是 false: 如果 remote 应用和 host 应用共享依赖的版本不一致,remote 应用和 host 应用需要分别各自加载依赖。所以我们在例子中要开启单例模式,让依赖共享。
- requiredVersion: 指定共享依赖的版本,默认值为当前应用的依赖版本。
- eager: 共享依赖在打包过程中是否被分离成 async chunk, true 表示共享依赖会打包到main、 remoteEntry 中。默认为 false: 共享依赖被分离出来,实现共享。
- import: 通过自定义共享依赖的名字,但是需要 import 这个key 来指定实际的 package name。
- shareScope: 所用共享依赖的作用域名称,默认是default。
基本例子
application(宿主)应用 webpack.config.js 文件
通过设置宿主应用的应用名、关联的远程地址,以及所需要的共享依赖配置,然后从 webpack 中引入 ModuleFederationPlugin 进行配置。
// application webpack.config
const federationConfig = {
name: 'application',
// 这里是选择关联其他应用的组件
remotes: {
'appMenus':'appMenus@http://localhost:8080/remoteEntry.js'
},
// 共享的依赖
shared: {
react: {
singleton: true, // true -> 表示开启单例模式
requiredVersion: dependencies["react"], // 指定共享依赖的版本
},
"react-dom": {
singleton: true,
requiredVersion: dependencies["react-dom"],
},
}
}
module.exports = {
entry: "./src/index.tsx",
...// 省略
plugins: [
new ModuleFederationPlugin({ ...federationConfig }),
]
}
子(远程依赖)应用的 webpack.config.js 文件
// 远程 appMenus webpack 配置文件
const federationConfig = {
name: 'appMenus',
filename: 'remoteEntry.js',
// 当前组件需要暴露出去的组件
exposes: {
'./AppMenus': './src/App',
},
shared: { // 统一 react 等版本,避免重复加载
react: {
singleton: true,
requiredVersion: dependencies["react"],
},
"react-dom": {
singleton: true
requiredVersion: dependencies["react-dom"],
},
},
}
application 加载远程依赖
// 在 application 中应用 appMenus
const AppMenus = React.lazy(() => import('appMenus/AppMenus'))
export default () => {
const propsConfig = {
user: [],
apps: [],
showBackPortal: true,
}
return (
<div className="App">
<header className="App-header">
<h1>Application Component</h1>
<React.Suspense fallback="Loading App Container from Host">
<AppMenus {...propsConfig}></AppMenus>
</React.Suspense>
</header>
</div>
)
}
}
基本实现效果
我们通过 network 可以查看出其实现远程加载的过程,首先加载宿主应用 bundle.js, 从bundle.js中可以看到有下面的代码,通过 webpack_require_.l 完成异步加载文件 remoteEntry.js 拉到资源文件;查看 remoteEntry.js 文件可以看到全局声明 appMenus 并挂载再 window 下。其中包含了对 appMenus 源码文件的引用,左后完成对 src_App_tsx.bundle.js 文件的请求,并完成远程加载第三方依赖的全过程。
(function (module, __unused_webpack_exports, __webpack_require__) {
"use strict";
var __webpack_error__ = new Error();
module.exports = new Promise(function (resolve, reject) {
if (typeof appMenus !== "undefined") return resolve();
__webpack_require__.l(
"http://localhost:8080/remoteEntry.js",
function (event) {
if (typeof appMenus !== "undefined") return resolve();
var errorType =
event && (event.type === "load" ? "missing" : event.type);
var realSrc = event && event.target && event.target.src;
__webpack_error__.message =
"Loading script failed.\n(" +
errorType +
": " +
realSrc +
")";
__webpack_error__.name = "ScriptExternalLoadError";
__webpack_error__.type = errorType;
__webpack_error__.request = realSrc;
reject(__webpack_error__);
},
"appMenus"
);
}).then(function () {
return appMenus;
});
/***/
});
var appmenus;
(function (__unused_webpack_module, exports, __webpack_require__) {
"use strict";
eval(
'var moduleMap = {\n\t"./AppMenus": function() {\n\t\treturn Promise.all([__webpack_require__.e("vendors-node_modules_react_jsx-runtime_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react"), __webpack_require__.e("src_App_tsx")]).then(function() { return function() { return (__webpack_require__(/*! ./src/App */ "./src/App.tsx")); }; });\n\t}\n};\nvar get = function(module, getScope) {\n\t__webpack_require__.R = getScope;\n\tgetScope = (\n\t\t__webpack_require__.o(moduleMap, module)\n\t\t\t? moduleMap[module]()\n\t\t\t: Promise.resolve().then(function() {\n\t\t\t\tthrow new Error(\'Module "\' + module + \'" does not exist in container.\');\n\t\t\t})\n\t);\n\t__webpack_require__.R = undefined;\n\treturn getScope;\n};\nvar init = function(shareScope, initScope) {\n\tif (!__webpack_require__.S) return;\n\tvar name = "default"\n\tvar oldScope = __webpack_require__.S[name];\n\tif(oldScope && oldScope !== shareScope) throw new Error("Container initialization failed as it has already been initialized with a different share scope");\n\t__webpack_require__.S[name] = shareScope;\n\treturn __webpack_require__.I(name, initScope);\n};\n\n// This exports getters to disallow modifications\n__webpack_require__.d(exports, {\n\tget: function() { return get; },\n\tinit: function() { return init; }\n});\n\n//# sourceURL=webpack://app-menus/container_entry?'
);
/***/
});
Module Federation 原理
ModuleFederationPlugin 插件
我们从 ModuleFederationPlugin 插件导出的入口为切入点,以下是部分源码截图:
class ModuleFederationPlugin {
constructor(options) {
validate(options);
this._options = options;
}
apply(compiler) {
//*** 省略 *** 对 libaray 的一些配置判断
// 核心操作 - 在完成所有内部插件注册后处理 MF 插件
compiler.hooks.afterPlugins.tap('ModuleFederationPlugin', () => {
if (
options.exposes &&
(Array.isArray(options.exposes)
? options.exposes.length > 0
: Object.keys(options.exposes).length > 0)
) {
// 如果有 expose 配置,则注册一个 ContainerPlugin
new ContainerPlugin({
name: options.name,
library,
filename: options.filename,
runtime: options.runtime,
shareScope: options.shareScope,
exposes: options.exposes,
}).apply(compiler);
}
if (
options.remotes &&
(Array.isArray(options.remotes)
? options.remotes.length > 0
: Object.keys(options.remotes).length > 0)
) {
// 如果有 remotes 配置,则初始化一个 ContainerReferencePlugin
new ContainerReferencePlugin({
remoteType,
shareScope: options.shareScope,
remotes: options.remotes,
}).apply(compiler);
}
if (options.shared) {
// 如果有 shared 配置,则初始化一个 SharePlugin
new SharePlugin({
shared: options.shared,
shareScope: options.shareScope,
}).apply(compiler);
}
});
}
}
由此可以看出,ModuleFederationPlugin 插件其实不是很复杂,核心在于 afterPlugins hook 触发后,根据是否有 exposes 、remotes、shared 配置项,来决定是否注册 ContainerPlugin、 ContainerReferencePlugin、SharePlugin,那么这三个插件到底做了什么,才实现模块联邦?
ContainerPlugin 插件
class ContainerPlugin {
constructor(options) {
validate(options);
this._options = {
name: options.name,
/*共享作用域名称*/
shareScope: options.shareScope || 'default',
/*模块构建产物的类型*/
library: options.library || {
type: 'var',
name: options.name,
},
/*设置了该选项,会单独为 mf 相关的模块创建一个指定名字的 runtime*/
runtime: options.runtime,
filename: options.filename || undefined,
/*container 导出的模块*/
exposes: parseOptions(
options.exposes,
(item) => ({
import: Array.isArray(item) ? item : [item],
name: undefined,
}),
(item) => ({
import: Array.isArray(item.import) ? item.import : [item.import],
name: item.name || undefined,
})
),
};
}
/*ContainerPlugin 插件核心*/
apply(compiler) {
const { name, exposes, shareScope, filename, library, runtime } = this._options;
if (!compiler.options.output.enabledLibraryTypes.includes(library.type)) {
compiler.options.output.enabledLibraryTypes.push(library.type);
} /*以上是在构建生成最终产物的时候决定 bundlede library 的类型*/
/*通过监听名字为 make 的hook,然后在回调函数中,
根据传入的 options 创建 ContainerEntryDependency 的实例,
然后通过 addEntry将实例传入
*/
compiler.hooks.make.tapAsync(PLUGIN_NAME, (compilation, callback) => {
const dep = new ContainerEntryDependency(name, exposes, shareScope);
dep.loc = { name };
compilation.addEntry(
compilation.options.context,
dep,
{
name,
filename,
runtime,
library,
},
(error) => {
if (error) return callback(error);
callback();
}
);
});
/*插件监听了thisCompilation的 hook,
回调里面的逻辑做了两件事:
1.那就是将 ContainerEntryDependency 的 dependencyFactory设置成
ContainerEntryModuleFactory的实例,
2.ContainerExposedDependency的
dependencyFactory设置成 normalModuleFactory
*/
compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(
ContainerEntryDependency,
new ContainerEntryModuleFactory() // 创建ContainerEntryModule实例
);
compilation.dependencyFactories.set(ContainerExposedDependency, normalModuleFactory);
});
}
}
总结来说,ContainerPlugin 本质还是在原来的 Webpack构建流程中,引入了新的 entry、ContainerEntryDependency、ContainerEntryModule,而这些新的数据结构同样是基于基础的Dependpency 和 Module 派生出来的。它只需要在适当的时机,通过调用 addEntry 方法,将 exposes 模块加入到正常的 Webpack 构建流程中。
ContainerReferencePlugin 插件
class ContainerReferencePlugin {
constructor(options) {
validate(options);
/* remoteType 告知插件需要加载的远程模块类型,默认为 script,也就是通过插入 script 方式加载远程模块的js chunck */
this._remoteType = options.remoteType;
/* 这里将传入的配置 normalize成 _remotes选项的时候,会转换成将远程模块配置以 external 和 sharedScope 为 key 的对象。*/
this._remotes = parseOptions(
options.remotes,
(item) => ({
external: Array.isArray(item) ? item : [item],
shareScope: options.shareScope || 'default',
}),
(item) => ({
external: Array.isArray(item.external) ? item.external : [item.external],
shareScope: item.shareScope || options.shareScope || 'default',
})
);
}
/**
* Apply the plugin
* @param {Compiler} compiler the compiler instance
* @returns {void}
*/
apply(compiler) {
const { _remotes: remotes, _remoteType: remoteType } = this;
/** @type {Record<string, string>} */
const remoteExternals = {};
for (const [key, config] of remotes) {
let i = 0;
for (const external of config.external) {
if (external.startsWith('internal ')) continue;
remoteExternals[`webpack/container/reference/${key}${i ? `/fallback-${i}` : ''}`] =
external;
i++;
}
}
// MF 中的远程模块,其实本质是走 Webpack 本身已经有的ExternalsPlugin逻辑
new ExternalsPlugin(remoteType, remoteExternals).apply(compiler);
// 这里的监听事件,通过dependencyFactories 的set方法,将RemoteToExternalDependency也是被当成 NormalModule处理的
compiler.hooks.compilation.tap(
'ContainerReferencePlugin',
(compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(
RemoteToExternalDependency,
normalModuleFactory
);
compilation.dependencyFactories.set(FallbackItemDependency, normalModuleFactory);
compilation.dependencyFactories.set(
FallbackDependency,
new FallbackModuleFactory()
);
// 根据 remotes配置创建一个个 RemoteModule,当然这里需要根据 request与模块的 key做一个匹配。这里的request就是当我们在代码中
// import xxx from 'app1/Button'后面的 app1/Button字面量 ,
// 接着与源码前面做 remotes配置初始化后的数组第一个项做匹配,正好是前面提到的 app1,
// 命中后走创建 RemoteModule逻辑。
normalModuleFactory.hooks.factorize.tap('ContainerReferencePlugin', (data) => {
if (!data.request.includes('!')) {
for (const [key, config] of remotes) {
if (
data.request.startsWith(`${key}`) &&
(data.request.length === key.length ||
data.request.charCodeAt(key.length) === slashCode)
) {
return new RemoteModule(
data.request,
config.external.map((external, i) =>
external.startsWith('internal ')
? external.slice(9)
: `webpack/container/reference/${key}${
i ? `/fallback-${i}` : ''
}`
),
`.${data.request.slice(key.length)}`,
config.shareScope
);
}
}
}
});
compilation.hooks.runtimeRequirementInTree
.for(RuntimeGlobals.ensureChunkHandlers)
.tap('ContainerReferencePlugin', (chunk, set) => {
set.add(RuntimeGlobals.module);
set.add(RuntimeGlobals.moduleFactoriesAddOnly);
set.add(RuntimeGlobals.hasOwnProperty);
set.add(RuntimeGlobals.initializeSharing);
set.add(RuntimeGlobals.shareScopeMap);
compilation.addRuntimeModule(chunk, new RemoteRuntimeModule());
});
}
);
}
}
可以看出 该组件的大致流程,script 插入远程 js chunk 入口, 然后通过创建新的 ExternalModule 模块去加入核心的动态加载模块的 runtime 代码。
应用场景
以往的代码复用组件或者是逻辑主要方式:
抽离一个NPM 包,是比较常见的复用手段,但是存在的缺点在于, 当 NPM 包 fix 一个 bug 的时候,那么其他依赖这个NPM 包的应用都需要重新构建打包部署,这种操作是比较重复并且低效的。
打包的产物中可能会包含一些公用的三方库,这样会导致打包之后的内容有重复,也没有起到复用的效果。
Module Federation 便有了以下的应用场景:
-
代码共享进行部分代码或者全部应用的共享, 利用 ModuleFederationPlugin 中配置一下 exposes , 宿主使用的时候配置下 remotes 就可以远程应用暴露的想要共享的部分。
MouduleFederation 虽然可以解决代码共享的问题,但是新的开发模式也会带来几点问题- 缺乏类型提示在应用远程应用的时候,是根本获取不到类型文件的,所以在 host 中使用的时候 , 较为不便利。
- 缺乏支持多个应用同事启动同时开发的工具,与 copy 文件到 host 中支持直接调试,也会带来一些不便利。
-
公共依赖在 ModuleFederationPlugin 中配置 shared 字段, 在 ModuleFeration 中所有的公共依赖最终会保存在一个公共变量当中,然后根据不同的规则匹配到相应的依赖版本。
总结与思考
本文从 Module Federation 实践,展示 Module Federation 如何配置,达成什么样的效果。同时再根据产生的效果大致梳理 Module Federation 的源码。
主要核心的实现插件为 ContainerPlugin\ContainerReferencePlugin\SharePlugin
ContainerPlugin:
通过调用 addEntry方法,将 exposes 模块加入到正常的 Webpack 构建流程中。
ContainerReferencePlugin:
host 应用消费remote 应用的组件,构建的时候本质会走 ExternalsPlugin 插件,也就是 Webpack externals 配置的功能。除此之外,插件还提供了 fallback 的功能,这样不至于加载远程模块失败的时候影响整个页面的内容展示,避免因为单点故障带来的应用不稳定性问题。
SharePlugin:
MF shared 模块机制是一个 Provide 和 Consume 模型,需要 ProvideSharedModule 和 ConsumeSharedPlugin 同时提供相关的功能才能保证模块加载机制;
在初始化应用过程中,在真正加载 share 模块之前,必须先通过 initializeSharing 的 runtime 代码保证各应用的 share 模块进行注册;
如果当前 shareScope 有同一个模块的多个版本,为了保证单例,在获取模块版本时,总是返回版本最大的那一个。如果有任何应用加载过程版本没匹配上,只是会做一个 warning 打印提示,并不会阻断加载流程;如果配置中 strictVersion 配置项为 true 时,则直接抛错阻断加载过程;
如果加载多个应用过程中,同时注册 share 模块,则如果版本一致的时候,会通过覆盖的方式,保证 shareScope 的同一模块同一版本只有一份。
最后
欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star