ModuleFederationPlugin 源码解析(四)

前言

上一篇文章中我解析了 ContainerReferencePlugin 插件的源码,从文章中你可以了解到原来 host 应用消费remote 应用的组件,构建的时候本质会走 ExternalsPlugin 插件,也就是 Webpack externals 配置的功能。除此之外,插件还提供了 fallback 的功能,这样不至于加载远程模块失败的时候影响整个页面的内容展示,避免因为单点故障带来的应用不稳定性问题。

本篇文章我将解析 SharePlugin 的源码,个人认为 share 模块机制是 MF 整个设计最复杂也是最重要的部分,它需要解决例如运行时不同的远程应用之间共享同一份 chunk 的问题、单例问题、同一个 library 版本问题等。

话不多说,我们直接进入正题。

源码解析

SharePlugin 源码

第一篇文章我在解析 ModuleFederationPlugin 源码的时候,可以了解到只有当配置了shared选项才会初始化 SharedPlugin,它的源码放在 lib/sharing/SharePlugin.js 中,也就说它是一个更加独立的功能模块。现在主要是 MF 的场景需要用到,后续说不定会扩展到其它的功能。

让我们先看看 SharePlugin.js的源码:

class SharePlugin {
    /**
     * @param {SharePluginOptions} options options
     */
    constructor(options) {
/** @type {[string, SharedConfig][]} */
const sharedOptions = parseOptions(
            options.shared,
            (item, key) => {
if (typeof item !== "string")
                    throw new Error("Unexpected array in shared");
                    /** @type {SharedConfig} */
                    const config =
                    // isRequiredVersion 判断 item 是不是一个合法的 version
                        item === key || !isRequiredVersion(item)
                            ? {
                                import: item
                              }
                            : {
                                import: key,
                                requiredVersion: item
                              };
                        return config;
                    },
                    item => item
);
/** @type {Record<string, ConsumesConfig>[]} */
const consumes = sharedOptions.map(([key, options]) => ({
                    [key]: {
                        import: options.import,
shareKey: options.shareKey || key,
shareScope: options.shareScope,
requiredVersion: options.requiredVersion,
strictVersion: options.strictVersion,
singleton: options.singleton,
packageName: options.packageName,
eager: options.eager
                    }
}));
/** @type {Record<string, ProvidesConfig>[]} */
const provides = sharedOptions
                    .filter(([, options]) => options.import !== false)
                    .map(([key, options]) => ({
[options.import || key]: {
                            shareKey: options.shareKey || key,
                            shareScope: options.shareScope,
                            version: options.version,
                            eager: options.eager
}
}));
            this._shareScope = options.shareScope;
            this._consumes = consumes;
            this._provides = provides;
}
  
  /**
   * Apply the plugin
   * @param {Compiler} compiler the compiler instance
   * @returns {void}
   */
    apply(compiler) {
new ConsumeSharedPlugin({
            shareScope: this._shareScope,
            consumes: this._consumes
        }).apply(compiler);
        new ProvideSharedPlugin({
            shareScope: this._shareScope,
            provides: this._provides
        }).apply(compiler);
    }
}  

首先是转换用户传入的选项,将其转换成一个 tuple 类型的数组,因为 shared配置也有非常多的形式,一般 纯对象的方式比较多,例如以下配置:

const shared = {
  react: {
    requiredVersion: '^16.13.0',
    singleton: true,
  },
  lodash: '^4.17.0'
}

// 经过上面的 parseOptions 后
const sharedOptions = [
  ['react', { requiredVersion: '^16.13.0', singleton: true }],
  ['lodash', { import: 'lodash', requiredVersion: '^4.17.0' }]
]

接着并根据 sharedOptions 分别构造出 consumesprovides,然后赋值给插件类本身的

_consumes_provides实例属性。还是以上面例子的配置举例,这时候转换后的 consumes

provides分别为:

const consumes = [
  {
    react: {
  import: undefined,
  shareKey: "react",
  shareScope: undefined,
  requiredVersion: "^16.13.0",
  strictVersion: undefined,
  singleton: true,
  packageName: undefined,
  eager: undefined,
    }
  },
  {
    lodash: {
  import: "lodash",
  shareKey: "lodash",
  shareScope: undefined,
  requiredVersion: "^4.17.0",
  strictVersion: undefined,
  singleton: undefined,
  packageName: undefined,
  eager: undefined,
    }
  }
]

const provides = [
  {
    react: {
  shareKey: "react",
  shareScope: undefined,
  version: undefined,
  eager: undefined,
    }
  },
  {
    lodash: {
  shareKey: "lodash",
  shareScope: undefined,
  version: undefined,
  eager: undefined,
    }
  }
]

接下来就是 apply方法,代码很简单,分别注册了 ConsumeSharedPlugin 插件,传入

_consumesshareScope;注册 ProvideSharedPlugin,传入 providesshareScope

shareScope也是外部传入,在上面的例子中,没有传入,所以值为 undefined

代码逻辑非常简单,但是 ConsumeProvide 两个名词相信除了勾起我的好奇心,也包括读者的好奇心,这到底是什么样的概念?我的第一反应是这有点像 React 中的 Context,当我们创建一个 Context 的时候,需要在组件树顶层通过 Context.Provider注册并传入value,然后它的所有子组件就可以通过 useContext指定Context获取到对应的 value,它像是一个提供者和消费者的模型,只不过在 React 中通过组件机制使用。

除此之外,我回顾了 Webpack 官网对 MF 的介绍:

The ModuleFederationPlugin allows a build to provide or consume modules with other independent builds at runtime.

翻译过来大概的意思:MF 插件可以构建提供或者消费其它独立构建产物的产物,而无论是作为提供方还是消费方,这个时机发生在运行时。这也说明了,shared的功能对于 MF 插件来说也是非常核心的功能。

下面我们开始慢慢揭开它们的面纱。

ConsumeSharedPlugin 源码

parseOptions

ConsumeSharedPlugin 的源码在 lib/sharing/ConsumeSharedPlugin.js,我们首先看初始化 parseOptions 部分:

class ConsumeSharedPlugin {
    /**
     * @param {ConsumeSharedPluginOptions} options options
     */
    constructor(options) {
if (typeof options !== "string") {
            validate(options);
}

/** @type {[string, ConsumeOptions][]} */
this._consumes = parseOptions(
            options.consumes,
            (item, key) => {
                if (Array.isArray(item)) {
                  throw new Error("Unexpected array in options");
            } 
/** @type {ConsumeOptions} */
        let result =
            item === key || !isRequiredVersion(item)
                ? // item is a request/key
            {
import: key,
shareScope: options.shareScope || "default",
shareKey: key,
requiredVersion: undefined,
packageName: undefined,
strictVersion: false,
singleton: false,
eager: false
            }
: // key is a request/key
            // item is a version
            {
                import: key,
shareScope: options.shareScope || "default",
shareKey: key,
requiredVersion: parseRange(item),
strictVersion: true,
packageName: undefined,
singleton: false,
eager: false
            };
            return result;
        },
        (item, key) => ({
            import: item.import === false ? undefined : item.import || key,
            shareScope: item.shareScope || options.shareScope || "default",
            shareKey: item.shareKey || key,
            requiredVersion:
                typeof item.requiredVersion === "string"
                    ? parseRange(item.requiredVersion)
                    : item.requiredVersion,
            strictVersion: typeof item.strictVersion === "boolean"
                            ? item.strictVersion
                            : item.import !== false && !item.singleton,
            packageName: item.packageName,
            singleton: !!item.singleton,
            eager: !!item.eager
})
        );
    }
}  

没有非常复杂的逻辑,主要是处理传入的 options,并对一些必要的字段进行默认赋值处理。前面我们例子中的 consumes传入经过处理变成了:

this._consumes = [
  [
    'react',
    {
      import: "react",
      shareScope: "default",
      shareKey: "react",
      requiredVersion: [
    1,
    16,
    13,
    0,
      ],
      strictVersion: false,
      packageName: undefined,
      singleton: true,
      eager: false,
    }
  ],
  [
    'lodash',
    {
      import: "lodash",
      shareScope: "default",
      shareKey: "lodash",
      requiredVersion: [
    1,
    4,
    17,
    0,
      ],
      strictVersion: true,
      packageName: undefined,
      singleton: false,
      eager: false,
    }
  ]
]  

这里值得留意的一点就是我们传入的每一项配置的版本信息会被处理成一个数组,这个转换逻辑是通过

parseRange方法完成的,而且 ^转换成了数组 1, 感兴趣的可以自行去看详细实现,我们主要关注后面的代码怎么消费这个requiredVersion

resolveMatchedConfigs

接下来我们进入重点的源码部分,也就是 apply方法的实现。前方高能,请系好安全带⚠️⚠️⚠️。

为了让读者更容易消化和理解,我们分块解析,首先看第一部分:

apply(compiler) {
  compiler.hooks.thisCompilation.tap(
    PLUGIN_NAME,
    (compilation, { normalModuleFactory }) => {
      compilation.dependencyFactories.set(
        ConsumeSharedFallbackDependency,
        normalModuleFactory
      );

      let unresolvedConsumes, resolvedConsumes, prefixedConsumes;
      // resolveMatchedConfigs 的作用是通过 resolver 按传入的配置去尝试加载模块
      // 每个 compiler 有一个 resolverFactory ,而 ResolverFactory 
      // 是基于 enhanced-resolve 封装专门加载模块路径的工厂方法
      // enhanced-resolve 封装了支持异步高可配置的 require.resolve
      // 根据不同的 module request 路径,获取异步收集到的 
      // resolvedConsumes、unresolvedConsumes、prefixedConsumes 配置
      const promise = resolveMatchedConfigs(compilation, this._consumes)
        .then(({ resolved, unresolved, prefixed }) => {
          resolvedConsumes = resolved;
          unresolvedConsumes = unresolved;
          prefixedConsumes = prefixed;
        }
      );
    }
  );
}

首先监听了 thisCompilation hook,然后设置了 ConsumeSharedFallbackDependency

ModuleFactory,非核心逻辑,读者留个印象即可。

继续往下定义了三个变量:unresolvedConsumes, resolvedConsumes, prefixedConsumes,接着调用了 resolveMatchedConfigs方法,它返回的是一个Promise,然后将 resolve的结果,解构出正好跟前面定义的三个变量一一对应的变量,然后并赋值。

那么这个 resolveMatchedConfigs内部做了什么,我们需要看下它的实现:

// lib/sharing/resolveMatchedConfigs.js
exports.resolveMatchedConfigs = (compilation, configs) => {
    /** @type {Map<string, T>} */
    const resolved = new Map();
    /** @type {Map<string, T>} */
    const unresolved = new Map();
    /** @type {Map<string, T>} */
    const prefixed = new Map();
    const resolveContext = {
/** @type {LazySet<string>} */
fileDependencies: new LazySet(),
/** @type {LazySet<string>} */
contextDependencies: new LazySet(),
/** @type {LazySet<string>} */
missingDependencies: new LazySet()
    };
    const resolver = compilation.resolverFactory.get("normal", RESOLVE_OPTIONS);
    const context = compilation.compiler.context;

    return Promise.all(
configs.map(([request, config]) => {
// { shared: {'./utils/shared': './utils/shared' } 会做
        // 一个当前模块是否能被 resolved 的校验
            if (/^..?(/|$)/.test(request)) {
                // relative request
return new Promise(resolve => {
                    resolver.resolve(
{},
context,
request,
resolveContext,
                        (err, result) => {
                            if (err || result === false) {
                                err = err || new Error(`Can't resolve ${request}`);
                                // 加载失败,构建报错
                                                                                                                        compilation.errors.push(
                                    ModuleNotFoundError(null, err, {
                                        name: `shared module ${request}`
                                    })
                                );
                                return resolve();
                            }
                            resolved.set(result, config);
                            resolve();
}
                    );
            });
        } else if (/^(/|[A-Za-z]:\|\\)/.test(request)) {
            // absolute path
            resolved.set(request, config);
} else if (request.endsWith("/")) {
            // module request prefix
            prefixed.set(request, config);
        } else {
            // module request
            unresolved.set(request, config);
        }
    })
    ).then(() => {
        compilation.contextDependencies
          .addAll(resolveContext.contextDependencies);
        compilation.fileDependencies
          .addAll(resolveContext.fileDependencies);
        compilation.missingDependencies
          .addAll(resolveContext.missingDependencies);
        return { resolved, unresolved, prefixed };
    });
};

要看懂这段代码,读者需要有一点背景知识,搞清楚 compilation.resolverFactory是个什么东西。

因为其调用链路比较长,这里我就直接公布答案。实际上这里的 resolverFactory真正来源于

Compiler对象,在初始化 Compiler对象的时候会创建一个 ResolverFactory的实例,并挂在

Compiler的实例属性 resolverFactory 上。而 ResolverFactory则是基于官方编写的 enhanced-resolve 包封装的模块加载工厂,它主要的作用是在一些必要的时候,校验当前的工程项目中文件模块是否能被 resolved,通俗地讲就是校验文件是否存在。Nodejs 本身也有 require.resolve方法,但是功能不够强大,enhanced-resolve 提供的 resolve方法支持异步,而且支持配置更多的上下文信息。

我们再回过头来看 resolveMatchedConfigs的实现,实际上就是根据传入的 consumes,校验consumes对应的模块是否能被 resolved,如果能被 resolved,则根据不同的 request路径,构造出不同的 Map并返回;如果 resolved失败,则会在构建时抛错。因为shared中配置的模块可以是第三方包,例如 react,也可以是本身项目的模块,一般通过相对路径或者绝对路径引用。在我们上面给的例子中,我们得到最终的结果是:

// 如果是内部模块,也就是使用相对路径或者绝对路径配置的模块会存在这个变量里面
const unresolvedConsumes = Map() // size = 0
// ?
const prefixedConsumes = Map() // size = 0
// 第三方模块会存在这个变量里面
const resolvedConsumes = Map() // size = 2  "react" => object, "lodash" => object 

什么类型的 shared 配置会成为 prefixedConsumes,这里暂时没有想到,我们先继续往下看。

createConsumeSharedModule

先贴下代码:

const createConsumeSharedModule = (context, request, config) => {
  // 省略代码
}

// 因为创建一个 ConsumeSharedModule 是异步的,所以这里需要用 tapPromise
normalModuleFactory.hooks.factorize.tapPromise(
    PLUGIN_NAME,
    ({ context, request, dependencies }) =>
// wait for resolving to be complete
promise.then(() => {
            if (
                dependencies[0] instanceof ConsumeSharedFallbackDependency ||
dependencies[0] instanceof ProvideForSharedDependency
            ) {
return;
            }
            const match = unresolvedConsumes.get(request);
            // 一般的 { react: { eager: true, singleton: true } } 配置走的是这个创建逻辑
            if (match !== undefined) {
return createConsumeSharedModule(context, request, match);
            }
            // 什么样的配置走的是这个逻辑?
            for (const [prefix, options] of prefixedConsumes) {
                if (request.startsWith(prefix)) {
                    const remainder = request.slice(prefix.length);
                    return createConsumeSharedModule(context, request, {
...options,
                        import: options.import 
                            ? options.import + remainder : undefined,
shareKey: options.shareKey + remainder
});
            }
}
    })
);
normalModuleFactory.hooks.createModule.tapPromise(
    PLUGIN_NAME,
({ resource }, { context, dependencies }) => {
            if (
                dependencies[0] instanceof ConsumeSharedFallbackDependency ||
dependencies[0] instanceof ProvideForSharedDependency
            ) {
return Promise.resolve();
            }
      // 如果有一个内部模块声明成 shared,则会在 createModule 钩子创建一个 ConsumeSharedModule
           // 这个钩子在创建一个 NormalModule 之前触发
            const options = resolvedConsumes.get(resource);
            if (options !== undefined) {
                return createConsumeSharedModule(context, resource, options);
            }
            return Promise.resolve();
    }
);

接着是定义了一个 createConsumeSharedModule方法,我们先不看这个方法的代码,先继续往下看调用的地方。

监听了 factorize hook,这个钩子看过我前面文章的读者应该很熟悉了,这里不多说。在这里会劫持模块的创建,在前面执行完 resolveMatchedConfigs后,也就是拿到了 unresolvedConsumes

prefixedConsumesresolvedConsumes三个变量的值,就会开始干预模块创建逻辑。首先是排除不需要关注的 ConsumeSharedFallbackDependency 和 ProvideForSharedDependency,直接走正常的模块创建逻辑,这两个 Dependency在后面使用的地方,我再详细介绍。

往下走,就是看下是否当前构建的模块在 unresolvedConsumes里面,如果存在,直接返回

createConsumeSharedModule调用结果。如果不存在,则遍历 prefixedConsumes,将当前处理模块的 requestprefixedConsumes 每一项进行对比,如果匹配,也是返回

createConsumeSharedModule调用结果。

继续往下,监听了 createModule hook,这里跟我们之前劫持的模块相关的 hook有点不一样,这个

hook执行的时机是 NormalModule被创建之后。因为这里处理的是 resolvedConsumes而这里的

****resolvedConsumes的含义则是我们配置一个内部模块作为一个 share 模块,一般配置的路径是相对路径和绝对路径。 也就是说这种类型的模块既生成一个NormalModule ,也会生成一个ConsumeSharedModule

unresolvedConsumes变量从前面的解析中我们知道存的是第三方模块,也就是从 node_modules 里面 import 的模块一般会成为此种类型的模块,那 prefixedConsumes存的又是什么类型的模块了?

我查阅了 Webpack 官网关于 MF 的部分,找到了一点说明,官网是这样说的:

  • Module requests with trailing / in shared will match all module requests with this prefix.

也就是说所有以例如 modulePrefix/这样配置的 share 模块,在构建的时候,会匹配所有通过以下方式 import的模块:

import a from 'modulePrefix/a';
import b from 'modulePrefix/b';

这些模块都能命中,当然这种配置的方式很少见,目前在官网给的 examples 我还没见到过,主要是以第三方模块或者当前项目内部模块为主。

好了,搞清楚了三个变量的来源以及后续对于模块创建流程的干涉,我们回到核心的

createConsumeSharedModule方法的实现:

/**
 * @param {string} context issuer directory
 * @param {string} request request
 * @param {ConsumeOptions} config options
 * @returns {Promise<ConsumeSharedModule>} create module
 */
// 创建一个 ConsumeSharedModule,但是在此之前会做两个事:
// 1.判断模块是否能被 resolve 2.获取模块的版本
const createConsumeSharedModule = (context, request, config) => {
    const requiredVersionWarning = details => {
const error = new WebpackError(
            `No required version specified and unable to automatically 
            determine one. ${details}`
);
error.file = `shared module ${request}`;
compilation.warnings.push(error);
    };
    const directFallback =
config.import && /^(..?(/|$)|/|[A-Za-z]:|\\)/.test(config.import);
    return Promise.all([
new Promise(resolve => {
            if (!config.import) return resolve();
            const resolveContext = {
/** @type {LazySet<string>} */
fileDependencies: new LazySet(),
/** @type {LazySet<string>} */
contextDependencies: new LazySet(),
/** @type {LazySet<string>} */
missingDependencies: new LazySet()
            };
            // 判断模块是否可以 resolved
            resolver.resolve(
{},
directFallback ? compiler.context : context,
config.import,
resolveContext,
                (err, result) => {
                    compilation.contextDependencies.addAll(
resolveContext.contextDependencies
                    );
                    compilation.fileDependencies.addAll(
                        resolveContext.fileDependencies
                    );
                    compilation.missingDependencies.addAll(
                        resolveContext.missingDependencies
                    );
                    if (err) {
                        compilation.errors.push(
                            new ModuleNotFoundError(null, err, {
                                name: `resolving fallback for shared module ${request}`
                            })
);
return resolve();
                    }
                    resolve(result);
                }
            );
        }),
// 根据配置获取 shared 模块版本
new Promise(resolve => {
            if (config.requiredVersion !== undefined)
                return resolve(config.requiredVersion);
                let packageName = config.packageName;
if (packageName === undefined) {
                    if (/^(/|[A-Za-z]:|\\)/.test(request)) {
                        // For relative or absolute requests we don't automatically 
                        // use a packageName.
                        // If wished one can specify one with the packageName option.
return resolve();
                    }
                    const match = /^((?:@[^\/]+[\/])?[^\/]+)/.exec(request);
                    if (!match) {
                        requiredVersionWarning(
                            "Unable to extract the package name from request."
);
return resolve();
                    }
                    packageName = match[0];
                }

                getDescriptionFile(
                    compilation.inputFileSystem,
                    context,
                    ["package.json"],
                    (err, result) => {
                        if (err) {
                            requiredVersionWarning(
                                `Unable to read description file: ${err}`
                            );
                            return resolve();
}
const { data, path: descriptionPath } = result;
if (!data) {
                            requiredVersionWarning(
`Unable to find description file in ${context}.`
                            );
                            return resolve();
}
const requiredVersion = getRequiredVersionFromDescriptionFile(
                            data,
                            packageName
);
                        if (typeof requiredVersion !== "string") {
                            requiredVersionWarning(
`Unable to find required version for 
                                 "${packageName}" in description file (${descriptionPath}).It need to be in dependencies, devDependencies 
                            or peerDependencies.`
);
return resolve();
                    }
                    resolve(parseRange(requiredVersion));
}
            );
        })
    ]).then(([importResolved, requiredVersion]) => {
        // 都成功后再创建 ConsumeSharedModule 模块
return new ConsumeSharedModule(
            directFallback ? compiler.context : context,
            {
                ...config,
importResolved,
import: importResolved ? config.import : undefined,
requiredVersion
            }
         );
    });
};

看着这个函数的实现代码不少,实际上它做的事情非常的清晰:

  1. 校验模块是否能被 resolved
  2. 获取模块配置的版本
  3. 完成以上两个步骤,然后去创建一个 ConsumeSharedModule

我们先说第一点,因为使用 resolver 去判断一个模块能否被 resolved是异步的,所以这里使用了

Promise.all去进行调用。但是这里存疑的一个点是,对于 resolvedConsumes中的模块,在前面的时候已经使用 resolveMatchedConfigs去校验过一次了,但是在创建模块的时候还是会再做一次,感觉这里有点重复了,个人觉得是可以优化的一个点。

关于判断模块是否能被 resolved这部分我们前面大概介绍过一些背景知识,这里就不再做更深入的解析了,感兴趣的读者可以去详细看 ResolveFactory 和 enhance-resolve 相关的详细实现。

我们详细看看第二点,对于模块版本的获取。对于内部模块,正常是没有 requiredVersion的配置,这里我们主要关注三方模块,在前面的例子中,我们配置了 react 和 lodash 的 requiredVersion

如果有配置 requiredVersion,则直接 resolve结果。如果没有,尝试去获取配置的 packageName,如果连 packageName也没有配置,并且是通过相对路径或者绝对路径加载的模块(也就是内部模块),直接 resolve,因为这样的模块一般不会有版本的概念。

继续往下则通过调用 getDescriptionFile方法从 package.json里面去找版本,例如前面的配置,实际上,我们配置的时候可以不指定版本,例如配置成:

const shared = ['react', 'lodash']

此时就会走到 getDescriptionFile的逻辑尝试去 package.json中找到模块的版本,然后完成了版本获取和模块校验后就创建一个 ConsumeSharedModule

下面我们看下这个 Module 的代码。

ConsumeSharedModule

我们首先看下 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 = {};
  if (this.options.import) {
    const dep = new ConsumeSharedFallbackDependency(this.options.import);
    // 配置了 eager 是告诉 webpack 该模块是作为一个 initial chunk,
    // 无论怎么样,初始化都需要加载该模块
    if (this.options.eager) {
      this.addDependency(dep);
    } else {
      // 否则该模块作为一个 async chunk,按需加载
      const block = new AsyncDependenciesBlock({});
      block.addDependency(dep);
      this.addBlock(block);
    }
  }
  callback();
}

在前面的文章中,我们看过了很多的 Module build 方法的实现,这里的实现算是比较简单,只是需要关注我添加注释的地方。这里有个判断逻辑,如果当前的 share 模块有配置 eager选项,则模块会被编译成 initial chunk 类型,首屏一定会加载。否则是一个 async chunk,在使用到的时候才会异步加载。

ConsumeSharedFallbackDependency 对应的 ModuleFactory 是 NomralModuleFactory,走正常模块的构建,没什么好说的。

继续往下,我们看下 codeGeneration方法的实现:

/**
 * @param {CodeGenerationContext} context context for code generation
 * @returns {CodeGenerationResult} result
*/
codeGeneration({ chunkGraph, moduleGraph, runtimeTemplate }) {
  // __webpack_require__.S 一个包含所有 sharedScope 的对象
  const runtimeRequirements = new Set([RuntimeGlobals.shareScopeMap]);
  const {
    shareScope,
    shareKey,
    strictVersion,
    requiredVersion,
    import: request,
    singleton,
    eager
  } = this.options;
  let fallbackCode;
  // 处理 fallback module 的情况
  if (request) {
    if (eager) {
      const dep = this.dependencies[0];
      fallbackCode = runtimeTemplate.syncModuleFactory({
        dependency: dep,
        chunkGraph,
        runtimeRequirements,
        request: this.options.import
      });
    } else {
      const block = this.blocks[0];
      fallbackCode = runtimeTemplate.asyncModuleFactory({
        block,
        chunkGraph,
        runtimeRequirements,
        request: this.options.import
      });
    }
  }
  let fn = "load";
  // 根据不同的配置,生成不同的基于 load 串拼接的函数名
  // 这段可以用编译后的代码来说明
  const args = [JSON.stringify(shareScope), JSON.stringify(shareKey)];
  if (requiredVersion) {
    if (strictVersion) {
      fn += "Strict";
    }
    if (singleton) {
      fn += "Singleton";
    }
    args.push(stringifyHoley(requiredVersion));
    fn += "VersionCheck";
  } else {
    if (singleton) {
      fn += "Singleton";
    }
  }
  if (fallbackCode) {
    fn += "Fallback";
    args.push(fallbackCode);
  }
  const code = runtimeTemplate.returningFunction(`${fn}(${args.join(", ")})`);
  const sources = new Map();
  sources.set("consume-shared", new RawSource(code));
  return {
    runtimeRequirements,
    sources
  };
}

首先定义了模块的 runtimeRequirements,这里取的是一个 __webpack_require__.S对象,在构建后的代码初始化的时候依赖这个对象存储所有 share 模块信息。

接着从 options读取所有配置,然后构造 fallbackCode,这里构造的代码也跟 eager配置强相关,也就是初始化时同步拉取模块或者按需异步拉取模块的区别。syncModuleFactoryasyncModuleFactory实现比较简单,我们就不细讲,为了让大家理解最后代码的差别,我们以前面的例子看下 fallbackCode最后是怎么样的:

// eager 配置项为 false,模块为 async chunk
const fallbackCode = 
  `() => (__webpack_require__.e("defaultVendors-node_modules_react_index_js")
  .then(() => (() => (__webpack_require__("./node_modules/react/index.js)))))
`
// eager 配置项为 true,模块为 initial chunk
const fallbackCode = 
  `() => (() => (__webpack_require__("./node_modules/react/index.js")))
`

以 react 为例,我们可以看到,如果配置了eagertrue,则直接使用 __webpack_require__加载模块,否则使用 __webpack_require__.e异步加载模块,而 __webpack_require__.e则是加载模块前调用的 ensure方法,它返回的是一个 Promise,后面我在介绍这部分运行时的代码时再详细介绍。

继续往下是构造 loadXXX方法,这个方法会根据不同的 share 配置,动态拼接。例如在我们前面的例子中,这里会生成这样一个方法,也就是最后设置到 sources里面的 code代码最终如下:

const code = `
    () => (loadSingletonVersionCheckFallback(
    "default", 
    "react", [1,16,13,0], 
     () => (() => (__webpack_require__("./node_modules/react/index.js")))))
`

这里的代码逻辑,虽然很简单。但是当时我看完的第一感觉有很多不太明白的点。

loadSingletonVersionCheckFallback方法从哪里来?解决 share 模块单例的逻辑又是在哪处理的?为什么这里生成的对应模块的代码称为fallbackCode

所以要搞清楚这部分运作原理,我们还需要详细研究下这块的运行时代码。经过反复的 debug,总算是对 share 模块的加载机制运作流程有些基本了解。下面我们结合一些例子,通过构建产物去分析下 share 模块的加载机制。

share 模块加载机制

ConsumeSharedRuntimeModule

真正开始讲 share 模块加载机制的时候,首先需要介绍 ConsumeSharedPlugin 源码中的一个小细节,它在 apply 方法的最后:

compilation.hooks.additionalTreeRuntimeRequirements.tap(
    PLUGIN_NAME,
    (chunk, set) => {
    // 这里跟 ContainerReferencePlugin 差不多,为相关的 chunk 
    // 添加 webpack runtime 依赖的函数
    // module,内部模块对象
    set.add(RuntimeGlobals.module);
    // __webpack_require__.c 模块缓存对象
    set.add(RuntimeGlobals.moduleCache); 
    // __webpack_require__.m (add only)
    set.add(RuntimeGlobals.moduleFactoriesAddOnly);
    // __webpack_require__.S
    set.add(RuntimeGlobals.shareScopeMap); 
    // __webpack_require__.I
    set.add(RuntimeGlobals.initializeSharing); 
    // __webpack_require__.o
    set.add(RuntimeGlobals.hasOwnProperty); 
    // 这里的 ConsumeSharedRuntimeModule 具体有什么作用?
    compilation.addRuntimeModule(
      chunk, 
      new ConsumeSharedRuntimeModule(set));
    }
);

前面给 chunk 补充一些 runtime 方法的逻辑,在之前的文章中我详细介绍过了,但是这里与之前有点不一样的是,最后调用了 addRuntimeModule方法给 chunk 增加了 ConsumeSharedRuntimeModule, 这个 Module 作用是什么?

首先补充一点背景知识,RuntimeModule 也是继承自 Webpack 源码中的 Module,但是 RuntimeModule 与其它 Module 不一样的是,它的作用是实现一个 generate 方法用来生成一些 Webpack runtime 代码,没有 build 中的依赖收集过程

而这里的 ConsumeSharedRuntimeModule 则继承了 RuntimeModule 并实现了自己的 generate方法:

/**
 * @returns {string} runtime code
*/
 generate() {
  const { compilation, chunkGraph } = this;
  const { runtimeTemplate, codeGenerationResults } = compilation;
  const chunkToModuleMapping = {};
  /** @type {Map<string | number, Source>} */
  const moduleIdToSourceMapping = new Map();
  const initialConsumes = [];
  /**
   *
   * @param {Iterable<Module>} modules modules
   * @param {Chunk} chunk the chunk
   * @param {(string | number)[]} list list of ids
   */
  const addModules = (modules, chunk, list) => {
    for (const m of modules) {
      const module = /** @type {ConsumeSharedModule} */ (m);
      const id = chunkGraph.getModuleId(module);
      list.push(id);
      moduleIdToSourceMapping.set(
        id,
        codeGenerationResults.getSource(
          module,
          chunk.runtime,
          "consume-shared"
        )
      );
    }
  };
  for (const chunk of this.chunk.getAllAsyncChunks()) {
    const modules = chunkGraph.getChunkModulesIterableBySourceType(
      chunk,
      "consume-shared"
    );
    if (!modules) continue;
    addModules(modules, chunk, (chunkToModuleMapping[chunk.id] = []));
  }
  for (const chunk of this.chunk.getAllInitialChunks()) {
    const modules = chunkGraph.getChunkModulesIterableBySourceType(
      chunk,
      "consume-shared"
    );
    if (!modules) continue;
    addModules(modules, chunk, initialConsumes);
  }
  if (moduleIdToSourceMapping.size === 0) return null;

   // 省略一些代码
 }  

我们先不着急解析这段代码,先大概看懂这段代码的意图。其实就是遍历当前构建的 chunk 中所有的同步和异步的 chunk,然后从 chunk 中取出 sourceType类型为 consume-shared的所有模块,然后通过 addModules 方法构造 moduleIdToSourceMappinginitialConsumes等数据。

前面在讲 ConsumeSharedModule 源码的时候,有一个细节,我没有提到:那就是 ConsumeSharedModule 的 sourceType 正好是 consume-shared。上一小节最后留下的几个疑问突然开始有点清晰了起来,我们先继续看下 ConsumeSharedRuntimeModule 生成的 runtime 代码:

/**
 * @returns {string} runtime code
*/
 generate() {
     // 省略一些代码
     return Template.asString([
parseVersionRuntimeCode(runtimeTemplate),
versionLtRuntimeCode(runtimeTemplate),
rangeToStringRuntimeCode(runtimeTemplate),
satisfyRuntimeCode(runtimeTemplate),
// 省略一写代码
`var getSingleton = ${runtimeTemplate.basicFunction(
            "scope, scopeName, key, requiredVersion",
            [
                "var version = findSingletonVersionKey(scope, key);",
"return get(scope[key][version]);"
            ]
)};`,
    // 省略一些代码
    `var loadSingletonVersionCheckFallback = /*#__PURE__*/     init(${runtimeTemplate.basicFunction(
"scopeName, scope, key, version, fallback",
[
            `if(!scope || !${RuntimeGlobals.hasOwnProperty}(scope, key)) return fallback();`,
            "return getSingletonVersion(scope, scopeName, key, version);"
        ]
)});`,
    `var loadStrictVersionCheckFallback = /*#__PURE__*/ init(${runtimeTemplate.basicFunction(
        "scopeName, scope, key, version, fallback",
[
            `var entry = scope && ${RuntimeGlobals.hasOwnProperty}(scope, key) && findValidVersion(scope, key, version);`,
            `return entry ? get(entry) : fallback();`
]
)});`,
"var installedModules = {};",
"var moduleToHandlerMapping = {",
    Template.indent(
Array.from(
            moduleIdToSourceMapping,
            ([key, source]) => `${JSON.stringify(key)}: ${source.source()}`
).join(",\n")
    ),
"};",
initialConsumes.length > 0
    ? Template.asString([
`var initialConsumes = ${JSON.stringify(initialConsumes)};`,
`initialConsumes.forEach(${runtimeTemplate.basicFunction("id", [
    `${
        RuntimeGlobals.moduleFactories
    }[id] = ${runtimeTemplate.basicFunction("module", [
        "// Handle case when module is used sync",
"installedModules[id] = 0;",
`delete ${RuntimeGlobals.moduleCache}[id];`,
"var factory = moduleToHandlerMapping[id]();",
        'if(typeof factory !== "function") throw new Error("Shared module is not available for eager consumption: " + id);',
`module.exports = factory();`
    ])}`
])});`
])
    : "// no consumes in initial chunks",
    this._runtimeRequirements.has(RuntimeGlobals.ensureChunkHandlers)
? Template.asString([
            `var chunkMapping = ${JSON.stringify(
chunkToModuleMapping,
null,
"\t"
            )};`,
            `${
RuntimeGlobals.ensureChunkHandlers
            }.consumes = ${runtimeTemplate.basicFunction("chunkId, promises", [
                `if(${RuntimeGlobals.hasOwnProperty}(chunkMapping, chunkId)) {`,
                    Template.indent([`chunkMapping[chunkId].forEach(${runtimeTemplate.basicFunction(
    "id",
    [    
        `if(${RuntimeGlobals.hasOwnProperty}(installedModules, id)) 
             return promises.push(installedModules[id]);`,
            `var onFactory = ${runtimeTemplate.basicFunction(
                "factory",
[
                    "installedModules[id] = 0;",
                    `${
                        RuntimeGlobals.moduleFactories}[id]=${runtimeTemplate.basicFunction("module", [
                        `delete ${RuntimeGlobals.moduleCache}[id];`,
                            "module.exports = factory();])}`
                ]
)};`,
`var onError = ${runtimeTemplate.basicFunction("error", [
    "delete installedModules[id];",
    `${
RuntimeGlobals.moduleFactories}[id] = ${runtimeTemplate.basicFunction("module", [
`delete ${RuntimeGlobals.moduleCache}[id];`,
"throw error;"
])}`
])};`,
"try {",
    Template.indent([
        "var promise = moduleToHandlerMapping[id]();",
"if(promise.then) {",
            Template.indent(
                "promises.push(installedModules[id] = promise.then(onFactory)['catch'](onError));"
),
"} else onFactory(promise);"
    ]),
    "} catch(e) { onError(e); }"
    ]
)});`
]),
"}"
])}`
])
: "// no chunk loading of consumes"
]); 
}     
  

鉴于这部分字符串代码拼接非常多,所以这里省略了部分代码,读者可以去找到 Webpack 源码目录下的lib/sharing/ConsumeSharedRuntimeModule.js 文件详读。

在这份模板代码里面,我们还可以看到上一小节提到的 loadSingletonVersionCheckFallback方法定义,除此之外还有更多类似的 loadXXX方法的定义。那我们可以断定,这份模板代码并是加载 share 模块必不可少的 runtime 代码

根据这份 runtime 代码,下面我们从几个问题入手,阐述 share 模块加载的几个要点。

share 模块加载的几个要点

第一个问题,什么时候或者哪些 chunk 需要这份 runtime 代码?

其实前面部分的generate源码已经给了我们答案:只要当前构建的 chunk (无论是 async chunk 或者 initial chunk)中的任何 Module 如果是属于 share 模块,那么构建后的产物中必须包含这份 runtime 代码,这是 share 模块加载机制非常重要的一点。


我们看一个具体的例子,我们将例子中的应用称为 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: {
        './share': './src/share',
        './Input': './src/components/Input',
        './date': './src/utils/date',
        './lodash': './src/utils/lodash',
      },
      shared: {
        react: {
          requiredVersion: packageJson['dependencies']['react'],
          singleton: true,
        },

        'react-dom': {
          requiredVersion: packageJson['dependencies']['react-dom'],
          singleton: true,
        },

        lodash: '^4.17.0'
      },
    }),
  ],
};

然后 app1 的 entry文件相关代码如下:

// 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;

从上面的构建配置和 app1 的入口源码可以发现,app1 依赖 shared配置的 reactreact-dom

lodash等模块,然后我们启动 app1,打开 network 在 Sources 中 http://localhost:3001 服务下通过 moduleToHandlerMapping 关键字搜索,这里的 moduleToHandlerMapping 是 ConsumeSharedRuntimeModule 独有的 runtime 代码中的一个变量,我们可以发现:

我们发现打包出来的 main.js 和暴露给其它 host 消费的入口文件都会包含这个关键的运行时代码,点开 main.js 我们可以发现如下源码:

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 installedModules = {};
var moduleToHandlerMapping = {
    "webpack/sharing/consume/default/react/react": () =>
        loadSingletonVersionCheckFallback("default", "react", [1, 16, 13, 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",
            [1, 16, 13, 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"
    ],
    webpack_sharing_consume_default_lodash_lodash: [
"webpack/sharing/consume/default/lodash/lodash"
    ]
};
/**
 * @param chunkId
 * @param promises
 */ __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]);
                /**
                 * @param factory
                 */ 
                 var onFactory = factory => {
                     // 省略一些代码
                 };
                /**
                 * @param error
                 */ 
                 var onError = 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);
            }
        });
    }
};

这部分并是由 ConsumeSharedRuntimeModule 生成的 runtime 代码。

第二个问题,share 模块加载怎么保证单例?


如果用我们对单例模式的理解,对于单例对象,在全局环境下,只能保证唯一一个实例,没有的时候创建,如果已经存在则直接返回。

对于 chunk 来说,也很简单粗暴,构建的时候保证唯一。也就是说配置在 shared 里面的模块在构建的时候默认是单独的一个 chunk,而且不做任何 tree-shaking ,因为 MF 基于运行时共享,它在构建时根本没法知道对于其它的 remote 应用,它们依赖 chunk 中的哪些模块或者方法。


以上面的 app1 为例,我们在 app1 的 utils中使用了两个 lodash方法,然后在 App.tsx 调用,在加载 app1 的 js 资源的时候,我们发现,network 中加载了整个 lodash.js:

这也是 MF 的机制 share 模块机制的副作用,所以在使用的时候需要格外小心,当你的应用非常巨大的时候,如果配置过多模块为 share,需要留意性能问题。

第三个问题,如果 app1 和依赖的 remote 应用 app2 如果配置的 share 模块版本不一致时,怎么处理?


这个问题很好回答,我们直接跑一个 demo 即可,现在我们有两个应用 app1 和 app2,它们同时作为 Bidirectional-hosts 应用,也就是 app1 既消费 app2 导出的模块,也导出模块给 app2 消费,app2 也是如此。

现在 app1 的 react 和 react-dom 版本为 16.13.0,而 app2 的 react 和 react-dom 版本也为

16.13.0,然后它们的 Webpack 配置分别如下:

// app1
module.exports = {
  entry: './src/index',
  // 省略一些配置
  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,
        },
      },
    }),
  ],
};

// app2
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,
        },
      },
    }),
  ],
};

然后访问 app1 的应用页面,查看 react 和 react-dom 加载的路径:

发现 app1 和 app2 的 react 和 react-dom 资源都是加载 app2 构建的产物。

然我们修改下 app1 的 react 和 react-dom 版本,改为 16.14.0,然后装完依赖重启服务,再看下加载结果:

发现 app1 和 app2 的 react 和 react-dom 资源都是加载 app1 构建的产物。

然我们修改下 app1 的 react 和 react-dom 版本,改回为 16.13.0,app2 的对应版本改为 16.14.0,然后装完依赖重启服务,再看下加载结果:

发现 app1 和 app2 的 react 和 react-dom 资源都是加载 app2 构建的产物。

所以我们得出的结论是:

  • 如果两个应用 shared 模块版本一致,优先加载 remotes 应用的构建产物
  • 在配置了单例的前提下,否则谁的模块版本越高,则使用谁的构建产物,前提是没有配置

strictVersion选项;


对于第一点,其实有点难理解,为什么不默认加载自己本身的构建产物,而是加载远程模块的构建产物,这一点是这样吗?于是,我认真看了 app1 对于 app2 组件引用的方式:

import * as React from 'react';

// src/App.tsx
const RemoteButton = React.lazy(() => import('app2/Button'));

虽然前面其本身也导入了 react,但是我们知道 import其本身只是一种静态引用,其实真正渲染后面的组件时才会有 js chunk 请求的发起。而引用 RemoteButton 则是更前置的逻辑,而 RemoteButton 依赖了 react,所以导致这个例子中加载的是 app2 的 react chunk。为了验证我的猜想,我延后 RemoteButton 的加载:

import * as React from 'react';

function loadComponent(scope: string, module: string) {
  return async () => {
    // Initializes the shared scope. Fills it with known 
    // provided modules from this build and all remotes
    await __webpack_init_sharing__('default');
    const container = window[scope]; // or get the container somewhere else
    // Initialize the container, it may provide shared modules
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

const App = () => {
  const [Component, setComponent] = React.useState
    <React.LazyExoticComponent<React.ComponentType<any>> | null>(null)
  
  React.useEffect(() => {
    const moduleFactory = loadComponent('app2', './Button')

    setComponent(React.lazy(moduleFactory))
  }, [])
  return (
    <div>
    <h1>Typescript</h1>
    <h2>App 1</h2>
    <React.Suspense fallback={null}>
      { Component ? <Component /> : null}
    </React.Suspense>
    <LocalButton type="primary">Local Button</LocalButton>
  </div>
  )

然后,刷新 app1 的页面后,我们发现 app1 加载的 react 路径如下:

从实际例子来看,我们得出的结论是同一个 share 模块,谁提供的版本大就优先使用哪个版本。而且可能因为依赖加载的先后顺序,导致依赖的远程应用 chunk 优先加载,至于背后的实现是不是一定是这样,还需要详细研究其 runtime 代码,在下一篇文章中,我将补充这部分的原理解析。

需要补充一点说明的是,在当前的应用中,例如上例中的 app1,无论是加载了 app1 还是 app2 的构建产物的 share 模块,如果配置是开启单例 singleton 选项,后续所有依赖方都将使用这一份 chunk,必须保证只有一份 chunk 来源

对于第二点,有一定的风险,如果其中某个应用依赖的模块版本出现了 breaking change,那么另外一个应用消费的时候风险很大。所以基于这一点,在应用 MF 的时候需要认真考虑这个问题,通过合理的架构设计来尽量避免这个问题。如果配置了 stricVersion选项,这里处理的策略又是不一样的,限于篇幅,这里留到下一篇文章进行解析。

小结

到这里,我们基本看完了 ConsumeSharedPlugin 插件的源码,这部分源码涉及到的内容信息量也不小。从 resolveFactory 到 ConsumeSharedModule,然后最后是非常关键的 ConsumeSharedRuntimeModule。我个人依然觉得 share 机制是 MF 中最复杂的功能,其中有大量的 runtime 代码需要保证。最后我们经过一些实际的例子,总结了一些 share 模块加载机制的几个要点。

总结

本篇文章,我从 SharePlugin 源码出发, 引出了 ConsumeSharePlugin 和 ProvideSharePlugin,在这篇文章中我先是解析了 ConsumeSharePlugin 的源码,然后我又引入了 Webpack ResolveFactory 的设计介绍,接着是ConsumeSharedModule 、ConsumeSharedRuntimeModule 等非常关键的模块,最后通过一些例子,我总了 share 模块加载中非常重要的几个要点。从本文中,我们了解到:

  • SharePlugin 主要是由 ConsumeSharePluginProvideSharePlugin 插件组件,它是一个提供者和消费者模型,也就是 share 模块中构建的产物,即可以本身消费,也会给其它远程应用模块消费;
  • shared配置既可以配置三方模块,并指定版本,也可以配置项目本身的内部模块,还可以通过配置 prefix,匹配多个模块;
  • 每个依赖 share 模块的 chunk 都需要加入 ConsumeSharedRuntimeModule ,让其提供必要的 runtime 代码,这样才能保证 share 模块加载机制正常运作;
  • 为了实现 share 模块的单例,除了在构建时保证唯一的独立的 chunk 外,在运行的时也需要保证只能有一份 chunk 被加载。所以这里就会带来一些副作用,例如构建的模块没法被 tree-shaking 的问题、应用之间的需要保证单例的 share 模块的版本存在 breaking change 的问题,这两点是在应用 MF,设计项目架构的时候需要特别注意的点。

后续文章

限于篇幅,我将在下一篇文章中继续解析 ProvideSharePlugin 插件的源码,实际上理解了 ConsumeSharePlugin 源码,与其对应的 ProvideSharePlugin 源码读起来应该容易很多。在本文中,关于 share 模块机制的加载,更多的是从理论和示例来阐述,下一篇文章将会从构建后的 runtime 代码角度进行更加详细的介绍。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容