美文网首页
解析 Webpack5 的 Module Federation

解析 Webpack5 的 Module Federation

作者: 袋鼠云数栈前端 | 来源:发表于2024-05-13 10:31 被阅读0次

    我们是袋鼠云数栈 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

    相关文章

      网友评论

          本文标题:解析 Webpack5 的 Module Federation

          本文链接:https://www.haomeiwen.com/subject/tolpfjtx.html