总述
几周前我写过一篇文章,讲述了 Webpack Module Federation 场景下的微前端解决方案。那么读者可能会好奇,mf 的模块共享究竟是如何实现的?在本文中我们将一探究竟。
提示
- 本文的案例来自官方 DEMO(来自 webpack module federation 官方文档)。建议读者在阅读本文之前将其运行起来,方便对照。
- 使用的 webpack 版本:5.65.0。
原理详解
案例基本介绍
案例有两个项目,第一个叫 app1,第二个叫 app2,app1 配置了远程路径 app2,并指定了共享依赖(即 shared 配置项,后文都称作共享依赖),分别为 react 和 react-dom。
另外,在业务代码中它复用了 app2 暴露出来的 Button 组件,这个我们称为远程组件, 请读者和 react、react-dom 这种共享依赖区分开 。
其 ModuleFederationPlugin 配置如下:
new ModuleFederationPlugin({
name: 'app1',
remotes: {
app2: `app2@${getRemoteEntryUrl(3002)}`,
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
打开 localhost:3001,app1 的界面如图所示:
app2 是生产者,暴露出了一个 Button 组件,由于要和 app1 共享 react 和 react-dom,它也要配置共享依赖,其 ModuleFederationPlugin 配置如下:
new ModuleFederationPlugin({
name: 'app2',
library: { type: 'var', name: 'app2' },
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
另外再给出 app1 的核心代码:
index.js
// index.js
import('./bootstrap'); // 动态 import
bootstrap.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
综合上述结论可以画出依赖图:
从 import react from 'react' 讲起
以 react 这个共享依赖为例,依照开发经验,react 真正被消费的时机在 import
语句中,找到相应的代码,react 的导入本质上是执行 __webpack_require__("reactModuleId")
:
reactModuleId
即 react 的模块 Id,由 webpack 生成,下面都用此简写来表示。
走进 __webpack_require__("reactModuleId")
,我们注意到在没有缓存的情况下,会从 __webpack_modules__["reactModuleId"]
中找到相应的模块,然后通过 Function.prototype.call
方法执行:
模块得以执行,上下文如下所示:
我们可以看出:
- 在某个时机
__webpack_require__.m["reactModuleId"]
被赋值(成一个函数),我们在 react 真正执行的时候就可以调用它,这个时机我们到后面再讨论。 - react 模块的执行,本质上是执行最后的
factory()
方法。 - 在 factory 方法执行之前的代码(也就是本文至此为止走过的流程)全部在 app1。
而 factory 方法的本质,则是执行 app2 的 __webpack_require__("../node_modules/react/index.js")
,如下所示(读者可以在 factory()
调用处单步调试,就可以看到下面的代码),接着就是 webpack 的常规模块获取逻辑,最终获取并执行 react 真正的模块, 具体实现就略去不表了。
上面消费 react 的流程执行流程为: app1 的代码 -- app2 的代码 -- react 真正的代码(来自 app2)
而将这些关联起来的核心,就是这个 factory()
方法,而 factory()
得以执行,依靠的就是 __webpack__.m[reactModuleId]
(这个东西本质上就是 __webpack_require__.m
),因为在打包产物的某处有一段这样的代码:
// expose the modules object (__webpack_modules__)
__webpack_require__.m = __webpack_modules__;
所以我们现在要找 webpack.m[reactModuleId]
值被写入的时机,这个值决定了作为远程依赖 react 的来源。
何时指定依赖源?
通过调试,指定依赖源的时机是 import('./bootstrap')
时(这也是为什么 mf 要求我们对入口文件需要使用动态的 import -- 我们要在此时下载 app2 的 mf 入口文件,然后判断到底需要加载哪一个模块),下面我们走一遍流程:
进入 index.js
,执行 import('./bootstrap');
语句。
接着执行 __webpack_require__.e("src_bootstrap_js")
,一路向下,最后会去执行 __webpack_require__.f.consumes(src_bootstrap_js 的 ModuleId)
。
通过 chunkId
从 chunkMapping
中拿到其下所有依赖的共享依赖,我们可以看到在 shared 中配置的 react 和 react-dom 的身影:
遍历 chunkMapping['src_bootstrap_js']
,通过 moduleToHandlerMapping
这个对象拿到对应的 loadSingletonVersionCheckFallback
回调,并执行之,执行的结果是一个 promise,它的 resoloved 回调参数就是我们在第一小节提到的 factory
函数:
可以看出依赖源的指定就是 loadSingletonVersionCheckFallback
方法了!我们来看它的具体实现,首先先看传参:
- default,sharedScope 的名称(
sharedScope
具体概念后面提) - react,共享依赖名称
- [1, 16, 13, 0],版本信息,这里表示 16.13.0
首先这个函数会去执行 __webpack_require__.I('default')
,来看其代码:
- 如果
__webpack_require__.S
中没有此sharedScope
,初始化成一个空对象。 - 在后面的
switch case
语句中 , 调用了 register 方法将 app1 的 react 和 react-dom 模块的加载器注册到名称为default
的sharedScope
中。 - 所谓
sharedScope
,就是一个 (key = 共享依赖名称,value = 包含该依赖各个版本的基本信息和加载器) 的对象。 - 如下图,app1 有一个名称为 default 的
sharedScope
,有两个共享依赖react
和reactDOM
,各自有一个版本,通过调用get()
方法来执行后续模块加载与执行的操作:
提示
这里的 get 方法执行,并不代表共享依赖被加载与执行(后面会提到),可以暂时把它看成一个加载器,还请读者注意。
- 在上面几个 register 之后,我们会调用
initExternal("webpack/container/reference/app2")
,这是和远程项目 app2 交互的纽带,这个方法会去下载远程 app2 相关入口文件,然后调用其暴露的 init 方法(mf 特有):
特别注意执行 app2 的 init 方法前的第一个入参,它是 __webpack_require__.S['default']
,即 app1 的 sharedScope
。
来看 app2 的 init()
方法:
var init = (shareScope, initScope) => {
if (!__webpack_require__.S) return;
var name = "default"
// 将 app1 的 shared scope 挂载到 app2 的 __webpack_require__.S 下
__webpack_require__.S[name] = shareScope;
// 执行 APP2 的 __webpack_require__.I,见下图
return __webpack_require__.I(name, initScope);
};
上图是 app2 的 __webpack_require__.I(name, initScope)
,
和上面 app1 的基本一致,但是有几点不同:
- 由于 app2 是生产者,没有配置远程依赖(remotes 参数), 所以下面的 switch 语句中就不在有
initExternal
的执行。 - 另外
__webpack_require__.S
中有app1
的sharedScope
,这个已经提过,是通过 app1 调用 init 方法初始化的。
由于 app2 也配置了公共依赖 react
和 react-dom
,所以类似于 app1 的逻辑,会调用 register 注册他们,但由于我们已经在 __webpack_require__.S
挂载了 app1 的 react
和 react-dom
,具体细节会有不同(请看下面注释):
var register = (name, version, factory, eager) => {
// 拿到已经 注册 react 的所有版本
var versions = (scope[name] = scope[name] || {});
// 寻找 16.14.0 是否已经初始化
var activeVersion = versions[version];
// 如果符合下面的条件之一(即下面的 if 语句为 true),
// 挂载 app2 的加载器,否则复用 app1 的加载器:
// 1. 在 app1 中没有对应的模块版本,即 activeVersion 为空
// 2. 旧版本没有被加载,并且没有配置 eager(强制加载)
// 3. 旧版本没有被加载,并且 app2 的 uniqueName > 源模块的 uniqueName
//(uniqueName 其实就是 packagejson 的 main 字段)
// TIP: 这个条件 3 我觉得很迷,感觉像一个兜底逻辑
// 如果知道为什么要这样做的同学务必告诉我,不过对复用的机制没有影响
// 另外这部分代码相应的 pull request 在这里,我已经询问了相关作者,但对方没有给出答复:
// https://github.com/webpack/webpack/pull/12132
if (
!activeVersion ||
(!activeVersion.loaded &&
(!eager != !activeVersion.eager
? eager
: uniqueName > activeVersion.from))
)
versions[version] = {
get: factory,
from: uniqueName,
eager: !!eager,
};
};
举个例子,如果 app1 配置了 shared = ['react', 'reactDOM']
,则 sharedScope
可能如下所示:
const scope = {
'react': {
'16.14.0': {
eager: false,
from: '@basic-host-remote/app1',
get: () => {
/* 获取、加载该模块的函数 */
},
},
},
'react-dom': {
'16.14.0': {
eager: false,
from: '@basic-host-remote/app1',
get: () => {
/* 获取、加载该模块的函数 */
},
},
},
};
如果 app2 配置了 shared = ['react', 'reactDOM']
,且 react 为 16.14.0
版本,react-dom 为 16.15.0
,则最终的 sharedScope
如下所示:
const scope = {
'react': {
'16.14.0': {
eager: false,
from: '@basic-host-remote/app1',
get: () => {
/* 一个函数,供获取、加载该模块 */
},
},
},
'react-dom': {
'16.14.0': {
eager: false,
from: '@basic-host-remote/app1',
get: () => {
/* 一个函数,供获取、加载该模块 */
},
},
'16.15.0': {
eager: false,
from: '@basic-host-remote/app2',
get: () => {
/* 一个函数,供获取、加载该模块 */
},
},
},
};
由于 shareScope
是一个引用类型,所以在 app1 和 app2 获取的 shareScope 值都是同一个对象。
上述操作的意义在于收集 module 的各个版本,供后续调用,在一定程度上也决定了本地 React 和远程组件的 React 在 React 版本相同的情况下,会使用谁的。
回到 app1 的上下文,__webpack_require__.I(scopeName)
执行完成,接着会去执行 getSingletonVersion
方法:
// 注意:下面的 versionLt(a, b) 是一个基于 semver 规范的版本比较函数,
// 表示 若 a 比 b 版本小,返回真,否则假
// 其具体实现较复杂,且不是本文要点,故略去
// 感兴趣的读者可以查看源码:https://github.com/webpack/webpack/blob/ccecc17c01af96edddb931a76e7a3b21ef2969d8/lib/util/semver.js#L42
var findSingletonVersionKey = (scope, key) => {
// 拿到共享依赖的所有版本
var versions = scope[key];
// 遍历所有版本号,利用 reduce 方法,从代码中不难看出最终返回的版本是版本较大的那个。
return Object.keys(versions).reduce((a, b) => {
return !a || (!versions[a].loaded && versionLt(a, b)) ? b : a;
}, 0);
};
var getSingletonVersion = (scope, scopeName, key, requiredVersion) => {
var version = findSingletonVersionKey(scope, key);
if (!satisfy(requiredVersion, version)) typeof console !== "undefined" && console.warn && console.warn(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion));
return get(scope[key][version]);
};
拿到对应的版本之后,直接获取 get 方法,然后调用它,这个 get 方法执行并没有拉取模块,而是返回一个工厂函数,这个工厂函数就是我们之前提到的 factory()
,最终和第一小节所讲的内容实现了完美的闭环:
加载远程 expose 的 Button 组件
app1 还有一个能力,那就是调用了 app2 的一个组件,在实际开发中,他也可以是一个 JavaScript 模块,甚至是一个 JavaScript 库,那么这个是如何实现的呢,上面的依赖共享提到了远程 mf 入口提供了一个 init 方法,除此以外,还提供了一个 get 方法供 app1 调用:
接下来的流程就和 webpack 的基本模块调度逻辑类似了,如果这个组件是一个懒加载组件,那么去执行懒加载逻辑,否则就执行默认加载的逻辑,相信读者应该很熟悉了。
实践与补充
开头提到的的例子
基于上面的原理,我们再来总结文中开头提到的案例:
// app1 的 mf 配置
new ModuleFederationPlugin({
name: 'app1',
remotes: {
app2: `app2@${getRemoteEntryUrl(3002)}`,
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
// app2 的 mf 配置
new ModuleFederationPlugin({
name: 'app2',
library: { type: 'var', name: 'app2' },
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button',
},
shared: { react: { singleton: true }, 'react-dom': { singleton: true } },
}),
另外 app1 的 package.json 的 main 属性为 @basic-host-remote/app1
,app2 为 @basic-host-remote/app2
。
那么打开 app1,app1 加载了 react(假设两 app 的 react 版本相同),请问这个 react 是谁的?
答案是 app2,app1 初始化 sharedScope,如下:
const scope = {
'react': {
'16.14.0': {
eager: false,
from: '@basic-host-remote/app1',
get: () => {
/* 一个函数,供获取、加载该模块 */
},
},
}
};
接着 app1 调用 app2 的 init 方法,app2 的初始 sharedScope 来自 app1,app2 在注册自己的 react 发现版本相同,且 app2 的 package.json 字符串 > app1 的 package.json 字符串(字符串比较),则 app1 的 react['16.14.0']
被覆盖,所以接下来 app1 的 react 和 app2 的 react 最终指向 app2 的 react。
app1 和 app2 维护不同版本的共享依赖
- 还是和上面一样的配置,但 app1 的 react 和 react-dom 版本变成了 16.14.0,app2 的 react 和 react-dom 版本变成了 16.13.0,请问 app1 加载了哪个版本的 react?
答案是 react 版本更高的 app1:
在这种情况下,app1 的 sharedScope
如下,由于两个 react 版本不一样,所以没有冲突,都被保留下来:
const scope = {
'react': {
'16.14.0': {
eager: false,
from: '@basic-host-remote/app1',
get: () => {
/* 一个函数,供获取、加载该模块 */
},
},
'16.13.0': {
eager: false,
from: '@basic-host-remote/app2',
get: () => {
/* 一个函数,供获取、加载该模块 */
},
},
}
};
真正决定使用哪个版本是在执行入口异步 chunk,执行 getSingletonVersion
的阶段,我们会选择那个最大的,也就是 16.14.0
,它来自 app1,所以加载 app1 的 react。
同一页面,多版本共存
我们更新 app1 的 package.json
,将 react 版本号改为 16.14.0
,同理,将 app2 的 react 版本改成 17.0.2
。
特别注意: 官方案例的 webpack 配置中对每一个 shared 的依赖都增加了 singleton: true 属性(该值默认为 false),这个属性会强制使用单个版本的 react,请读者务必删除此选项。
然后在 App1 入口的代码、App2 expose 的 Button 组件处打印一下 React.version
。
打开 app1,刷新页面,查看浏览器控制台,可以看到此时页面上两个版本 React:
当然,多版本的 React 在真实环境下不会使用,这里只是举一个例子,但是在微前端架构下,由于种种原因,一些工具库可能要做到多版本共存,不难看出,mf 已经优雅地帮我们解决了这个问题。
那么多版本共存的原理是?基于上面的源码分析,特别简单,请看 React 加载时的代码:
App1
App2
可以看出,mf 插件在编译时就已经通过模块 Id 之后追加的 hash 值来确定版本,对于不同的版本会从 webpack_require.m
对象中拿到不同的加载器,从而执行不同的 factory
函数。
参考资料
达达XxjzZ(知乎用户),Module Federation 没有魔法仅仅是异步chunk
webpack,Module Federation 案例合集