我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:贝儿

在前面的文章 基于 Webpack5 Module Federation 的业务解耦实践 中我们在业务中实现了对 Module Federation 的应用。

前言

本文主要以 webpack5 中一个特性 — Module Federation(模块联邦)为核心,介绍 Module Federation 的原理以及其在业务当中的应用场景。

Module Federation 强依赖 webpack 5

Module Federation 是什么

webpack 官网如此定义其动机:

多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。这通常被称作微前端,但并不仅限于此。

🤔 貌似通过 webpack 官网对此动机的阐述仍然无法理解 Module Federation 到底是什么?

最终,经过不断的操作实践,作者认为: Module Federation 是一种“允许应用动态加载另一个应用的代码,而且共享依赖项” 的能力。 我们通过借助 Module Federation 来解决业务中应用的共享依赖,实现独立部署,并远程引用更新依赖的问题。

基本概念

  1. 宿主容器 (host):消费方 , 它动态的加载并运行远程共享的代码。
  2. 远程依赖 (remote):提供方,它暴露出例如组件方法等供宿主容器进行使用。
  3. shared: 指定共享的依赖。

基本用法

有两个应用分别名为application(宿主),appMenus(远程),目的: application 远程获取到 appMenus,并且实现 appMenus 升级, application 加载最新的 appMenus。

其中有如下几个配置项具体作用,可以进行对照阅读

  • name: 应用别名
  • filename:入口文件名, remote 供 host 消费的时候, remote 提供远程文件的文件名字
  • exposes: remote 暴露的组件以及组件的文件路径
  • remotes: 是一个对象, key 为remote 的应用别名,value 为 remote的文件链接

    • 格式必须严格遵守 “obj/@url”的格式,对应的 remote 的别名,url 为 remote 入口的链接
  • shared: shared 配置项指示 remote 应用的输出内容和 host 应用可以共用哪些依赖。 shared 要想生效,则 host 应用和 remote 应用的 shared 配置的依赖要一致。

    • singleton: 是否开启单例模式,默认是 false: 如果 remote 应用和 host 应用共享依赖的版本不一致,remote 应用和 host 应用需要分别各自加载依赖。所以我们在例子中要开启单例模式,让依赖共享。
    • requiredVersion: 指定共享依赖的版本,默认值为当前应用的依赖版本。
    • eager: 共享依赖在打包过程中是否被分离成 async chunk, true 表示共享依赖会打包到main、 remoteEntry 中。默认为 false: 共享依赖被分离出来,实现共享。
    • import: 通过自定义共享依赖的名字,但是需要 import 这个key 来指定实际的 package name。
    • shareScope: 所用共享依赖的作用域名称,默认是default。

基本例子

application(宿主)应用 webpack.config.js 文件

通过设置宿主应用的应用名、关联的远程地址,以及所需要的共享依赖配置,然后从 webpack 中引入 ModuleFederationPlugin 进行配置。

// application webpack.config
const federationConfig = {
    name: 'application',
    // 这里是选择关联其他应用的组件
    remotes: {
        'appMenus':'appMenus@http://localhost:8080/remoteEntry.js'
    },
    // 共享的依赖
   shared: {
      react: {
        singleton: true, // true -> 表示开启单例模式
        requiredVersion: dependencies["react"], // 指定共享依赖的版本
      },
      "react-dom": {
        singleton: true,
        requiredVersion: dependencies["react-dom"],
      },
    }
}

module.exports = {
    entry: "./src/index.tsx",
    ...// 省略
    plugins: [
        new ModuleFederationPlugin({ ...federationConfig }),
    ]
}

子(远程依赖)应用的 webpack.config.js 文件

// 远程 appMenus webpack 配置文件
const federationConfig = {
  name: 'appMenus',
  filename: 'remoteEntry.js',
  // 当前组件需要暴露出去的组件
  exposes: {
    './AppMenus': './src/App',
  },
  shared: { // 统一 react 等版本,避免重复加载
    react: {
      singleton: true,
      requiredVersion: dependencies["react"],
    },
    "react-dom": {
      singleton: true
      requiredVersion: dependencies["react-dom"],
    },
  },
}

application 加载远程依赖

// 在 application 中应用 appMenus
const AppMenus = React.lazy(() => import('appMenus/AppMenus'))
export default () => {
const propsConfig = {
    user: [],
    apps: [],
    showBackPortal: true,
  }
  return (
    <div className="App">
      <header className="App-header">
        <h1>Application Component</h1>
            <React.Suspense fallback="Loading App Container from Host">
            <AppMenus {...propsConfig}></AppMenus>
        </React.Suspense>
      </header>
    </div>
  )
}
}

基本实现效果

image.png

我们通过 network 可以查看出其实现远程加载的过程,首先加载宿主应用 bundle.js, 从bundle.js中可以看到有下面的代码,通过 webpack\_require\_.l 完成异步加载文件 remoteEntry.js 拉到资源文件;查看 remoteEntry.js 文件可以看到全局声明 appMenus 并挂载再 window 下。其中包含了对 appMenus 源码文件的引用,左后完成对 src\_App\_tsx.bundle.js 文件的请求,并完成远程加载第三方依赖的全过程。
(function (module, __unused_webpack_exports, __webpack_require__) {
    "use strict";
    var __webpack_error__ = new Error();
    module.exports = new Promise(function (resolve, reject) {
        if (typeof appMenus !== "undefined") return resolve();
        __webpack_require__.l(
            "http://localhost:8080/remoteEntry.js",
            function (event) {
                if (typeof appMenus !== "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__);
            },
            "appMenus"
        );
    }).then(function () {
        return appMenus;
    });

    /***/
});
var appmenus;
(function (__unused_webpack_module, exports, __webpack_require__) {
    "use strict";
    eval(
        'var moduleMap = {\n\t"./AppMenus": function() {\n\t\treturn Promise.all([__webpack_require__.e("vendors-node_modules_react_jsx-runtime_js"), __webpack_require__.e("webpack_sharing_consume_default_react_react"), __webpack_require__.e("src_App_tsx")]).then(function() { return function() { return (__webpack_require__(/*! ./src/App */ "./src/App.tsx")); }; });\n\t}\n};\nvar get = function(module, getScope) {\n\t__webpack_require__.R = getScope;\n\tgetScope = (\n\t\t__webpack_require__.o(moduleMap, module)\n\t\t\t? moduleMap[module]()\n\t\t\t: Promise.resolve().then(function() {\n\t\t\t\tthrow new Error(\'Module "\' + module + \'" does not exist in container.\');\n\t\t\t})\n\t);\n\t__webpack_require__.R = undefined;\n\treturn getScope;\n};\nvar init = function(shareScope, initScope) {\n\tif (!__webpack_require__.S) return;\n\tvar name = "default"\n\tvar oldScope = __webpack_require__.S[name];\n\tif(oldScope && oldScope !== shareScope) throw new Error("Container initialization failed as it has already been initialized with a different share scope");\n\t__webpack_require__.S[name] = shareScope;\n\treturn __webpack_require__.I(name, initScope);\n};\n\n// This exports getters to disallow modifications\n__webpack_require__.d(exports, {\n\tget: function() { return get; },\n\tinit: function() { return init; }\n});\n\n//# sourceURL=webpack://app-menus/container_entry?'
    );
    /***/
});

Module Federation 原理

ModuleFederationPlugin 插件

我们从 ModuleFederationPlugin 插件导出的入口为切入点,以下是部分源码截图:

class ModuleFederationPlugin {
    constructor(options) {
        validate(options);
        this._options = options;
    }
    apply(compiler) {
        //*** 省略 ***  对 libaray 的一些配置判断
        // 核心操作 - 在完成所有内部插件注册后处理 MF 插件
        compiler.hooks.afterPlugins.tap('ModuleFederationPlugin', () => {
            if (
                options.exposes &&
                (Array.isArray(options.exposes)
                    ? options.exposes.length > 0
                    : Object.keys(options.exposes).length > 0)
            ) {
                // 如果有 expose 配置,则注册一个 ContainerPlugin
                new ContainerPlugin({
                    name: options.name,
                    library,
                    filename: options.filename,
                    runtime: options.runtime,
                    shareScope: options.shareScope,
                    exposes: options.exposes,
                }).apply(compiler);
            }
            if (
                options.remotes &&
                (Array.isArray(options.remotes)
                    ? options.remotes.length > 0
                    : Object.keys(options.remotes).length > 0)
            ) {
                // 如果有 remotes 配置,则初始化一个 ContainerReferencePlugin
                new ContainerReferencePlugin({
                    remoteType,
                    shareScope: options.shareScope,
                    remotes: options.remotes,
                }).apply(compiler);
            }
            if (options.shared) {
                // 如果有 shared 配置,则初始化一个 SharePlugin
                new SharePlugin({
                    shared: options.shared,
                    shareScope: options.shareScope,
                }).apply(compiler);
            }
        });
    }
}

由此可以看出,ModuleFederationPlugin 插件其实不是很复杂,核心在于 afterPlugins hook 触发后,根据是否有 exposes 、remotes、shared 配置项,来决定是否注册 ContainerPlugin、 ContainerReferencePlugin、SharePlugin,那么这三个插件到底做了什么,才实现模块联邦?

ContainerPlugin 插件

class ContainerPlugin {
    constructor(options) {
        validate(options);

        this._options = {
            name: options.name,
            /*共享作用域名称*/
            shareScope: options.shareScope || 'default',
            /*模块构建产物的类型*/
            library: options.library || {
                type: 'var',
                name: options.name,
            },
            /*设置了该选项,会单独为 mf 相关的模块创建一个指定名字的 runtime*/
            runtime: options.runtime,
            filename: options.filename || undefined,
            /*container 导出的模块*/
            exposes: parseOptions(
                options.exposes,
                (item) => ({
                    import: Array.isArray(item) ? item : [item],
                    name: undefined,
                }),
                (item) => ({
                    import: Array.isArray(item.import) ? item.import : [item.import],
                    name: item.name || undefined,
                })
            ),
        };
    }
    /*ContainerPlugin 插件核心*/
    apply(compiler) {
        const { name, exposes, shareScope, filename, library, runtime } = this._options;

        if (!compiler.options.output.enabledLibraryTypes.includes(library.type)) {
            compiler.options.output.enabledLibraryTypes.push(library.type);
        } /*以上是在构建生成最终产物的时候决定 bundlede library 的类型*/

        /*通过监听名字为 make 的hook,然后在回调函数中,
      根据传入的 options 创建 ContainerEntryDependency 的实例,
      然后通过 addEntry将实例传入
    */
        compiler.hooks.make.tapAsync(PLUGIN_NAME, (compilation, callback) => {
            const dep = new ContainerEntryDependency(name, exposes, shareScope);
            dep.loc = { name };
            compilation.addEntry(
                compilation.options.context,
                dep,
                {
                    name,
                    filename,
                    runtime,
                    library,
                },
                (error) => {
                    if (error) return callback(error);
                    callback();
                }
            );
        });

        /*插件监听了thisCompilation的 hook,
      回调里面的逻辑做了两件事:
      1.那就是将 ContainerEntryDependency 的 dependencyFactory设置成
                ContainerEntryModuleFactory的实例,
            2.ContainerExposedDependency的
                dependencyFactory设置成 normalModuleFactory
    */
        compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation, { normalModuleFactory }) => {
            compilation.dependencyFactories.set(
                ContainerEntryDependency,
                new ContainerEntryModuleFactory() // 创建ContainerEntryModule实例
            );

            compilation.dependencyFactories.set(ContainerExposedDependency, normalModuleFactory);
        });
    }
}

总结来说,ContainerPlugin 本质还是在原来的 Webpack构建流程中,引入了新的 entry、ContainerEntryDependency、ContainerEntryModule,而这些新的数据结构同样是基于基础的Dependpency 和 Module 派生出来的。它只需要在适当的时机,通过调用 addEntry 方法,将 exposes 模块加入到正常的 Webpack 构建流程中。

ContainerReferencePlugin 插件

class ContainerReferencePlugin {
    constructor(options) {
        validate(options);
        /* remoteType 告知插件需要加载的远程模块类型,默认为 script,也就是通过插入 script 方式加载远程模块的js chunck */
        this._remoteType = options.remoteType;
        /* 这里将传入的配置 normalize成 _remotes选项的时候,会转换成将远程模块配置以 external 和 sharedScope 为 key 的对象。*/
        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',
            })
        );
    }

    /**
     * Apply the plugin
     * @param {Compiler} compiler the compiler instance
     * @returns {void}
     */
    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++;
            }
        }
        // MF 中的远程模块,其实本质是走 Webpack 本身已经有的ExternalsPlugin逻辑
        new ExternalsPlugin(remoteType, remoteExternals).apply(compiler);

        // 这里的监听事件,通过dependencyFactories 的set方法,将RemoteToExternalDependency也是被当成 NormalModule处理的
        compiler.hooks.compilation.tap(
            'ContainerReferencePlugin',
            (compilation, { normalModuleFactory }) => {
                compilation.dependencyFactories.set(
                    RemoteToExternalDependency,
                    normalModuleFactory
                );

                compilation.dependencyFactories.set(FallbackItemDependency, normalModuleFactory);

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

                // 根据 remotes配置创建一个个 RemoteModule,当然这里需要根据 request与模块的 key做一个匹配。这里的request就是当我们在代码中
                // import xxx from 'app1/Button'后面的 app1/Button字面量 ,
                // 接着与源码前面做 remotes配置初始化后的数组第一个项做匹配,正好是前面提到的 app1,
                // 命中后走创建 RemoteModule逻辑。
                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
                                );
                            }
                        }
                    }
                });

                compilation.hooks.runtimeRequirementInTree
                    .for(RuntimeGlobals.ensureChunkHandlers)
                    .tap('ContainerReferencePlugin', (chunk, set) => {
                        set.add(RuntimeGlobals.module);
                        set.add(RuntimeGlobals.moduleFactoriesAddOnly);
                        set.add(RuntimeGlobals.hasOwnProperty);
                        set.add(RuntimeGlobals.initializeSharing);
                        set.add(RuntimeGlobals.shareScopeMap);
                        compilation.addRuntimeModule(chunk, new RemoteRuntimeModule());
                    });
            }
        );
    }
}

可以看出 该组件的大致流程,script 插入远程 js chunk 入口, 然后通过创建新的 ExternalModule 模块去加入核心的动态加载模块的 runtime 代码。

应用场景

以往的代码复用组件或者是逻辑主要方式:

抽离一个NPM 包,是比较常见的复用手段,但是存在的缺点在于, 当 NPM 包 fix 一个 bug 的时候,那么其他依赖这个NPM 包的应用都需要重新构建打包部署,这种操作是比较重复并且低效的。

打包的产物中可能会包含一些公用的三方库,这样会导致打包之后的内容有重复,也没有起到复用的效果。

Module Federation 便有了以下的应用场景:

  1. 代码共享进行部分代码或者全部应用的共享, 利用 ModuleFederationPlugin 中配置一下 exposes , 宿主使用的时候配置下 remotes 就可以远程应用暴露的想要共享的部分。
    MouduleFederation 虽然可以解决代码共享的问题,但是新的开发模式也会带来几点问题

    • 缺乏类型提示在应用远程应用的时候,是根本获取不到类型文件的,所以在 host 中使用的时候 , 较为不便利。
    • 缺乏支持多个应用同事启动同时开发的工具,与 copy 文件到 host 中支持直接调试,也会带来一些不便利。
  2. 公共依赖在 ModuleFederationPlugin 中配置 shared 字段, 在 ModuleFeration 中所有的公共依赖最终会保存在一个公共变量当中,然后根据不同的规则匹配到相应的依赖版本。

总结与思考

本文从 Module Federation 实践,展示 Module Federation 如何配置,达成什么样的效果。同时再根据产生的效果大致梳理 Module Federation 的源码。

主要核心的实现插件为 ContainerPlugin\ContainerReferencePlugin\SharePlugin

ContainerPlugin:

通过调用 addEntry方法,将 exposes 模块加入到正常的 Webpack 构建流程中。

ContainerReferencePlugin:

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

SharePlugin:

MF shared 模块机制是一个 Provide 和 Consume 模型,需要 ProvideSharedModule 和 ConsumeSharedPlugin 同时提供相关的功能才能保证模块加载机制;

在初始化应用过程中,在真正加载 share 模块之前,必须先通过 initializeSharing 的 runtime 代码保证各应用的 share 模块进行注册;

如果当前 shareScope 有同一个模块的多个版本,为了保证单例,在获取模块版本时,总是返回版本最大的那一个。如果有任何应用加载过程版本没匹配上,只是会做一个 warning 打印提示,并不会阻断加载流程;如果配置中 strictVersion 配置项为 true 时,则直接抛错阻断加载过程;

如果加载多个应用过程中,同时注册 share 模块,则如果版本一致的时候,会通过覆盖的方式,保证 shareScope 的同一模块同一版本只有一份。

最后

欢迎关注【袋鼠云数栈UED团队】\~\
袋鼠云数栈UED团队持续为广大开发者分享技术成果,相继参与开源了欢迎star


袋鼠云数栈UED
277 声望33 粉丝

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。