前言
在上一篇文章中,我从 SharePlugin 源码出发,详细解析了 ConsumeSharedPlugin 的源码,从而引出了 ConsumeSharedModule 和 ConsumeSharedRuntimeModule 等对于 shared 机制非常重要的模块类型,它们提供了 MF share 模块加载机制中关键的 runtime 代码。最后通过几个例子,我总结了 MF share 模块加载机制中重要的几个理论要点。
本篇文章,我会结合编译后的 runtime 代码补充 share 模块加载机制的原理解析,在此之前,我会继续完成 SharePlugin 中另一个插件:ProvideSharedPlugin 的源码的解析。
源码解析
ProvideSharedPlugin
我在上一篇文章刚开始解析 SharePlugin 源码的时候,首先从 Webpack 的官网对 MF 的介绍和插件命名来分析 SharePlugin 的理论,我们可以大概猜想,share 模块的设计是一个消费者和生产者的模型 ,在 MF 整个设计中,这部分也是非常关键和巧妙的一点,我们将要解析的 ProvideSharedPlugin 也是这个模型中必不可少的另一半。
话不多说,我们进入源码解析。
ProvideSharedPlugin 插件的源码在 lib/sharing/ProvideSharedPlugin.js 中,我们先看 parseOptions 部分:
parseOptions
class ProvideSharedPlugin {
/**
* @param {ProvideSharedPluginOptions} options options
*/
constructor(options) {
validate(options);
/** @type {[string, ProvideOptions][]} */
this._provides = parseOptions(
options.provides,
item => {
if (Array.isArray(item))
throw new Error("Unexpected array of provides");
/** @type {ProvideOptions} */
const result = {
shareKey: item,
version: undefined,
shareScope: options.shareScope || "default",
eager: false
};
return result;
},
item => ({
shareKey: item.shareKey,
version: item.version,
shareScope: item.shareScope || options.shareScope || "default",
eager: !!item.eager
})
);
this._provides.sort(([a], [b]) => {
if (a < b) return -1;
if (b < a) return 1;
return 0;
});
}
// 省略一些代码
}
parseOption
部分逻辑比较简单,一般都是给一些未传值的变量赋默认值。如果我们在 MF shared
配置中添加如下配置:
const shared = {
react: {
requiredVersion: packageJson['dependencies']['react'],
singleton: true,
},
'react-dom': {
requiredVersion: packageJson['dependencies']['react-dom'],
singleton: true,
},
lodash: '^4.17.0'
}
经过 SharePlugin 的处理,然后到这里插件自身的处理,最后 _provides
结构如下:
const _provides = [
[
'react' ,
{
shareKey: "react",
version: undefined,
shareScope: "default",
eager: false,
}
],
[
'react-dom',
{
shareKey: "react-dom",
version: undefined,
shareScope: "default",
eager: false,
}
],
[
'lodash',
{
shareKey: "lodash",
version: undefined,
shareScope: "default",
eager: false,
}
]
]
这里需要注意一个细节,下面会对 _provides
进行一个排序,这个算法也比较好理解,按数组每一项的第一项也就是 react
、react-dom
、lodash
排序,排完后,数组顺序变成了 lodash
、react
、react-dom
。暂时不知道这里的用意是什么,继续往下看。
provideSharedModule
接下来进入apply
方法,首先定义了 compilationData
变量,类型为 WeakMap<Compilation, ResolvedProvideMap>
,接着监听了 compilation hook
:
/**
* Apply the plugin
* @param {Compiler} compiler the compiler instance
* @returns {void}
*/
apply(compiler) {
/** @type {WeakMap<Compilation, ResolvedProvideMap>} */
const compilationData = new WeakMap();
compiler.hooks.compilation.tap(
"ProvideSharedPlugin",
(compilation, { normalModuleFactory }) => {
/** @type {ResolvedProvideMap} */
const resolvedProvideMap = new Map();
/** @type {Map<string, ProvideOptions>} */
const matchProvides = new Map();
/** @type {Map<string, ProvideOptions>} */
const prefixMatchProvides = new Map();
for (const [request, config] of this._provides) {
if (/^(\/|[A-Za-z]:\\|\\\\|\.\.?(\/|$))/.test(request)) {
// relative request
resolvedProvideMap.set(request, {
config,
version: config.version
});
} else if (/^(\/|[A-Za-z]:\\|\\\\)/.test(request)) {
// absolute path
resolvedProvideMap.set(request, {
config,
version: config.version
});
} else if (request.endsWith("/")) {
// module request prefix
prefixMatchProvides.set(request, config);
} else {
// module request
matchProvides.set(request, config);
}
}
})
compilationData.set(compilation, resolvedProvideMap);
// 这个方法核心的作用就是将 module 的 config 和 version 信息
// 存在 resolvedProvideMap 中
const provideSharedModule = (
key,
config,
resource,
resourceResolveData
) => {
// 省略实现
};
// 省略一些代码
}
然后开始遍历_provides
,根据配置构造 resolvedProvideMap
、matchProvides
、prefixMatchProvides
变量,这三个变量有点眼熟,都是 Map
数据结构。在上一篇文章中,解析 ConsumeSharedPlugin 源码中的resolveMatchedConfigs
方法的时候,也是有构造这三个变量的过程,所以这里我们简单回顾下。
实际上前面的 if
和 if else
匹配到的 request
一般指的是当前项目内部模块,也就项目源码本身创建的 JS 模块,这类模块会存在 resolvedProvideMap
变量中;接着是以 /
结尾的匹配一类模块的配置,其实就是类似一个通配,这种配置方式很少见,例如配置 prefixModule/
,下面的导入方式模块将会命中:
import a from 'prefixModule/a'
import b from 'prefixModule/b'
这类模块会存在 prefixMatchProvides
中;最后一类,如果以上的方式都没匹配上,则存在 matchProvides
中,这类模块一般是来自 node_modules 目录,比如我们上面例子中的 react
、react-dom
、lodash
等都属于这类模块。
继续往下会将 resolvedProvideMap
使用前面定义的 compilationData
存下来,这里的 key
是
compilation
对象。普及一点小知识,**在 Webpack 单次构建过程中,一般情况 compilation
对象是唯一的,只有在开发模式下,每次文件变更,会触发新的一次构建过程。**所以猜测这里的 compilationData 可能是为了做一些缓存之类的处理,先继续往下看。
接着定义了 provideSharedModule
方法,我们先不看方法的实现,先往下看调用的地方,再回过头看其详细的实现。
接着监听了 normalModuleFactory
中的 module hook
,这个 hook
触发的时机是在创建一个 NormalModule
实例后:
normalModuleFactory.hooks.module.tap(
"ProvideSharedPlugin",
(module, { resource, resourceResolveData }, resolveData) => {
if (resolvedProvideMap.has(resource)) {
return module;
}
const { request } = resolveData;
{
const config = matchProvides.get(request);
if (config !== undefined) {
provideSharedModule(
request,
config,
resource,
resourceResolveData
);
// 将 resolveData cacheable 属性置为 false,
// 暂时不知道 cacheable 属性的作用,后面再看
resolveData.cacheable = false;
}
}
for (const [prefix, config] of prefixMatchProvides) {
if (request.startsWith(prefix)) {
const remainder = request.slice(prefix.length);
provideSharedModule(
resource,
{
...config,
shareKey: config.shareKey + remainder
},
resource,
resourceResolveData
);
resolveData.cacheable = false;
}
}
return module;
}
);
}
也就是说这里也是劫持了模块的创建过程。首先判断当前模块是否在resolvedProvideMap
中,通过
resource
查找,如果在,则直接返回创建好的 normalModule
实例,也就是说内部模块,不需要处理,直接跳过。
注意这里的 resource
,一般对于内部模块,创建的时候会带有相对或者绝对路径的resource
字段,例如项目中 utils 模块,resource
值为./src/utils/index.ts
。对于从 node_modules 来的模块,一般 resource
和 request
相同。
接着解析的是 matchProvides
模块,通过 request
查找,这里从钩子回调函数里取出模块创建成功后的 resourceResolveData
中取出 request
字段,然后去 matchProvides
中找,如果找到,则调用
provideSharedModule
方法处理该模块。
最后处理的是 prefixMatchProvides
,意图也很明显,看是否命中了 request
,因为前面说过这种配置方式是匹配一类统一前缀的模块,所以这边需要遍历,通过 startsWith
方法去看是否在 prefixMatchProvides
能找到,命中后同样会走 provideSharedModule
的逻辑。
所以,provideSharedModule
的实现还是非常重要的,我们看下该方法的实现:
const provideSharedModule = (
key,
config,
resource,
resourceResolveData
) => {
let version = config.version;
if (version === undefined) {
let details = "";
if (!resourceResolveData) {
details = `No resolve data provided from resolver.`;
} else {
const descriptionFileData =
resourceResolveData.descriptionFileData;
if (!descriptionFileData) {
details =
`No description file (usually package.json) found.
Add description file with name and version,
or manually specify version in shared config.`;
} else if (!descriptionFileData.version) {
details = `No version in description
file (usually package.json).
Add version to description
file ${resourceResolveData.descriptionFilePath},
or manually specify version in shared config.`;
} else {
version = descriptionFileData.version;
}
}
if (!version) {
const error = new WebpackError(
`No version specified and unable to
automatically determine one. ${details}`
);
error.file = `shared module ${key} -> ${resource}`;
compilation.warnings.push(error);
}
}
resolvedProvideMap.set(resource, {
config,
version
});
};
实际上这里的过程就是会把 matchProvides
和 prefixMatchProvides
转换成 resolvedProvideMap
模块,而前提是这类模块能找到version
的信息。对于来自 node_modules 中的模块,这好说,一般都能从 package.json
中找到版本。这里的查找版本信息的逻辑也是,如果你的配置信息里面没有 version
配置项,则尝试从模块的 descriptionFileData
信息去寻找,而这里的 descriptionFileData
中的数据一般来自 package.json
。
也就是说无论怎么样,前面构造的三种模块类型,经过这里的处理,都会成为 resolvedProvideMap
里面的模块。我们继续看下面的源码,看下 resolvedProvideMap
在哪里被消费。
compilation.addInclude
// 在执行完 make hook 后执行,而 make 触发的时机在完成 compilation 创建后
compiler.hooks.finishMake.tapPromise("ProvideSharedPlugin", compilation => {
const resolvedProvideMap = compilationData.get(compilation);
if (!resolvedProvideMap) return Promise.resolve();
return Promise.all(
Array.from(
resolvedProvideMap,
([resource, { config, version }]) =>
new Promise((resolve, reject) => {
// 这里相当于执行了 compilation addEntry 逻辑
// 意思就是需要将这些配置的 shared 模块按入口 entry 配置的方式
// 作为一个 module 去构建
// addInclude -> _addEntryItem -> addModuleTree -> handleModuleCreation
compilation.addInclude(
compiler.context,
new ProvideSharedDependency(
config.shareScope,
config.shareKey,
version || false,
resource,
config.eager
),
{
name: undefined
},
err => {
if (err) return reject(err);
resolve();
}
);
})
)
).then(() => {});
});
接下来监听了 finishMake hook
,这个 hook
触发时机在 make hook
触发后,也就是 compilation
对象创建之后。这里就使用到了前面定义的 compilationData
,首先会判断当前的构建过程是否有
resolvedProvideMap
数据,如果没有,则下面的逻辑可以跳过,相当于是做了一个优化,减少非必要的代码执行。
接下来是遍历 resolvedProvideMap
中的数据,然后调用 compilation.addInclude
方法 ,创建
ProvideSharedDependency
,实际上这里的 addInclude
可以简单理解为是在 Webpack 构建过程中,增加了新的 entry
,然后依次执行如下函数 addInclude -> addModuleTree -> handleModuleCreation
等流程。因为这里是一个异步的过程,所以需要使用 Promise.all 进行处理。
总结下,看完这一块的源码我们知道 provideSharedPlugin 的作用就是根据配置,构造出可以在项目中 resolved 的模块,存在 resolvedProvideMap
中,然后在构建开始的过程中,通过 compilation.addInclude
将这些模块通过 ProvideSharedDependency
添加到构建流程。
而 ProvideSharedDependency
实际上它有自己的 ModuleFactory,在插件源码的最后,进行了
dependencyFactories
对应:
compiler.hooks.compilation.tap(
"ProvideSharedPlugin",
(compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(
ProvideForSharedDependency,
normalModuleFactory
);
compilation.dependencyFactories.set(
ProvideSharedDependency,
new ProvideSharedModuleFactory()
);
}
);
接下里,又进入了熟悉的 Module 源码解析环节,我们继续看下 ProvideSharedModule 的核心实现。
ProvideSharedModule
build
我们先看下 build
方法的实现:
/**
* @param {WebpackOptions} options webpack options
* @param {Compilation} compilation the compilation
* @param {ResolverWithOptions} resolver the resolver
* @param {InputFileSystem} fs the file system
* @param {function(WebpackError=): void} callback callback function
* @returns {void}
*/
build(options, compilation, resolver, fs, callback) {
this.buildMeta = {};
this.buildInfo = {
strict: true
};
this.clearDependenciesAndBlocks();
const dep = new ProvideForSharedDependency(this._request);
if (this._eager) {
this.addDependency(dep);
} else {
const block = new AsyncDependenciesBlock({});
block.addDependency(dep);
this.addBlock(block);
}
callback();
}
build
方法的实现跟 ConsumeSharedModule 的 build
方法实现基本一致,唯一的区别是这里创建Dependency 实例的类不一样,这里使用的是 ProvideForSharedDependency。在解析 ProvideSharedPlugin 源码的时候,我们知道 ProvideForSharedDependency 实际上对应的 ModuleFactory 是 NormalModuleFactory。
同样也会根据传入eager
的值决定来创建是一个 initial chunk 还是 async chunk,我们继续往下看
codeGeneration
的实现。
codeGeneration
/**
* @param {CodeGenerationContext} context context for code generation
* @returns {CodeGenerationResult} result
*/
codeGeneration({ runtimeTemplate, moduleGraph, chunkGraph }) {
const runtimeRequirements = new Set([RuntimeGlobals.initializeSharing]);
const code = `register(${JSON.stringify(this._name)}, ${JSON.stringify(
this._version || "0"
)}, ${
this._eager
? runtimeTemplate.syncModuleFactory({
dependency: this.dependencies[0],
chunkGraph,
request: this._request,
runtimeRequirements
})
: runtimeTemplate.asyncModuleFactory({
block: this.blocks[0],
chunkGraph,
request: this._request,
runtimeRequirements
})
}${this._eager ? ", 1" : ""});`;
const sources = new Map();
const data = new Map();
data.set("share-init", [
{
shareScope: this._shareScope,
initStage: 10,
init: code
}
]);
return { sources, data, runtimeRequirements };
}
首先构造了 runtimeRequirements
,这里需要依赖 initializeSharing
的运行时方法,对应的是:
__webpack_require__.I
,这个方法的作用是专门初始化 shareScope,而且在页面加载过程中只初始化一次,后面我在详细解析 share 模块加载原理的时候再详细介绍。
接着构造 runtime 代码,这里的code
调用了 register
方法,然后第一个参数是 _name
,实际上对应的是 share 模块对应的 shareKey
,例如前面的例子中配置的 react 模块,则对应的 shareKey
是 react。
然后根据不同的 eager
配置来生成不同的加载模块的 factory 方法。
实际上对于所有的 share 模块,如果应用任意其它 chunk 依赖了 share 模块,则同样需要在构建时加入这段运行时代码。也就是说,对于 share 模块,无论是上一篇文章提到的 ConsumeSharedModule,还是这里的 ProvideSharedModule,它们的本质是为了提供 share 模块加载机制的运行时代码,而这个模型无论是对于提供方: Provide ,还是消费方:Consume,它们需要互相依赖,必须同时存在,才能保证 share 模块加载机制。
启动上一篇文章中提到的 app1 服务,访问页面,然后打开 devtool,我们通过 register 关键字搜索构建产物,发现如下代码:
实际上这里的几个register
调用,就是从 ProvideSharedModule 模块提供的运行时代码拼接而来的。
那么这些代码是在哪拼接的?register
方法又是从哪来?
ShareRuntimeModule
实际上在整个 SharePlugin 运作过程中,大量需要依赖到 shareScope 这个 runtime 变量,除了在 share 相关插件中直接使用如下方式增加运行时方法:
// 以 lib/sharing/ConsumeShardPlugin.js 为例
compilation.hooks.additionalTreeRuntimeRequirements.tap(
PLUGIN_NAME,
(chunk, set) => {
// 这里跟 ContainerReferencePlugin 差不多,为相关的 chunk 添加 webpack runtime 依赖的函数
set.add(RuntimeGlobals.module); // module,内部模块对象
set.add(RuntimeGlobals.moduleCache); // __webpack_require__.c 模块缓存对象
set.add(RuntimeGlobals.moduleFactoriesAddOnly); // __webpack_require__.m (add only)
set.add(RuntimeGlobals.shareScopeMap); // __webpack_require__.S
set.add(RuntimeGlobals.initializeSharing); // __webpack_require__.I
set.add(RuntimeGlobals.hasOwnProperty); // __webpack_require__.o
// 将相关的 shared chunk 建立与 ConsumeSharedRuntimeModule 的关系
compilation.addRuntimeModule(
chunk,
new ConsumeSharedRuntimeModule(set)
);
}
);
Webpack 内部插件 RuntimePlugin 也会在构建初始化时补充更多的运行时代码,而这里也补充了 share 机制相关的 ShareRuntimeModule:
class RuntimePlugin {
/**
* @param {Compiler} compiler the Compiler
* @returns {void}
*/
apply(compiler) {
compiler.hooks.compilation.tap("RuntimePlugin", compilation => {
// 省略一些代码
compilation.hooks.runtimeRequirementInTree
.for(RuntimeGlobals.ensureChunkIncludeEntries)
.tap("RuntimePlugin", (chunk, set) => {
set.add(RuntimeGlobals.ensureChunkHandlers);
});
// 添加 ShareRuntimeModule
compilation.hooks.runtimeRequirementInTree
.for(RuntimeGlobals.shareScopeMap)
.tap("RuntimePlugin", (chunk, set) => {
compilation.addRuntimeModule(chunk, new ShareRuntimeModule());
return true;
});
// 省略一些代码
})
}
}
RuntimePlugin 源码比较好理解,这里我就不详细解析了,感兴趣读者可以自行阅读。
构造 initCodePerScope
我们来看下 ShareRuntimeModule 做了什么,直接看 generate
方法的实现:
/**
* @returns {string} runtime code
*/
generate() {
const { compilation, chunkGraph } = this;
const {
runtimeTemplate,
codeGenerationResults,
outputOptions: { uniqueName }
} = compilation;
/** @type {Map<string, Map<number, Set<string>>>} */
const initCodePerScope = new Map();
for (const chunk of this.chunk.getAllReferencedChunks()) {
const modules = chunkGraph.getOrderedChunkModulesIterableBySourceType(
chunk,
"share-init",
compareModulesByIdentifier
);
if (!modules) continue;
for (const m of modules) {
const data = codeGenerationResults.getData(
m,
chunk.runtime,
"share-init"
);
if (!data) continue;
for (const item of data) {
const { shareScope, initStage, init } = item;
let stages = initCodePerScope.get(shareScope);
if (stages === undefined) {
initCodePerScope.set(shareScope, (stages = new Map()));
}
let list = stages.get(initStage || 0);
if (list === undefined) {
stages.set(initStage || 0, (list = new Set()));
}
list.add(init);
}
}
}
// 省略一些代码
}
我们先看第一部分,可以发现,这里的逻辑是为了构造 initCodePerScope
这个变量。首先是从 chunk 中取出所有类型为 share-init 的模块,而这种模块即为上一小节提到的 ProvideSharedModule,然后从模块的codeGenerationResults 中取出对应的运行时代码,然后构造出 initCodePerScope
。
这里的逻辑跟上一篇文章中解析到的 ConsumeSharedRuntimeModule 实现也是基本思路是一致的,而 ConsumeSharedRuntimeModule 处理的是 consume-shared 类型的模块。
生成 runtime 代码
接下来并开始生成运行时的代码,因为代码比较长,我们看关键的部分:
generate () {
// 省略一些代码
return Template.asString([
`${RuntimeGlobals.shareScopeMap} = {};`,
"var initPromises = {};",
"var initTokens = {};",
`${RuntimeGlobals.initializeSharing} = ${runtimeTemplate.basicFunction(
"name, initScope",
[
// 省略一些代码
`var register = ${runtimeTemplate.basicFunction(
"name, version, factory, eager",
[
"var versions = scope[name] = scope[name] || {};",
"var activeVersion = versions[version];",
`if(!activeVersion ||
(!activeVersion.loaded &&
(!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) {
versions[version] = { get: factory, from: uniqueName, eager: !!eager };
}`
]
)};`,
// 省略一些代码
"var promises = [];",
"switch(name) {",
...Array.from(initCodePerScope)
.sort(([a], [b]) => compareStrings(a, b))
.map(([name, stages]) =>
Template.indent([
`case ${JSON.stringify(name)}: {`,
Template.indent(
Array.from(stages)
.sort(([a], [b]) => a - b)
.map(([, initCode]) =>
Template.asString(Array.from(initCode))
)
),
"}",
"break;"
])
),
"}",
"if(!promises.length) return initPromises[name] = 1;",
`return initPromises[name] = Promise.all(promises)
.then(${runtimeTemplate.returningFunction(
"initPromises[name] = 1"
)});`
]
)};`
]);
}
我这里贴了部分关键的消费 initCodePerScope
变量的代码,可以发现,这里并是拼接 ProvideSharedModule 运行时代码的地方,也就是上一小节截图里面的 register
方法的调用,而
register
方法也是由这里的 runtime 代码提供。
案例分析
那这里的 register
的作用是什么了?通过 debug,我从前面 app1 例子中找出这些关键的运行时代码:
__webpack_require__.S = {};
var initPromises = {};
var initTokens = {};
__webpack_require__.I = (name, initScope) => {
if(!initScope) initScope = [];
// handling circular init calls
var initToken = initTokens[name];
if(!initToken) initToken = initTokens[name] = {};
if(initScope.indexOf(initToken) >= 0) return;
initScope.push(initToken);
// only runs once
if(initPromises[name]) return initPromises[name];
// creates a new share scope if needed
if(!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {};
// runs all init snippets from all modules reachable
// 这里的 name 是当前 app1 的 scope,没有定义,默认是 `default`
var scope = __webpack_require__.S[name];
var uniqueName = "@typescript/app1";
var register = (name, version, factory, eager) => {
var versions = scope[name] = scope[name] || {};
var activeVersion = versions[version];
if(!activeVersion || (!activeVersion.loaded && (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) versions[version] = { get: factory, from: uniqueName, eager: !!eager };
};
var promises = [];
switch(name) {
case "default": {
register("lodash", "4.17.21", () => (__webpack_require__.e("vendors-node_modules_lodash_lodash_js").then(() => (() => (__webpack_require__(/*! ./node_modules/lodash/lodash.js */ "./node_modules/lodash/lodash.js"))))));
register("react-dom", "16.13.0", () => (Promise.all([__webpack_require__.e("vendors-node_modules_react-dom_index_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react")]).then(() => (() => (__webpack_require__(/*! ./node_modules/react-dom/index.js */ "./node_modules/react-dom/index.js"))))));
register("react", "16.13.0", () => (__webpack_require__.e("vendors-node_modules_react_index_js").then(() => (() => (__webpack_require__(/*! ./node_modules/react/index.js */ "./node_modules/react/index.js"))))));
}
break;
}
if(!promises.length) return initPromises[name] = 1;
return initPromises[name] = Promise.all(promises).then(() => (initPromises[name] = 1));
};
这里的 __webpack_require__.I
方法,在加载 app1 的过程就会执行,作用是初始化 app1 的 share 模块,或者说就是一个 share 模块注册的过程,而且这个注册只做一次,而且根据配置的模块版本作为 key 进行映射。然后注册后的 share 模块存在 __webpack_require__.S
这个变量中,如果 share 模块已经被加载过,在后续无论是加载 app1 自身 chunk 依赖还是消费 app2 组件 chunk 依赖,如果命中 share 模块(shareKey 和 version 一致),都会复用这个模块。
这就是 MF share 模块单例加载的奥秘,通过在多个远程应用中加入注册 share 模块的运行时代码,在启动应用的过程中,首先注册应用本身的 share 模块,接着加载应用 chunk 过程中如果加载过一次 share 模块, 则其它所有应用都会复用这个模块,除非其它应用的 share 模块加载失败或者 version 不匹配,则 fallback 到其它应用构建出来的 share 模块。这也是为什么 SharePlugin 需要同时通过 Consume 和 Provide 两个插件注入类似运行时代码的原因。
到这里 SharePlugin 的源码基本解析完毕,为了让读者更加深入理解 share 模块加载的原理,接下来,我们结合上一篇介绍的 ConsumeSharedPlugin 以及实际的案例对其完整的运行时代码进行更加详细的解析。
Share 模块加载运行时代码解析
example
我们先看一个例子,现在有 app1,app2 两个应用,互相依赖对方导出的组件。app1 的 Webpack 配置代码如下:
module.exports = {
entry: './src/index',
mode: 'development',
devServer: {
static: {
directory: path.join(__dirname, 'dist'),
},
port: 3001,
},
// 省略一些配置
plugins: [
new ModuleFederationPlugin({
name: 'app1',
remotes: {
app2: 'app2@http://localhost:3002/remoteEntry.js',
},
filename: 'remoteEntry.js',
exposes: {
'./Input': './src/components/Input',
},
shared: {
react: {
requiredVersion: packageJson['dependencies']['react'],
singleton: true,
},
'react-dom': {
requiredVersion: packageJson['dependencies']['react-dom'],
singleton: true,
},
lodash: '^4.17.0'
},
}),
],
};
app1 的一些关键代码如下:
// src/index.ts 入口文件
import('./bootstrap')
// src/bootstrap.tsx
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));
// src/App.tsx
import * as React from 'react';
import LocalButton from './components/Button'
import { assign } from './utils/lodash'
console.log(assign({a: 1}, { b: 2}))
const RemoteButton = React.lazy(() => import('app2/Button'));
const App = () => (
<div>
<h1>Typescript</h1>
<h2>App 1</h2>
<React.Suspense fallback="Loading Button">
<RemoteButton />
</React.Suspense>
<LocalButton type="primary">Local Button</LocalButton>
</div>
);
export default App;
app2 的 Webpack 配置如下:
module.exports = {
entry: './src/index',
// 省略一些配置
plugins: [
new ModuleFederationPlugin({
name: 'app2',
filename: 'remoteEntry.js',
exposes: {
'./Button': './src/Button',
},
remotes: {
app1: 'app1@http://localhost:3001/remoteEntry.js',
},
shared: {
react: {
requiredVersion: packageJson['dependencies']['react'],
singleton: true,
},
'react-dom': {
requiredVersion: packageJson['dependencies']['react-dom'],
singleton: true,
},
},
}),
],
};
// app2/Button.tsx
import * as React from 'react';
const Button = () => <button>App 2 Button</button>;
export default Button;
这里省略了一些配置,这里我们只需要知道一个事实,那就是 app1 依赖 app2 的 Button
组件,然后它们在各自的 Webpack 中都配置了 react 和 react-dom 这两个包为 shared 并声明了单例和版本,而 app1 作为一个 React 应用,在初始化的时候就需要加载 react 和 react-dom 相关 chunk。而 app2 导出的 Button
组件也是基于 React 的一个组件,它同样依赖 react 和 react-dom,接下来我们访问 app1 的服务,看下 react 这个 chunk 的加载过程。
我们先看一个从启动 app1 到 react chunk 加载完成的流程图:
注意,这里只是展示 app1 启动到 react chunk 加载完成的过程,并没有包含其它的 app1 资源加载流程。
因为在 app1 的源码中,我们通过如下方式消费:
const RemoteButton = React.lazy(() => import('app2/Button'));
app2 的远程组件,这会导致在整个依赖加载过程中,会拉取 app2 构建的 remoteEntry.js
。当然在此之前,app1 的 share 模块已经完成注册,因为入口是从 app1 应用发起,但是尚未加载。然后紧接初始化 app2 配置的 share 模块,在这个过程中,打开 network devtool ,我们发现此时 react chunk 是来自于 app2:
为什么不加载 app1 自身的 chunk 了?让我们根据前面的流程图,逐步分析相关的运行时代码。
runtime 代码分析
首先是 ensureChunk 方法,它对应的运行时函数为 __webpack_require__.e
,一般配套使用的变量为ensureChunkHandlers,对应的运行时变量为 __webpack_require__.f
,在 app1 编译后的源码,比较前面的位置,我们可以找到如下代码:
__webpack_require__.f = {};
// This file contains only the entry chunk.
// The chunk loading function for additional chunks
__webpack_require__.e = (chunkId) => {
return Promise.all(Object.keys(__webpack_require__.f)
.reduce((promises, key) => {
__webpack_require__.f[key](chunkId, promises);
return promises;
}, []));
};
从注释可以知道,这个方法的作用是保证在加载某个 chunk 时,确保 chunk 能加载到正确的模块或者额外需要的模块。我们可以在 app1 构建后的源码中随处可以找到此方法的调用:
那么这里的 __webpack_require__.f
会存储一些什么信息了,实际上在解析前面 ConsumeSharedRuntimeModule 的源码过程中,其提供的运行时代码,它会定义 consume-shared 模块类型的加载方法:
// no consumes in initial chunks
var chunkMapping = {
"webpack_sharing_consume_default_react_react": [
"webpack/sharing/consume/default/react/react"
],
"webpack_sharing_consume_default_react-dom_react-dom": [
"webpack/sharing/consume/default/react-dom/react-dom"
],
"src_utils_lodash_ts": [
"webpack/sharing/consume/default/lodash/lodash"
]
};
__webpack_require__.f.consumes = (chunkId, promises) => {
if(__webpack_require__.o(chunkMapping, chunkId)) {
// 省略具体实现
}
}
以及在 RemoteRuntimeModule 中,它会定义 remote 模块类型的加载方法:
var chunkMapping = {};
var idToExternalAndNameMapping = {};
__webpack_require__.f.remotes = (chunkId, promises) => {
if(__webpack_require__.o(chunkMapping, chunkId)) {
// 省略具体实现
}
}
所以有前面流程图中的,加载 react chunk 过程中会走到调用 __webpack_require__.f.xxx
方法的流程中。相当于加载模块之前,首先需要确定该模块是否在 MF 提供的 share 模块中找到,如果命中,则会走 share 模块的加载过程。
share 模块相比正常的模块加载,会有一些额外的处理,例如版本校验、保证单例、fallback 处理等。这部分的运行时代码由 ConsumeSharedRuntimeModule 提供:
var parseVersion = (str) => {
// see webpack/lib/util/semver.js for original code
var p=p=>{return p.split(".").map((p=>{return+p==p?+p:p}))},n=/^([^-+]+)?(?:-([^+]+))?(?:\+(.+))?$/.exec(str),r=n[1]?p(n[1]):[];return n[2]&&(r.length++,r.push.apply(r,p(n[2]))),n[3]&&(r.push([]),r.push.apply(r,p(n[3]))),r;
}
var versionLt = (a, b) => {
// see webpack/lib/util/semver.js for original code
a=parseVersion(a),b=parseVersion(b);for(var r=0;;){if(r>=a.length)return r<b.length&&"u"!=(typeof b[r])[0];var e=a[r],n=(typeof e)[0];if(r>=b.length)return"u"==n;var t=b[r],f=(typeof t)[0];if(n!=f)return"o"==n&&"n"==f||("s"==f||"u"==n);if("o"!=n&&"u"!=n&&e!=t)return e<t;r++}
}
var rangeToString = (range) => {
// see webpack/lib/util/semver.js for original code
var r=range[0],n="";if(1===range.length)return"*";if(r+.5){n+=0==r?">=":-1==r?"<":1==r?"^":2==r?"~":r>0?"=":"!=";for(var e=1,a=1;a<range.length;a++){e--,n+="u"==(typeof(t=range[a]))[0]?"-":(e>0?".":"")+(e=2,t)}return n}var g=[];for(a=1;a<range.length;a++){var t=range[a];g.push(0===t?"not("+o()+")":1===t?"("+o()+" || "+o()+")":2===t?g.pop()+" "+g.pop():rangeToString(t))}return o();function o(){return g.pop().replace(/^\((.+)\)$/,"$1")}
}
var satisfy = (range, version) => {
// see webpack/lib/util/semver.js for original code
if(0 in range){version=parseVersion(version);var e=range[0],r=e<0;r&&(e=-e-1);for(var n=0,i=1,a=!0;;i++,n++){var f,s,g=i<range.length?(typeof range[i])[0]:"";if(n>=version.length||"o"==(s=(typeof(f=version[n]))[0]))return!a||("u"==g?i>e&&!r:""==g!=r);if("u"==s){if(!a||"u"!=g)return!1}else if(a)if(g==s)if(i<=e){if(f!=range[i])return!1}else{if(r?f>range[i]:f<range[i])return!1;f!=range[i]&&(a=!1)}else if("s"!=g&&"n"!=g){if(r||i<=e)return!1;a=!1,i--}else{if(i<=e||s<g!=r)return!1;a=!1}else"s"!=g&&"n"!=g&&(a=!1,i--)}}var t=[],o=t.pop.bind(t);for(n=1;n<range.length;n++){var u=range[n];t.push(1==u?o()|o():2==u?o()&o():u?satisfy(u,version):!o())}return!!o();
}
var findSingletonVersionKey = (scope, key) => {
var versions = scope[key];
return Object.keys(versions).reduce((a, b) => {
return !a || (!versions[a].loaded && versionLt(a, b)) ? b : a;
}, 0);
};
var getInvalidSingletonVersionMessage = (scope, key, version, requiredVersion) => {
return "Unsatisfied version " + version + " from " + (version && scope[key][version].from) + " of shared singleton module " + key + " (required " + rangeToString(requiredVersion) + ")"
};
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]);
};
var findValidVersion = (scope, key, requiredVersion) => {
var versions = scope[key];
var key = Object.keys(versions).reduce((a, b) => {
if (!satisfy(requiredVersion, b)) return a;
return !a || versionLt(a, b) ? b : a;
}, 0);
return key && versions[key]
};
var get = (entry) => {
entry.loaded = 1;
return entry.get()
};
var init = (fn) => (function(scopeName, a, b, c) {
var promise = __webpack_require__.I(scopeName);
if (promise && promise.then) return promise.then(fn.bind(fn, scopeName, __webpack_require__.S[scopeName], a, b, c));
return fn(scopeName, __webpack_require__.S[scopeName], a, b, c);
});
var loadSingletonVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
if(!scope || !__webpack_require__.o(scope, key)) return fallback();
return getSingletonVersion(scope, scopeName, key, version);
});
var loadStrictVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
var entry = scope && __webpack_require__.o(scope, key) && findValidVersion(scope, key, version);
return entry ? get(entry) : fallback();
});
var installedModules = {};
var moduleToHandlerMapping = {
"webpack/sharing/consume/default/react/react": () => (loadSingletonVersionCheckFallback("default", "react", [4,16,14,0], () => (__webpack_require__.e("vendors-node_modules_react_index_js").then(() => (() => (__webpack_require__(/*! react */ "./node_modules/react/index.js"))))))),
"webpack/sharing/consume/default/react-dom/react-dom": () => (loadSingletonVersionCheckFallback("default", "react-dom", [4,16,14,0], () => (__webpack_require__.e("vendors-node_modules_react-dom_index_js").then(() => (() => (__webpack_require__(/*! react-dom */ "./node_modules/react-dom/index.js"))))))),
"webpack/sharing/consume/default/lodash/lodash": () => (loadStrictVersionCheckFallback("default", "lodash", [1,4,17,0], () => (__webpack_require__.e("vendors-node_modules_lodash_lodash_js").then(() => (() => (__webpack_require__(/*! lodash */ "./node_modules/lodash/lodash.js")))))))
};
// no consumes in initial chunks
var chunkMapping = {
"webpack_sharing_consume_default_react_react": [
"webpack/sharing/consume/default/react/react"
],
"webpack_sharing_consume_default_react-dom_react-dom": [
"webpack/sharing/consume/default/react-dom/react-dom"
],
"src_utils_lodash_ts": [
"webpack/sharing/consume/default/lodash/lodash"
]
};
__webpack_require__.f.consumes = (chunkId, promises) => {
if(__webpack_require__.o(chunkMapping, chunkId)) {
chunkMapping[chunkId].forEach((id) => {
if(__webpack_require__.o(installedModules, id)) return promises.push(installedModules[id]);
var onFactory = (factory) => {
installedModules[id] = 0;
__webpack_require__.m[id] = (module) => {
delete __webpack_require__.c[id];
module.exports = factory();
}
};
var onError = (error) => {
delete installedModules[id];
__webpack_require__.m[id] = (module) => {
delete __webpack_require__.c[id];
throw error;
}
};
try {
var promise = moduleToHandlerMapping[id]();
if(promise.then) {
promises.push(installedModules[id] = promise.then(onFactory)['catch'](onError));
} else onFactory(promise);
} catch(e) { onError(e); }
});
}
}
这里删除了一些没有用到的方法,不影响对于整个加载流程的理解。
在 consumes
方法定义之前,有两个对象,moduleToHandlerMapping
映射的是 moduleId -> handler,
这里的 handler 实际上是模块加载的方法。chunkMapping
映射的是 chunkId -> moduleId,在加载 chunk 过程中都是通过 chunkId 查找,然后再通过 chunkId 找到对应的 moduleId。
然后找到对应的模块加载 handler,以 react 的为例,它对应的 handler 如下:
const handler = () => (
loadSingletonVersionCheckFallback(
"default",
"react",
[4,16,14,0],
() => (__webpack_require__.e("vendors-node_modules_react_index_js")
.then(() => (() => (
__webpack_require__(/*! react */ "./node_modules/react/index.js"))))
)
)
),
首先执行该 handler 它内部会再调用 loadSingletonVersionCheckFallback 方法,该方法在前面定义:
var loadSingletonVersionCheckFallback = init(
(scopeName, scope, key, version, fallback) => {
if(!scope || !__webpack_require__.o(scope, key)) return fallback();
return getSingletonVersion(scope, scopeName, key, version);
});
接着它调用的是 init 方法,而 init 方法实际做的事情就是注册所有应用的 share 模块,也就是前面我们解析的 ShareRuntimeModule 提供的那部分运行时代码。核心的作用就是注册各应用的 share 模块,并存在 shareScope 对象中,比较关键的就是 register 方法:
var register = (name, version, factory, eager) => {
var versions = scope[name] = scope[name] || {};
var activeVersion = versions[version];
if(!activeVersion ||
(!activeVersion.loaded &&
(!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) {
versions[version] = { get: factory, from: uniqueName, eager: !!eager };
}
};
首先取出 shareScope 中所有当前模块的所有版本 versions
,它是一个对象。然后看当前注册的版本是否已存在,如果此时已经已有 activeVersion
,并且如果模块还没有加载,则走到后面的 || 逻辑,通过
eager
判断或者 uniqueName
的对比来决定是否需要覆盖,这里的 uniqueName
处理是我有点摸不着头脑的地方,在我们举的例子中,这里的 uniqueName
分别是在我项目中是 @typescript/app1 和 @typescript/app2,通过对比字符串大小 @typescript/app2' > '@typescript/app1' 结果为 true
,所以这里会覆盖之前注册的版本相同的模块。因此在后面的加载过程中,加载的是 app2 构建出的 react chunk。
光看代码有点抽象,这里以 app1 和 app2 注册完所有 share 模块后的 shareScope 对象为例,因为两个应用的 react 和 react-dom 版本一致,所以此时生成的 shareScope 如下:
const shareScope = {
"lodash": {
"4.17.21": {
"from": "@typescript/app1",
"eager": false
}
},
"react-dom": {
"16.14.0": {
"from": "@typescript/app2",
"eager": false
}
},
"react": {
"16.14.0": {
"from": "@typescript/app2",
"eager": false
}
}
}
如果我们修改 app1 的版本为 v17.0.0,则生成的 shareScope 如下:
const shareScope = {
"lodash": {
"4.17.21": {
"from": "@typescript/app1",
"eager": false
}
},
"react-dom": {
"17.0.0": {
"from": "@typescript/app1",
"eager": false
},
"16.14.0": {
"from": "@typescript/app2",
"eager": false
}
},
"react": {
"17.0.0": {
"from": "@typescript/app1",
"eager": false
},
"16.14.0": {
"from": "@typescript/app2",
"eager": false
}
}
}
初始化完成之后,执行检查当前的 shareScope 是否存在,或者 shareScope 是否包含该模块。如果没有,则直接执行 fallback。如果有,继续调用 getSingletonVersion 方法:
var findSingletonVersionKey = (scope, key) => {
var versions = scope[key];
return Object.keys(versions).reduce((a, b) => {
return !a || (!versions[a].loaded && versionLt(a, b)) ? b : a;
}, 0);
};
var getInvalidSingletonVersionMessage =
(scope, key, version, requiredVersion) => {
return "Unsatisfied version " + version
+ " from " + (version && scope[key][version].from)
+ " of shared singleton module " + key + " (required "
+ rangeToString(requiredVersion) + ")"
};
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]);
};
然后去 shareScope 中找 react
版本信息是否有对应的版本,这里会通过 versionLt 方法做 semver 版本查找,如果版本正好匹配则直接返回,否则返回版本大的,最后调用 get 方法加载模块;不过就算是版本不匹配,这里只做一个 warning 的提醒,但是并不阻断加载流程。
如果我们给前面的 app1 的 Webapck 配置补充 strictVersion
选项,将值设为 true
:
module.exports = {
// 省略一些配置
shared: {
react: {
requiredVersion: packageJson['dependencies']['react'],
singleton: true,
strictVersion: true
},
'react-dom': {
requiredVersion: packageJson['dependencies']['react-dom'],
singleton: true,
strictVersion: true
},
lodash: '^4.17.0'
},
// 省略一些配置
}
通过 debug,可以发现加载过程中走的是另外一个版本校验的函数 loadStrictSingletonVersionCheckFallback
,相关的方法如下:
var loadStrictSingletonVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
if(!scope || !__webpack_require__.o(scope, key)) return fallback();
return getStrictSingletonVersion(scope, scopeName, key, version);
});
var getStrictSingletonVersion = (scope, scopeName, key, requiredVersion) => {
var version = findSingletonVersionKey(scope, key);
if (!satisfy(requiredVersion, version)) {
throw new Error(getInvalidSingletonVersionMessage(scope, key, version, requiredVersion))
}
return get(scope[key][version]);
};
此时获取版本的方法跟上面的一致,但是当版本不匹配的策略不再是简单的 warning
提示,而是通过抛错来阻断应用加载的过程。所以此时访问 app1 应用,打开 devtool,发现如下报错:
也就是说,如果想要严格保证、锁死某个共享模块的版本,除了添加 requiredVersion
配置,还可以补充
strictVersion
配置,保证因为加载 chunk 顺序导致的引入意外的 share 模块版本。
这就是 share 模块从注册到加载关键的 runtime 代码执行过程,下面通过更详细的流程图总结下这部分代码的加载过程:
总结
本文解析了 ProvideSharedPlugin 源码,并引出其相关的 ProvideSharedModule 和 ShareRuntimeModule 等模块,在看完这部分源码,结合上一篇文章 ConsumeSharedPlugin 的源码解析,通过编译后的 runtime 代码,详细解析了 share 模块加载流程。从本文,我们可以了解到:
- MF shared 模块机制是一个 Provide 和 Consume 模型,需要 ProvideSharedModule 和 ConsumeSharedPlugin 同时提供相关的功能才能保证模块加载机制;
- 在初始化应用过程中,在真正加载 share 模块之前,必须先通过 initializeSharing 的 runtime 代码保证各应用的 share 模块进行注册;
- 如果当前
shareScope
有同一个模块的多个版本,为了保证单例,在获取模块版本时,总是返回版本最大的那一个。如果有任何应用加载过程版本没匹配上,只是会做一个warning
打印提示,并不会阻断加载流程;如果配置中strictVersion
配置项为true
时,则直接抛错阻断加载过程; - 如果加载多个应用过程中,同时注册 share 模块,则如果版本一致的时候,会通过覆盖的方式,保证 shareScope 的同一模块同一版本只有一份,例如前面例子中 app2 会覆盖 app1 的 react 模块。