ModuleFederationPlugin 源码解析(五)

2,273 阅读8分钟

前言

上一篇文章中,我从 SharePlugin 源码出发,详细解析了 ConsumeSharedPlugin 的源码,从而引出了 ConsumeSharedModuleConsumeSharedRuntimeModule 等对于 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进行一个排序,这个算法也比较好理解,按数组每一项的第一项也就是 reactreact-domlodash排序,排完后,数组顺序变成了 lodashreactreact-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,根据配置构造 resolvedProvideMapmatchProvidesprefixMatchProvides变量,这三个变量有点眼熟,都是 Map数据结构。在上一篇文章中,解析 ConsumeSharedPlugin 源码中的resolveMatchedConfigs 方法的时候,也是有构造这三个变量的过程,所以这里我们简单回顾下。

实际上前面的 ifif else匹配到的 request一般指的是当前项目内部模块,也就项目源码本身创建的 JS 模块,这类模块会存在 resolvedProvideMap变量中;接着是以 /结尾的匹配一类模块的配置,其实就是类似一个通配,这种配置方式很少见,例如配置 prefixModule/,下面的导入方式模块将会命中:

import a from 'prefixModule/a'
import b from 'prefixModule/b'

这类模块会存在 prefixMatchProvides中;最后一类,如果以上的方式都没匹配上,则存在 matchProvides中,这类模块一般是来自 node_modules 目录,比如我们上面例子中的 reactreact-domlodash等都属于这类模块。

继续往下会将 resolvedProvideMap使用前面定义的 compilationData 存下来,这里的 keycompilation对象。普及一点小知识,**在 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 来的模块,一般 resourcerequest相同。

接着解析的是 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
  });
};

实际上这里的过程就是会把 matchProvidesprefixMatchProvides转换成 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 模块,则对应的 shareKeyreact。 然后根据不同的 eager配置来生成不同的加载模块的 factory 方法。

实际上对于所有的 share 模块,如果应用任意其它 chunk 依赖了 share 模块,则同样需要在构建时加入这段运行时代码。也就是说,对于 share 模块,无论是上一篇文章提到的 ConsumeSharedModule,还是这里的 ProvideSharedModule,它们的本质是为了提供 share 模块加载机制的运行时代码,而这个模型无论是对于提供方: Provide ,还是消费方:Consume,它们需要互相依赖,必须同时存在,才能保证 share 模块加载机制。

启动上一篇文章中提到的 app1 服务,访问页面,然后打开 devtool,我们通过 register 关键字搜索构建产物,发现如下代码: image.png

实际上这里的几个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 加载完成的流程图: share module loading (2).png

注意,这里只是展示 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:

image.png

为什么不加载 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 构建后的源码中随处可以找到此方法的调用: image.png

那么这里的 __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,发现如下报错: image.png

也就是说,如果想要严格保证、锁死某个共享模块的版本,除了添加 requiredVersion 配置,还可以补充 strictVersion 配置,保证因为加载 chunk 顺序导致的引入意外的 share 模块版本。

这就是 share 模块从注册到加载关键的 runtime 代码执行过程,下面通过更详细的流程图总结下这部分代码的加载过程: load sharing module (4).png

总结

本文解析了 ProvideSharedPlugin 源码,并引出其相关的 ProvideSharedModuleShareRuntimeModule 等模块,在看完这部分源码,结合上一篇文章 ConsumeSharedPlugin 的源码解析,通过编译后的 runtime 代码,详细解析了 share 模块加载流程。从本文,我们可以了解到:

  • MF shared 模块机制是一个 Provide 和 Consume 模型,需要 ProvideSharedModule 和 ConsumeSharedPlugin 同时提供相关的功能才能保证模块加载机制;
  • 在初始化应用过程中,在真正加载 share 模块之前,必须先通过 initializeSharing 的 runtime 代码保证各应用的 share 模块进行注册;
  • 如果当前 shareScope 有同一个模块的多个版本,为了保证单例,在获取模块版本时,总是返回版本最大的那一个。如果有任何应用加载过程版本没匹配上,只是会做一个 warning 打印提示,并不会阻断加载流程;如果配置中 strictVersion 配置项为 true 时,则直接抛错阻断加载过程;
  • 如果加载多个应用过程中,同时注册 share 模块,则如果版本一致的时候,会通过覆盖的方式,保证 shareScope 的同一模块同一版本只有一份,例如前面例子中 app2 会覆盖 app1 的 react 模块。