ModuleFederationPlugin 源码解析(三)

2,266 阅读15分钟

前言

上一篇文章中我比较深入、详细地解析了 ContainerPlugin 插件的源码,并且为了让大家更好理解该插件的源码,在文中引入了 Webpack 的构建流程和底层数据结构相关的知识。从中我们也学到了所有 exposes 的模块都是构建为 Dynamic Module,一般通过异步的方式加载,而 remote 应用也会在构建模块时注入一些特别的 Webpack runtime 代码,让 host 应用能访问和获取到远程的模块。

本文我们将会解析 ContainerReferencePlugin 插件的源码,它是 ContainerPlugin 插件的另一半,必须在配置了 remotes 选项才会初始化ContainerPlugin 像是一个 Provider 提供 remote 应用导出的 exposes 模块,而 ContainerReferencePlugin 更像是一个 Consumer 消费 remote 导出的模块。

话不多说,下面我们直接进入源码解析。

具体源码

ContainerReferencePlugin 源码

ContainerReferencePlugin 比其它插件稍微多一些,我们分块来看,首先来看初始化选项:

class ContainerReferencePlugin {
	/**
	 * @param {ContainerReferencePluginOptions} options options
	 */
	constructor(options) {
		validate(options);

		// 类型为 ExternalsType,指定 remote module 的类型,默认为 script
		this._remoteType = options.remoteType;
		// 处理 remote options 的时候,会将 remote 模块处理成 external 模块
		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"
			})
		);
	}
}  

初始化选项的代码逻辑比较简单,这里我们需要注意两个点:

  • 首先是 remoteType 这个选项,这个选项是告诉插件需要加载的远程模块类型,默认是 script,也就是通过插入 script 方式加载远程模块的 js chunk,除此之外还可以设置为varmodulecommonjs 等值;
  • 第二就是这里将传入的配置 normalize _remotes选项的时候,会转换成将远程模块配置以

externalsharedScope为 key 的对象。下面看一个具体的例子:

// 传入配置
{
  remotes: {
    app1: 'app1@http://localhost:3001/remoteEntry.js'
  }
}

// normalize 处理后变成了
{
  _remotes: [
    'app1',
    {
      external: ['app1@http://localhost:3001/remoteEntry.js'],
      shareScope: 'default'
    }
  ]
}

shareScope前面介绍过了,那么这里的 external是什么意思了?反应快的小伙伴可能想到 Webpack 本身的 externals 配置,它们是同一个概念吗?这里留个悬念,我们继续看后面的源码。

继续看插件 apply函数里面的代码,先看第一部分:

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++;
     }
  }
  
  // 注册 ExternalsPlugin
  // 在 webpack 中,external module 不会在当前的 compilation 中构建
  new ExternalsPlugin(remoteType, remoteExternals).apply(compiler);

  // 省略一些代码
}  

首先这里会根据初始化的 _remotes 选项构造出一个 remoteExternal对象,还是以上面的

remotes配置为例,这里处理后生成如下的 remoteExternal配置:

{
  "webpack/container/reference/app1": "app1@http://localhost:3001/remoteEntry.js"
}

接着并注册了 ExternalsPlugin插件,看到这里,解答了我们初始化选项的疑问,也就是说 _remotes中的 external配置就是等价 Webpack 配置中的 externals配置,它们最终都会走 ExternalsPlugin的处理逻辑。

也就是说 MF 中的远程模块,其实本质是走 Webpack 本身已经有的ExternalsPlugin逻辑,当然可能没有那么简单,毕竟它可能是比较特殊的 external模块,那ExternalPlugin会不会需要特殊处理?我们继续看完本身插件的源码,再深入到其它部分的源码。

接下来监听了 compilation hook

compiler.hooks.compilation.tap(
	"ContainerReferencePlugin",
	(compilation, { normalModuleFactory }) => {
		compilation.dependencyFactories.set(
			RemoteToExternalDependency,
			normalModuleFactory
		);

		compilation.dependencyFactories.set(
			FallbackItemDependency,
			normalModuleFactory
		);

		compilation.dependencyFactories.set(
			FallbackDependency,
			new FallbackModuleFactory()
		);

    // 省略一些代码
 )       

这里的 dependencyFactories设置,在上一篇文章中我们也介绍了一些背景知识,这里需要留意下,实际上 RemoteToExternalDependency也是被当成 NormalModule处理的,这里的内容后面部分会提到。

继续往下监听了 factorize hook

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
         );
      }
    }
  }
});

这个hookmodule request能正常resolved在 normalModuleFactory 调用 create 方法时)后触发,而且是一个 AsyncSeriesBailHook类型的钩子, 这里需要普及一点 tapable hook 的知识。首先忽略前面那些前缀:AsyncSeries,这里我们只需要知道 bail类型的钩子是可以中断的,当我们监听的 bail 类型回调返回值是非 undefined的时候,那么这个 hook 回调就不会继续执行后面的逻辑。

也就是说这里相当于介入了正常模块的创建过程,根据 remotes配置创建一个个 RemoteModule,当然这里需要根据 request与模块的 key做一个匹配。这里的request就是当我们在代码中

import xxx from 'app1/Button'后面的 app1/Button字面量 ,接着与源码前面做 remotes配置初始化后的数组第一个项做匹配,正好是前面提到的 app1,命中后走创建 RemoteModule逻辑。

继续往下也是前面我们提到过的补充一些运行时函数的逻辑:

// runtimeRequirementInTree 是一个 HookMap,提供了一种集合操作 hook 的能力,
// 降低使用 hook 的复杂度
// 类型为 HookMap<SyncBailHook<[Chunk, Set<string>, RuntimeRequirementsContext]>>
// 这里的 ensureChunkHandlers 是专门处理一个 module 
// 运行时需要依赖的 webpack runtime
compilation.hooks.runtimeRequirementInTree
	.for(RuntimeGlobals.ensureChunkHandlers)
	.tap("ContainerReferencePlugin", (chunk, set) => {
	  // 这里添加的都是 webpack runtime 需要的各个方法
         set.add(RuntimeGlobals.module); // module
          // __webpack_require__.m (add only)
         set.add(RuntimeGlobals.moduleFactoriesAddOnly);
          // __webpack_require__.o
	 set.add(RuntimeGlobals.hasOwnProperty);
          // __webpack_require__.I
	 set.add(RuntimeGlobals.initializeSharing); 
         // __webpack_require__.S
	 set.add(RuntimeGlobals.shareScopeMap); 
	// 将相关的 remote chunk 建立与 RemoteRuntimeModule 的关系
	compilation.addRuntimeModule(chunk, new RemoteRuntimeModule());
});

这部分逻辑感兴趣的读者可以根据我的注释自行消化。

整个插件的源码整体看下来没有很复杂,比较巧妙的部分就是在 factorize hook中介入模块创建的逻辑,相信看到这里,读者也就理解为什么 Webpack 需要做一套定制化的 tapable hook机制,很大的原因在于,在兼容了 Webpack 源码使用场景的基础上,从另一个角度来说也是为了提高 Webpack 的扩展性。

下面我们回到前面提到的 ExternaslPlugin源码,我们需要深入到源码内部,看其是否会为了 MF 做一些定制化的逻辑处理。

ExternalsPlugin 源码

基本介绍

开始解析 ExternalsPlugin 源码时,我们先温习下该插件的作用和使用姿势。

首先看下官网的介绍:

The externals configuration option provides a way of excluding dependencies from the output bundles. Instead, the created bundle relies on that dependency to be present in the consumer's (any end-user application) environment.

翻译过来的大概意思就是externals配置提供了一种可以将依赖模块移除构建产物的方式,但是,构建出来的产物在消费的时候(任何用户端应用)需要依赖该模块。所以,这个依赖需要通过开发者手动加载,一般是通过 script标签加载该依赖。这样有两个好处:

  • 提高构建速度,避免了非必要的一些构建产物;
  • 充分利用 HTTP 缓存,这样的依赖一般存放在 CDN 上,很少变动,所以充分利用浏览器的强缓存,减少 JS 资源的拉取。

externals配置的形式多样,可以接受对象、字符串、正则、函数或者以上各种类型组合的数组等形式。我们看几个例子:

// object
module.exports = {
  //...
  externals: {
    jquery: 'jQuery',
  },
};

// string
module.exports = {
  //...
  externals: 'jquery',
};

// string
module.exports = {
  //...
  externals: 'commonjs jquery',
};

// Regex
module.exports = {
  //...
  externals: /^(jquery|$)$/i,
};

// function
module.exports = {
  //...
  externals: [
    function ({ context, request }, callback) {
      if (/^react$/.test(request)) {
        // Externalize to a commonjs module using the request path
        return callback(null, 'commonjs ' + request);
      }

      // Continue without externalizing the import
      callback();
    },
  ],
};

// 数组
module.exports = {
  //...
  externals: [
    {
      // String
      react: 'react',
      // Object
      lodash: {
        commonjs: 'lodash',
        amd: 'lodash',
        root: '_', // indicates global variable
      },
      // [string]
      subtract: ['./math', 'subtract'],
    },
    // Function
    function ({ context, request }, callback) {
      if (/^yourregex$/.test(request)) {
        return callback(null, 'commonjs ' + request);
      }
      callback();
    },
    // Regex
    /^(jquery|$)$/i,
  ],
};

配置方式灵活多变,还支持指定模块的类型。

下面我们进入具体源码。

具体源码

它的源码放在 lib/ExternalsPlugin.js中:

/** @typedef {import("../declarations/WebpackOptions").Externals} Externals */
/** @typedef {import("./Compiler")} Compiler */

class ExternalsPlugin {
    /**
     * @param {string | undefined} type default external type
     * @param {Externals} externals externals config
     */
    constructor(type, externals) {
      this.type = type;
      this.externals = externals;
    }

    /**
     * Apply the plugin
     * @param {Compiler} compiler the compiler instance
     * @returns {void}
    */
    apply(compiler) {
	compiler.hooks.compile.tap("ExternalsPlugin", ({ normalModuleFactory }) => {
	  new ExternalModuleFactoryPlugin(this.type, this.externals).apply(
	    normalModuleFactory
	 );
      });
   }
}

我们发现插件本身并没有太多的代码,只是监听了 compile hook,然后注册了

ExternalModuleFactoryPlugin插件,透传了一些配置,所以我们继续看

ExternalModuleFactoryPlugin的源码:

class ExternalModuleFactoryPlugin {
       /*
	* @param {string | undefined} type default external type
	* @param {Externals} externals externals config
	*/
	constructor(type, externals) {
            this.type = type;
            this.externals = externals;
	}

	/**
	 * @param {NormalModuleFactory} normalModuleFactory the normal module factory
	 * @returns {void}
	 */
	apply(normalModuleFactory) {
	  const globalType = this.type;
	  normalModuleFactory.hooks.factorize.tapAsync(
            "ExternalModuleFactoryPlugin",
	    (data, callback) => {
	        const context = data.context;
		const contextInfo = data.contextInfo;
		const dependency = data.dependencies[0];
		const dependencyType = data.dependencyType;

		/**
		 * @param {string|string[]|boolean|Record<string, string|string[]>} value the external config
		* @param {string|undefined} type type of external
		* @param {function(Error=, ExternalModule=): void} callback callback
		* @returns {void}
		*/
		const handleExternal = (value, type, callback) => {
                    // 省略代码
		};

		/**
		 * @param {Externals} externals externals config
		 * @param {function((Error | null)=, ExternalModule=): void} callback callback
		 * @returns {void}
		*/
		const handleExternals = (externals, callback) => {
                    // 省略代码
		};

		handleExternals(this.externals, callback);
            }
	);
    }
}

初始化选项比较简单,只是简单的赋值,直接跳过。

我们具体看 apply方法的实现,它监听的也是 factorize hook,在前面我们看 ContainerReferencePlugin 源码时我已经介绍过这个钩子的作用,我们可以得出结论,如果我们想要干涉一个 模块的创建流程,就是监听 factorize hook ,然后进行定制的模块创建逻辑

继续看下面的源码,首先是从 callback中取出 data,这里能获取到 dependency的上下文信息,方便后续做模块匹配。接着是定义了 handleExternalhandlExternals两个函数,最后调用的时候传入了

externals配置信息。那我们先看下 handleExternals的实现:

/**
 * @param {Externals} externals externals config
 * @param {function((Error | null)=, ExternalModule=): void} callback callback
 * @returns {void}
 */
const handleExternals = (externals, callback) => {
  if (typeof externals === 'string') {
    if (externals === dependency.request) {
      return handleExternal(dependency.request, undefined, callback);
    }
  } else if (Array.isArray(externals)) {
    // 省略一些代码
  } else if (externals instanceof RegExp) {
    if (externals.test(dependency.request)) {
      return handleExternal(dependency.request, undefined, callback);
    }
  } else if (typeof externals === 'function') {
    // 省略一些代码
  } else if (typeof externals === 'object') {
    const resolvedExternals = resolveLayer(externals, contextInfo.issuerLayer);
    if (Object.prototype.hasOwnProperty.call(resolvedExternals, dependency.request)) {
      return handleExternal(resolvedExternals[dependency.request], undefined, callback);
    }
  }
  callback();
};

我们省略了一些代码去看整体的 if-elseif-esle逻辑还是非常好理解的,在前面我们先介绍了

externals可以配置的方式,实际上这里就是根据不同的参数类型做 module request的匹配,然后命中后进入对应的逻辑处理,大同小异,关键还是要看 handleExternal的实现。

因为 ExternalsPlugin不是我们本文的主角,所以我们解析这部分源码不会太详细,我们只看大致的流程,寻找重点的一些实现。因为在前面解析 ContainerReferencePlugin 源码的时候,注册 ExternalsPlugin插件传入的 externals配置是对象,所以我们这里聚焦 externalsobject类型的逻辑处理。

首先调用 resolveLayer方法,传入externals配置和 issuerLayer上下文信息,返回一个

resolvedExternal信息。我们看下 resolveLayer的实现:

const cache = new WeakMap();

const resolveLayer = (obj, layer) => {
	let map = cache.get(obj);
	if (map === undefined) {
		map = new Map();
		cache.set(obj, map);
	} else {
		const cacheEntry = map.get(layer);
		if (cacheEntry !== undefined) return cacheEntry;
	}
	const result = resolveByProperty(obj, "byLayer", layer);
	map.set(layer, result);
	return result;
};

非核心的逻辑,这里我们一句话总结,实际上就是做了一个 externalsissuerLayer的缓存,为了减少构建过程中的计算逻辑。那么这里的 issuerLayer是什么东东了?实际上它来自 module的一个属性,类型是一个字符串,在 moduleFactory.create 调用的时候传入,作用是指定模块放置的层(layer),我个人理解是生成产物代码的时候源码拼接的时候模块放的位置。

继续下面的源码,然后去对比 requestexternals中的配置是否能匹配上,如果有,再调用

handleExternal方法,我们需要详细看 handlerExternal的实现,这是理解 ExternalsPlugin作用的核心逻辑:

/**
 * @param {string|string[]|boolean|Record<string, string|string[]>} value the external config
 * @param {string|undefined} type type of external
 * @param {function(Error=, ExternalModule=): void} callback callback
 * @returns {void}
 */
const handleExternal = (value, type, callback) => {
  if (value === false) {
    // Not externals, fallback to original factory
    return callback();
  }
  /** @type {string | string[] | Record<string, string|string[]>} */
  let externalConfig;
  if (value === true) {
    externalConfig = dependency.request;
  } else {
    externalConfig = value;
  }
  // When no explicit type is specified, extract it from the externalConfig
  if (type === undefined) {
    if (
      typeof externalConfig === 'string' &&
      UNSPECIFIED_EXTERNAL_TYPE_REGEXP.test(externalConfig)
    ) {
      const idx = externalConfig.indexOf(' ');
      type = externalConfig.slice(0, idx);
      externalConfig = externalConfig.slice(idx + 1);
    } else if (
      Array.isArray(externalConfig) &&
      externalConfig.length > 0 &&
      UNSPECIFIED_EXTERNAL_TYPE_REGEXP.test(externalConfig[0])
    ) {
      const firstItem = externalConfig[0];
      const idx = firstItem.indexOf(' ');
      type = firstItem.slice(0, idx);
      externalConfig = [firstItem.slice(idx + 1), ...externalConfig.slice(1)];
    }
  }
  callback(null, new ExternalModule(externalConfig, type || globalType, dependency.request));
};

这部分的源码还是写了非常易懂的注释,所以理解这部分代码还是非常容易的。

当配置 valuefalse则直接跳过,走正常的模块创建逻辑。接着就是赋值 externalConfig,在我们前面解析插件源码的中举的例子,这里的 externalConfig最终的值就是:app1@http://localhost:3001/remoteEntry.js

继续往下则是根据需要找出 type的类型,实际上在前面我们举例 externals的配置方式的时候,我们可以通过很多形式传入 type,所以才有了这一块的逻辑处理。个人认为这个配置项在设计的时候有点太灵活了,导致这里需要做很多额外的逻辑处理,其实大多数开发者是不需要用到的。而对于 ContainerReferencePlugin 场景这里 type的值是script

最后执行了传入的回调,并在第二个参数新建了一个 ExternalModule,传入了 externalConfig

typerequest等选项。也就是说,对于配置在 external里面的模块,它也不会走正常的模块创建逻辑,而是有自己的一个 ExternalModule 类型。所以,我们还需要看 ExternalModule的实现。

看到这里,可能有些小伙伴已经有点累了,毁灭吧的想法。看 Webpack 源码就是这样,在这样一个大的工程项目里,其模块与模块之间的关系是特别复杂的,我们很多时候不需要太多关注细节,我们需要真正理解其内部的运作流程。从 factorize到模块的创建,模块 build 再到 codeGeneration,它们执行的时机都是一样的。

话不多说,我们继续。在上一篇文章中,我们提到过看一个 Webpack 模块的实现,主要是看 build

codeGeneration的实现,这里也不例外。先看 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 = {
    async: false,
    exportsType: undefined
  };
  this.buildInfo = {
    strict: true,
    topLevelDeclarations: new Set(),
    module: compilation.outputOptions.module
  };
  const { request, externalType } = this._getRequestAndExternalType();
  this.buildMeta.exportsType = "dynamic";
  let canMangle = false;
  this.clearDependenciesAndBlocks();
  switch (externalType) {
    case "this":
      this.buildInfo.strict = false;
      break;
    case "system":
      if (!Array.isArray(request) || request.length === 1) {
        this.buildMeta.exportsType = "namespace";
        canMangle = true;
      }
      break;
    case "module":
      if (this.buildInfo.module) {
        if (!Array.isArray(request) || request.length === 1) {
          this.buildMeta.exportsType = "namespace";
          canMangle = true;
        }
      } else {
        this.buildMeta.async = true;
        if (!Array.isArray(request) || request.length === 1) {
          this.buildMeta.exportsType = "namespace";
          canMangle = false;
        }
      }
      break;
    case "script":
    case "promise":
      this.buildMeta.async = true;
      break;
    case "import":
      this.buildMeta.async = true;
      if (!Array.isArray(request) || request.length === 1) {
        this.buildMeta.exportsType = "namespace";
        canMangle = false;
      }
      break;
  }
  this.addDependency(new StaticExportsDependency(true, canMangle));
  callback();
}

我们发现,它的build方法基本上所有的逻辑都是围绕着 externalType去构造不同的 buildMeta信息,也就是说在 ExternalModule``build阶段,实际上并没有其它模块要做的创建依赖,构造依赖关系的过程。最后的代码只是添加了一个 StaticExportsDependency作为 dependency,而这个

dependency并没有对应的 moduleFactory,也就是说构建过程中不会有任何的模块创建,所以它更多的作用是为了保存一些上下文信息。

下面我们继续看 codeGeneration的实现:

/**
 * @param {CodeGenerationContext} context context for code generation
 * @returns {CodeGenerationResult} result
 */
 codeGeneration({
  runtimeTemplate,
  moduleGraph,
  chunkGraph,
  runtime,
  concatenationScope
}) {
  const { request, externalType } = this._getRequestAndExternalType();
  switch (externalType) {
    case "asset": {
      const sources = new Map();
      sources.set(
        "javascript",
        new RawSource(`module.exports = ${JSON.stringify(request)};`)
      );
      const data = new Map();
      data.set("url", request);
      return { sources, runtimeRequirements: RUNTIME_REQUIREMENTS, data };
    }
    case "css-import": {
      const sources = new Map();
      sources.set(
        "css-import",
        new RawSource(`@import url(${JSON.stringify(request)});`)
      );
      return {
        sources,
        runtimeRequirements: EMPTY_RUNTIME_REQUIREMENTS
      };
    }
    default: {
      const sourceData = this._getSourceData(
        request,
        externalType,
        runtimeTemplate,
        moduleGraph,
        chunkGraph,
        runtime
      );

      let sourceString = sourceData.expression;
      if (sourceData.iife)
        sourceString = `(function() { return ${sourceString}; }())`;
      if (concatenationScope) {
        sourceString = `${
          runtimeTemplate.supportsConst() ? "const" : "var"
        } ${ConcatenationScope.NAMESPACE_OBJECT_EXPORT} = ${sourceString};`;
        concatenationScope.registerNamespaceExport(
          ConcatenationScope.NAMESPACE_OBJECT_EXPORT
        );
      } else {
        sourceString = `module.exports = ${sourceString};`;
      }
      if (sourceData.init)
        sourceString = `${sourceData.init}\n${sourceString}`;

      let data = undefined;
      if (sourceData.chunkInitFragments) {
        data = new Map();
        data.set("chunkInitFragments", sourceData.chunkInitFragments);
      }

      const sources = new Map();
      if (this.useSourceMap || this.useSimpleSourceMap) {
        sources.set(
          "javascript",
          new OriginalSource(sourceString, this.identifier())
        );
      } else {
        sources.set("javascript", new RawSource(sourceString));
      }

      let runtimeRequirements = sourceData.runtimeRequirements;
      if (!concatenationScope) {
        if (!runtimeRequirements) {
          runtimeRequirements = RUNTIME_REQUIREMENTS;
        } else {
          const set = new Set(runtimeRequirements);
          set.add(RuntimeGlobals.module);
          runtimeRequirements = set;
        }
      }

      return {
        sources,
        runtimeRequirements:
          runtimeRequirements || EMPTY_RUNTIME_REQUIREMENTS,
        data
      };
    }
  }
}

首先是对于特殊模块的处理,也就是当我们传入的 externalTypeasset静态资源或者

css-import则直接构建对应的字符串源码,并返回。之前没有留意过,原来 externals配置其实也可以传静态资源类型的,之前配置的时候从来没有用到过。

继续看 default逻辑,首先调用了 _getSourceData方法拿到 sourceData,这里的

_getSourceData到底做了什么?考虑篇幅问题,我直接公布答案,就不再去看更多的细节,感兴趣的读者可以自行下载源码详读。实际上这个方法的作用就是根据 externalType和其它的一些上下文信息,构造出

ExternalModule的核心的 runtime 代码。以我们例子中传入的 externalType为例,它实际上获取的

sourceData如下:

const sourceData = {
  init: "var __webpack_error__ = new Error();",
  expression: `new Promise(${runtimeTemplate.basicFunction(
    "resolve, reject",
    [
      `if(typeof ${globalName} !== "undefined") return resolve();`,
      `${RuntimeGlobals.loadScript}(${JSON.stringify(
        url
      )}, ${runtimeTemplate.basicFunction("event", [
        `if(typeof ${globalName} !== "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__);"
      ])}, ${JSON.stringify(globalName)});`
    ]
  )}).then(${runtimeTemplate.returningFunction(
    `${globalName}${propertyAccess(urlAndGlobal, 2)}`
  )})`,
  runtimeRequirements: RUNTIME_REQUIREMENTS_FOR_SCRIPT
};

这是一段非常经典的 dynamic加载模块的 runtime 模板代码,原理就是通过插入script标签的方式去加载一个远程模块。

接下来会根据一些配置,例如是否为 iife 模块,是否配置了concatenationScope等在 sourceDataruntime 代码基础上构造出加强的 sourceString。而这里的 concatenationScope选项来源于 Webpack 配置中的 optimization.concatenateModules配置项,这个配置项可以在模块打包的时候用来告诉 Webpack 将一些模块代码拼接在一起组件一个 chunk,但是它需要依赖另外两个配置项

optimization.providedExportsoptimization.usedExports,在生产构建模式下默认开启。

再往下,这里会拼一些 initchunkInitFragments (用于初始化 commonjs 等模块需要的特殊的运行时代码)的初始化逻辑。然后是处理是否有 sourceMap的情况,处理不同情况的 runtimeRequirements赋值逻辑,在这个例子里面的值是:__webpack_require__.l也就是 loadScript的方法。

这部分的源码到这里就结束了,我们做个小结。

小结

基本理清了整个 ExternalsPlugin 的运作原理,就是在 factorize hook阶段干预模块创建的逻辑,然后通过创建新的ExternalModule模块去加入核心的动态加载模块的runtime代码,而 ExternalModule 本身并不会与其它模块一样,会有一个构建模块依赖的过程。虽然 ContainerReferencePlugin 依赖了

ExternalsPlugin但是我们发现它并没有为 MF 的功能做任何特殊的逻辑处理,它还是基于 Webpack 现有的功能进行扩展,又一次体现了 Webpack 的扩展能力

除了走 ExternalsPlugin逻辑,其本身也是干预了模块创建的流程,然后根据配置返回RemoteModule,下面我们看下 RemoteModule的源码。

RemoteModule 源码

先看下 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();
  if (this.externalRequests.length === 1) {
    this.addDependency(
      new RemoteToExternalDependency(this.externalRequests[0])
    );
  } else {
    this.addDependency(new FallbackDependency(this.externalRequests));
  }

  callback();
}

代码比较简单,我们重点关注,根据不同的 this.externalRequests长度来添加不同类型的

dependency的逻辑,那这个 externalRequests 是什么?它是在实例化 RemoteModule时传入的配置项的 external属性,让我们回顾下这部分:

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,
);

这里的 config就是前面我们例子中的 _remotes数组中每一项的第二个值,在前面的例子则即为:

{
  external: ['app1@http://localhost:3001/remoteEntry.js'],
  shareScope: 'default'
}

在这这个例子中它的长度为 1,走添加 RemoteToExternalDependency的逻辑,而这个 Dependency对应的 ModuleFactorynormalModuleFactory,也就是说这时候在构建过程中,它走的是正常模块创建流程。

如果长度不等于 1,也就是大于 1 或者等于 0 的情况,会走 FallbackDependency的逻辑,Fallback命名听起来像是一个兜底的处理,我们先继续往下看,看完再揭晓它的作用。FallbackDependency本身的代码比较简单,感兴趣的读者可以自行去看。我们主要关注其对应的 FallbackModule,它有对应的

FallbackModuleFactory

我们看下 FallbackModule的实现,首先看 build方法:

build(options, compilation, resolver, fs, callback) {
  this.buildMeta = {};
  this.buildInfo = {
    strict: true
  };

  this.clearDependenciesAndBlocks();
  for (const request of this.requests)
    this.addDependency(new FallbackItemDependency(request));

  callback();
}

这里会遍历传入的 requests,然后依次添加 FallbackItemDependency,又冒出了一个新的

dependency。不着急,我们等会再回头看这个 FallbackItemDependency,我们继续看完

FallbackModulecodeGeneration方法:

codeGeneration({ runtimeTemplate, moduleGraph, chunkGraph }) {
  const ids = this.dependencies.map(dep =>
    chunkGraph.getModuleId(moduleGraph.getModule(dep))
  );
  const code = Template.asString([
    `var ids = ${JSON.stringify(ids)};`,
    "var error, result, i = 0;",
    `var loop = ${runtimeTemplate.basicFunction("next", [
      "while(i < ids.length) {",
      Template.indent([
      	`try { next = __webpack_require__(ids[i++]); } 
         catch(e) { return handleError(e); }`,
        `if(next) return next.then ? 
        	next.then(handleResult, handleError) 
         : handleResult(next);`
      ]),
      "}",
      "if(error) throw error;"
    ])}`,
    `var handleResult = ${runtimeTemplate.basicFunction("result", [
      "if(result) return result;",
      "return loop();"
    ])};`,
    `var handleError = ${runtimeTemplate.basicFunction("e", [
      "error = e;",
      "return loop();"
    ])};`,
    "module.exports = loop();"
  ]);
  const sources = new Map();
  sources.set("javascript", new RawSource(code));
  return { sources, runtimeRequirements: RUNTIME_REQUIREMENTS };
}

这段模板代码看着有点抽象,实际上这里做的事情就是,找出所有模块的 dependencies的模块id,然后通过 __webpack_require__加载模块,并判断 module是否能被 resolve,也就是是否能被加载到,然后如果能,并且有返回结果,则直接 return 最终的结果,此时中断 while 循环。

这段 runtime 代码的作用是什么?还是不太好理解,我们需要模拟下这个场景。首先我们改下 remotes配置:

remotes: {
   app1: [
    'app1@http://localhost:3001/remoteEntry.js', 
    'app3@http://localhost:3003/remoteEntry.js'
  ],
},

然后,通过 debug,我们发现前面的 remotes变成了如下的数组:

const remotes = [
  'app1',
  {
    external: [
      'app1@http://localhost:3001/remoteEntry.js', 
      'app3@http://localhost:3003/remoteEntry.js'
    ],
    shareScope: 'default'
  }
]

通过 debug 继续往下,走到 RemoteModule的逻辑,接着进到了 FallbackModule的逻辑:

继续往下,我们看下 codeGeneration最后生成的 runtime代码:

var ids = [179, 708];
var error,
  result,
  i = 0;
var loop = (next) => {
  while (i < ids.length) {
    try {
      next = __webpack_require__(ids[i++]);
    } catch (e) {
      return handleError(e);
    }
    if (next) return next.then 
      	? next.then(handleResult, handleError) 
      	: handleResult(next);
  }
  if (error) throw error;
};
var handleResult = (result) => {
  if (result) return result;
  return loop();
};
var handleError = (e) => {
  error = e;
  return loop();
};

module.exports = loop();

其实从 FallbackModule的命名加上,加上上面模拟的这个 case ,再来看这段代码的用意就非常清楚了。

其实就是当我们配置 remotesvalue为数组,就是告诉 Webpack,除了第一项,后面的都是该远程模块的 fallback,可以配置多个。所以上面那段运行时代码,就会在加载的时候,依次遍历所有的模块 id,通过 webpack_require 去依次加载所有模块,包括 fallback ,如果能加载到任意一个模块,则直接返回结果,否则抛出错误。

为什么需要 FallbackModule?因为 MF 的这种基于运行时的共享远程模块的机制在构建的时候就算远程模块不存在或者报错对于 host 应用来说是没法在构建阶段识别到,我们在设计的时候应该总是假设第二方或者第三方服务不可靠,所以 MF 提供了这样一个兜底的机制,不至于在 runtime 时因为一个模块加载失败,导致整个页面都发生崩溃的情况。

前面还提到了 FallbackItemDependency,这个dependency是否有 ModuleFactory了?答案是有的,而且就是 normalModuleFactory,在我们前面解析 ContainerReferencePlugin 的时候有看到,只是没有单独拿出来说:

// lib/container/ContainerReferencePlugin.js
compiler.hooks.compilation.tap(
    "ContainerReferencePlugin",
    (compilation, { normalModuleFactory }) => {
	compilation.dependencyFactories.set(
            RemoteToExternalDependency,
            normalModuleFactory
	);

	compilation.dependencyFactories.set(
            FallbackItemDependency,
            normalModuleFactory
	);

	compilation.dependencyFactories.set(
            FallbackDependency,
            new FallbackModuleFactory()
	);

       // 省略一些代码
 ) 

看完了这块代码,说实话非常容易懵,各种 dependency绕来绕去。我们来画一个关系调用图:

这就是 ContainerReferencePlugin 相关的所有源码,它的设计还是非常巧妙的,无论是基于

ExternalsPlugin还是 FallbackModule 的设计,都让我感叹 Webpack 设计的巧妙和周全。

总结

在本文中,我从 ContainerReferencePlugin 源码解析出发,接着引入了其依赖的插件 ExternalsPlugin 和模块 RemoteModule 等源码的介绍,最后通过一个场景模拟我们理解了 FallbackModule 的作用。通过本文,我们知道了:

  • ContainerReferencePlugin 依赖了 ExternalsPlugin, 或者说 remotes 配置的本质就是被当做 externals 配置处理,所以在构建的时候不需要经历正常的模块创建流程;
  • Webpack externals 配置形式花样繁多,甚至我们可以在配置的字符串中指定模块的类型,而且也支持配置静态资源模块;
  • 因为第一点提到的 remotes 配置的模块在 host 应用构建过程中不会走正常的模块创建流程,所以如果 remotes 应用没有提供该模块或者提供了一个有问题的模块,或者说 remote 应用因为单点故障挂掉了,都会影响到 host 应用正常的功能,所以有了 FallbackModule 提供的兜底功能,使得我们可以在配置的时候提供兜底的模块;在设计一个应用架构的时候,保证软件的健壮性是非常重要的;
  • 如果想要干涉 Webpack 构建流程中的模块创建过程,可以监听 factorize hook,然后在回调里面定制自己想要的模块创建逻辑。

后续文章

下一篇文章,我将介绍 SharePlugin的源码,这部分的源码是我觉得整个 MF 架构中最复杂的部分,而且从官网的文档来看,其介绍的大部分配置也是关于 SharePlugin的。为了让读者更好的理解这部分源码,我会结合更多的实际例子进行讲解。