Skip to content

Module Federation

背景

在之前我们在跨项目之间复用组件代码或逻辑时,一般有以下三种方式:

  1. 将组件代码封装为一个 npm 包发布,各项目拉取依赖该 npm。这是一种广泛采用的做法,它优化了代码的可维护性和可复用性。然而这种方法存在效率问题:一旦 npm 包有更新,每个依赖该包的应用都需要升级版本、重新构建并部署,这在多团队协作时尤其耗时。

  2. 将代码打包为 UMD 格式,并通过 CDN 分发。这种方法可以减少重新构建和部署的需求,但随着依赖组件和逻辑的增多,可能会导致性能问题,如重复的代码块 chunk。例如,如果 A 和 B 组件都依赖 lodash,那么在 UMD 打包过程中,lodash 的代码可能会被重复打包,导致无法有效复用。

  3. 复制粘贴。复用成本最低,维护成本最高;

是否存在一种策略,能够在模块版本更新后,无需逐一重新部署依赖该模块的所有项目,而是直接在生产环境中动态加载更新后的模块?

什么是 Module Federation

多个独立的构建可以组成一个应用程序,这些独立的构建之间不应该存在依赖关系,因此可以单独开发和部署它们。

  1. 配置简单灵活,不需要中心应用,同时也能成为其他子应用的宿主。
  2. 可以独立部署、上线运行,像 npm 包一样引入使用,但支持热更新,升级无需发布通知。
  3. 运行时加载模块,允许应用之间远程共享依赖,减少了重复代码,提高了加载效率和性能。

Module Federation 是 Webpack 5 的新特性之一,允许在多个 webpack 编译产物之间在线共享模块、依赖、页面甚至应用,提供了一种更为轻量、灵活的微前端实现方式,通过远程共享机制,它优化了资源利用,减少了不必要的重复加载,使得各个子应用能够更加高效地协同工作。

Module Federation

  1. Container

    • 一个使用 ModuleFederationPlugin 构建的应用就是一个 Container,它可以加载其他的 Container,也可以被其他的 Container 加载。
  2. Host&Remote

    • 从消费者和生产者的角度看 Container,Container 可以分为 Host 和 Remote,Host 作为消费者,他可以动态加载并运行其他 Remote 的代码,Remote 作为提供方,他可以暴露出一些属性、方法或组件供 Host 使用,这里要注意的一点是一个 Container 既可以作为 Host 也可以作为 Remote。
  3. Shared

    • shared 表示共享依赖,一个应用可以将自己的依赖共享出去,比如 react、react。

案例解析

代码配置

示例项目地址github

新建两个 react 项目,分别各自导出一个 react 组件,并通过 mf 远程引入对方组件:

app1 的 App.jsx:

js
import React from 'react';
import Button from './src/Button';
import Logo from 'comp/Logo';
import _ from 'lodash';
export default class App extends React.Component {
  constructor(props) {
    super(props);
  }
  render() {
    return (
      <div>
        app1 lodash的版本号:{_.VERSION}
        <Button />
        <Logo></Logo>
      </div>
    );
  }
}

app2 的 App.jsx:

js
import React from 'react';
import Button from 'comp/Button';
import Logo from './src/Logo';
import _ from 'lodash';
export default class App extends React.Component {
  constructor(props) {
    super(props);
  }
  render() {
    return (
      <div>
        app2 lodash的版本号:{_.VERSION}
        <Button type="warning" />
        <Logo></Logo>
      </div>
    );
  }
}

app1 里的 webpack.config.js

js
//
   new ModuleFederationPlugin({
      name: 'app1',
      filename: 'app1RemoteEntry.js',
      remotes: {
        'comp': 'app2@http://localhost:8002/app2RemoteEntry.js',
      },
      exposes: {
        './Button': './src/Button.jsx',
      },
      shared: { lodash: { singleton: true } },
    }),

app2 里的 webpack.config.js

js
//
    new ModuleFederationPlugin({
      name: 'app2',
      filename: 'app2RemoteEntry.js',
      remotes: {
        'comp': 'app1@http://localhost:8001/app1RemoteEntry.js',
      },
      exposes: {
        './Logo': './src/Logo.jsx',
      },
      shared: { lodash: { singleton: true }},
    }),

效果如下:

app1

app2

从以上效果可以看到:

  1. app1/app2 通过异步下载 app1RemoteEntry/app2RemoteEntry 入口文件来完成远程组件的导入;
  2. app1 引入了 app2 里的 Logo 组件,app2 也可以同时引入 app1 里的 Buttom 组件;
  3. app1、app2 共用 8001 即 app1 的 lodash 依赖,因为其版本较高;

以上就是 MF 实现了代码动态加载以及依赖共享的功能。

配置解释

  1. name

    • 代表当前应用的唯一别名,作为远程(remote)应用时,被宿主(host)应用引用,需要在路径前加上这个前缀。
    • 例如,如果宿主应用配置为 {'comp': 'app1@http://localhost:3001/app1RemoteEntry.js'},则 app1 是远程应用的 name
  2. filename

    • 指定远程应用提供给宿主应用使用的入口文件名。
    • 例如,如果远程应用的 filename 设置为 app1RemoteEntry,构建产物中会包含一个 app1RemoteEntry.js 文件供宿主应用加载。
  3. exposes

    • 定义远程应用暴露给宿主应用的属性、方法和组件。是一个对象,其中键(key)是宿主应用中使用时的路径,值(value)是远程应用中暴露出的资源的路径,如:'./Logo': './src/Logo.jsx'
    • 宿主应用可以通过 import Logo from 'comp/Logo' 引入远程应用的 Logo 组件。
  4. remote

    • 定义宿主应用需要使用的远程应用及其资源地址,如:'comp': 'app1@http://localhost:8001/app1RemoteEntry.js'
    • 是一个对象,键(key)是远程应用的自定义别名,值(value)是远程应用的资源地址。
  5. shared

    • 指定无论当前应用是作为宿主还是远程,都可以共享的依赖。

    • 例如,shared: { lodash: { singleton: true }} 表示 lodash 以单例模式共享。

    • singleton:确定是否以单例模式共享依赖。开启后,共享的依赖只加载一次,优先使用版本高的。

    • requiredVersion:指定共享依赖的版本。

例如,如果宿主应用的 lodash 版本为 3.8.0,远程应用的 lodash 版本为 4.8.0,并且 singletontrue,那么两者将共用 4.8.0 版本的 lodash。如果远程应用将 lodashrequiredVersion 设置为 2.0.0,则它将使用自己的 lodash 版本,而不是共享宿主应用的版本。

通过这种配置,Module Federation 为微前端架构提供了灵活的依赖共享机制,优化了资源利用并减少了不必要的重复加载。

runtime 源码浅析

前置知识:

webpack 加载远程资源方法:

js
/* webpack/runtime/ensure chunk */
(() => {
  __webpack_require__.f = {};
  // This file contains only the entry chunk.
  // The chunk loading function for additional chunks
  __webpack_require__.e = (chunkId) => {
    return Promise.all(
      Object.keys(__webpack_require__.f).reduce((promises, key) => {
        __webpack_require__.f[key](chunkId, promises);
        return promises;
      }, []),
    );
  };
})();

其中 __webpack_require__.e 将同步执行 f 的三个方法: remotes、consumes、j,并等待均执行完成后才会执行 e 的回调方法。

我们以 app2 加载 app1 的远程组件 Button 为例来展开(app1 加载 app2 同理不在赘述),在编译后 bootstrap_js 文件的 App.jsx 里有如下代码:

js
var comp_Button__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(
  /*! comp/Button */ 'webpack/container/remote/comp/Button',
);
//...
var lodash__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(
  /*! lodash */ 'webpack/sharing/consume/default/lodash/lodash',
);

webpack/container/remote/comp/Button 即为 import Button from 'comp/Button' 编译后的代码,webpack/sharing/consume/default/lodash/lodash 即为共享模块代码,这两个模块什么时候是什么时候引入的呢,回头看主文件 main.js 有如下加载 bootstrap_js 的代码:

js
Promise.all(
  /*! import() */ [
    __webpack_require__.e('vendors-node_modules_pnpm_react_18_2_0_node_modules_react_index_js'),
    __webpack_require__.e('vendors-node_modules_pnpm_react-dom_18_2_0_react_18_2_0_node_modules_react-dom_client_js'),
    __webpack_require__.e('bootstrap_js'),
  ],
).then(__webpack_require__.bind(__webpack_require__, /*! ./bootstrap.js */ './bootstrap.js'));
//...

可以看到需要先加载完相关依赖文件后,才会执行 bootstrap.js module 里的代码,而要加载 bootstrap_js 文件,得加载他对应的依赖,分别执行以下 3 个方法:

remotes

js
// remotes
(() => {
  var chunkMapping = {
    bootstrap_js: ['webpack/container/remote/comp/Button'],
  };
  var idToExternalAndNameMapping = {
    'webpack/container/remote/comp/Button': ['default', './Button', 'webpack/container/reference/comp'],
  };
  __webpack_require__.f.remotes = (chunkId, promises) => {
    //...
  };
})();
js
__webpack_require__.f.remotes = (chunkId, promises) => {
  if (__webpack_require__.o(chunkMapping, chunkId)) {
  chunkMapping[chunkId].forEach((id) => {
    var getScope = __webpack_require__.R;
    if(!getScope) getScope = [];
    var data = idToExternalAndNameMapping[id];
    if(getScope.indexOf(data) >= 0) return;
    getScope.push(data);
    if(data.p) return promises.push(data.p);
    var onError = (error) => {
      //...
     };
    var handleFunction = (fn, arg1, arg2, d, next, first) => {
      try {
        var promise = fn(arg1, arg2);
        if(promise && promise.then) {
          var p = promise.then((result) => (next(result, d)), onError);
          if(first) promises.push(data.p = p); else return p;
        } else {
          return next(promise, d, first);
        }
      } catch(error) {
        onError(error);
      }
    }
    var onExternal = (external, _, first) => (external ? handleFunction(__webpack_require__.I, data[0], 0, external, onInitialized, first) :
    var onInitialized = (_, external, first) => (handleFunction(external.get, data[1], getScope, 0, onFactory, first));
    var onFactory = (factory) => {
      data.p = 1;
      __webpack_require__.m[id] = (module) => {
        module.exports = factory();
      }
    };
    handleFunction(__webpack_require__, data[2], 0, 0, onExternal, 1);
  });
  }
};

该 remotes 方法为加载远程组件的核心方法,通过 idToExternalAndNameMapping 和 remote 可以看出执行逻辑:

第一次执行 handleFunction:加载该 module(webpack/container/remote/comp/Button)对应的加载远程的【本地 module】(webpack/container/reference/comp);

webpack/container/reference/comp:

js
'use strict';
var __webpack_error__ = new Error();
module.exports = new Promise((resolve, reject) => {
  if (typeof app1 !== 'undefined') return resolve();
  __webpack_require__.l(
    'http://localhost:8001/app1RemoteEntry.js',
    (event) => {
      if (typeof app1 !== 'undefined') return resolve();
      //reject(__webpack_error__);
    },
    'app1',
  );
}).then(() => app1);

app1 里的 app1RemoteEntry.js:

js
var app1;
(() => {
  // webpackBootstrap
  // ...
  var __webpack_exports__ = __webpack_require__('webpack/container/entry/app1');
  app1 = __webpack_exports__;
})();

app1 里的webpack/container/entry/app1

js
var __webpack_modules__ = {
  'webpack/container/entry/app1': (__unused_webpack_module, exports, __webpack_require__) => {
    var moduleMap = {
      './Button': () => {
        return Promise.all([
          __webpack_require__.e('vendors-node_modules_pnpm_react_18_2_0_node_modules_react_index_js'),
          __webpack_require__.e('src_Button_jsx'),
        ]).then(() => () => __webpack_require__(/*! ./src/Button.jsx */ './src/Button.jsx'));
      },
    };
    var get = (module, getScope) => {
      __webpack_require__.R = getScope;
      getScope = moduleMap[module]();
      __webpack_require__.R = undefined;
      return getScope;
    };
    var init = (shareScope, initScope) => {
      if (!__webpack_require__.S) return;
      var name = 'default';
      //...
      __webpack_require__.S[name] = shareScope;
      return __webpack_require__.I(name, initScope);
    };

    // This exports getters to disallow modifications
    __webpack_require__.d(exports, {
      get: () => get,
      init: () => init,
    });
  },
};

__webpack_require__.l 是通过 jsonp 的方式加载远程资源app1RemoteEntry.js,加载完成后将 app1RemoteEntry.js 里的全局变量 app1 返回,传给 onExternal 方法里的第一个参数 external

第二次执行 handleFunction(onExternal):执行__webpack_require__.I('default') 初始化注册共享模块:

js
(() => {
__webpack_require__.S = {};
var initPromises = {};
var initTokens = {};
__webpack_require__.I = (name, initScope) => {
 if(!initScope) initScope = [];
 var initToken = initTokens[name];
 if(!initToken) initToken = initTokens[name] = {};
 if(initScope.indexOf(initToken) >= 0) return;
 initScope.push(initToken);
 if(initPromises[name]) return initPromises[name];
 if(!__webpack_require__.o(__webpack_require__.S, name)) __webpack_require__.S[name] = {};
 var scope = __webpack_require__.S[name];
 var uniqueName = "main-app";
 var register = (name, version, factory, eager) => {
  var versions = scope[name] = scope[name] || {};
  var activeVersion = versions[version];
  if(!activeVersion || (!activeVersion.loaded && (!eager != !activeVersion.eager ? eager : uniqueName > activeVersion.from))) versions[version] = { get: factory, from: uniqueName, eager:!!eager };
 };
 var initExternal = (id) => {
  try {
   var module = __webpack_require__(id);
   if(!module) return;
   var initFn = (module) => (module && module.init && module.init(__webpack_require__.S[name], initScope))
   if(module.then) return promises.push(module.then(initFn, handleError));
   var initResult = initFn(module);
   if(initResult && initResult.then) return promises.push(initResult['catch'](handleError));
  } catch(err) { }
 }
 var promises = [];
 switch(name) {
  case "default": {
   register("lodash", "3.8.0", () => (__webpack_require__.e("vendors-node_modules_pnpm_lodash_3_8_0_node_modules_lodash_index_js").then(() => (() => (__webpack_require__(/*! ../..node_modules/.pnpm/lodash@3.8.0/node_modules/lodash/index.js */ "../../node_modules/.pnpm/lodash@3.8.0/node_modules/lodash/index.js"))))));
   initExternal("webpack/container/reference/comp");
  }
  break;
 }
 if(!promises.length) return initPromises[name] = 1;
 return initPromises[name] = Promise.all(**promises**).then(() => (initPromises[name] = 1));
};
})();

__webpack_require__.I 是共享模块的核心。register 方法注册自身依赖的共享模块,initExternal 也执行加载远程的【本地 module】,得到结果即 webpack/container/entry/app1 模块的 exports 后,执行其 init 方法,方法里通过__webpack_require__.S将 app2 的共享模块共享给了 app1(共用同一个__webpack_require__.S引用类型),然后 app1 执行了__webpack_require__.I 同样注册自己的共享模块,这样 __webpack_require__.S 保存的信息为:

share

第三次执行 handleFunction(onInitialized):执行 external.get('./Button')加载远程模块 Button,加载完成后在 onFactory 里将该模块存入自身的模块系统中(webpack/container/remote/comp/Button);

consumes

js
//...
// consumes
(() => {
  var init = (fn) =>
    function (scopeName, a, b, c) {
      var promise = __webpack_require__.I(scopeName);
      if (promise && promise.then)
        return promise.then(fn.bind(fn, scopeName, __webpack_require__.S[scopeName], a, b, c));
      return fn(scopeName, __webpack_require__.S[scopeName], a, b, c);
    };
  var loadSingletonVersionCheckFallback = /*#__PURE__*/ init((scopeName, scope, key, version, fallback) => {
    if (!scope || !__webpack_require__.o(scope, key)) return fallback();
    return getSingletonVersion(scope, scopeName, key, version);
  });
  var moduleToHandlerMapping = {
    'webpack/sharing/consume/default/lodash/lodash': () =>
      loadSingletonVersionCheckFallback('default', 'lodash', [4, 3, 8, 0], () =>
        __webpack_require__
          .e('vendors-node_modules_pnpm_lodash_3_8_0_node_modules_lodash_index_js')
          .then(
            () => () =>
              __webpack_require__(/*! lodash */ '../../node_modules/.pnpm/lodash@3.8.0/node_modules/lodash/index.js'),
          ),
      ),
  };
  var chunkMapping = {
    bootstrap_js: ['webpack/sharing/consume/default/lodash/lodash'],
  };
  __webpack_require__.f.consumes = (chunkId, promises) => {
    if (__webpack_require__.o(chunkMapping, chunkId)) {
      chunkMapping[chunkId].forEach((id) => {
        if (__webpack_require__.o(installedModules, id)) return promises.push(installedModules[id]);
        if (!startedInstallModules[id]) {
          var onFactory = (factory) => {
            installedModules[id] = 0;
            __webpack_require__.m[id] = (module) => {
              delete __webpack_require__.c[id];
              module.exports = factory();
            };
          };
          startedInstallModules[id] = true;
          var onError = (error) => {
            delete installedModules[id];
            __webpack_require__.m[id] = (module) => {
              delete __webpack_require__.c[id];
              throw error;
            };
          };
          try {
            var promise = moduleToHandlerMapping[id]();
            if (promise.then) {
              promises.push((installedModules[id] = promise.then(onFactory)['catch'](onError)));
            } else onFactory(promise);
          } catch (e) {
            onError(e);
          }
        }
      });
    }
  };
})();

该 consumes 方法是确认版本并加载共享模块的核心,在 loadSingletonVersionCheckFallback->init 里,通过之前的__webpack_require__.I 收集注册所有的版本后,通过 getSingletonVersion 方法按照配置选择对于的版本(内部通过 get 方法即当初 register 的第三个参数加载对应版本)。然后通过 onFactory 将该模块存入自身的模块系统中(webpack/sharing/consume/default/lodash/lodash)。

j

js
var installedChunks = {
  main: 0,
};

__webpack_require__.f.j = (chunkId, promises) => {
  // JSONP chunk loading for javascript
  var installedChunkData = __webpack_require__.o(installedChunks, chunkId) ? installedChunks[chunkId] : undefined;
  if (installedChunkData !== 0) {
    if (installedChunkData) {
      promises.push(installedChunkData[2]);
    } else {
      if (true) {
        var promise = new Promise(
          (resolve, reject) => (installedChunkData = installedChunks[chunkId] = [resolve, reject]),
        );
        promises.push((installedChunkData[2] = promise));
        var url = __webpack_require__.p + __webpack_require__.u(chunkId);
        var loadingEnded = (event) => {
          if (__webpack_require__.o(installedChunks, chunkId)) {
            installedChunkData = installedChunks[chunkId];
            if (installedChunkData !== 0) installedChunks[chunkId] = undefined;
            if (installedChunkData) {
              // installedChunkData[1](error);
            }
          }
        };
        __webpack_require__.l(url, loadingEnded, 'chunk-' + chunkId, chunkId);
      }
    }
  }
};
//...
var webpackJsonpCallback = (parentChunkLoadingFunction, data) => {
  var [chunkIds, moreModules, runtime] = data;
  var moduleId,
    chunkId,
    i = 0;
  if (chunkIds.some((id) => installedChunks[id] !== 0)) {
    for (moduleId in moreModules) {
      if (__webpack_require__.o(moreModules, moduleId)) {
        __webpack_require__.m[moduleId] = moreModules[moduleId];
      }
    }
    if (runtime) var result = runtime(__webpack_require__);
  }
  if (parentChunkLoadingFunction) parentChunkLoadingFunction(data);

  for (; i < chunkIds.length; i++) {
    chunkId = chunkIds[i];
    if (__webpack_require__.o(installedChunks, chunkId) && installedChunks[chunkId]) {
      installedChunks[chunkId][0]();
    }
    installedChunks[chunkId] = 0;
  }
};

var chunkLoadingGlobal = (self['webpackChunkapp1'] = self['webpackChunkapp1'] || []);
chunkLoadingGlobal.forEach(webpackJsonpCallback.bind(null, 0));
chunkLoadingGlobal.push = webpackJsonpCallback.bind(null, chunkLoadingGlobal.push.bind(chunkLoadingGlobal));

installedChunks是一个键值对,记录了加载中和已加载的 chunk 代码块的状态,形如:

installedChunks

重写了 self['webpackChunkapp1']的 push 方法,使其执行 webpackJsonpCallback:

js
(self['webpackChunkapp1'] = self['webpackChunkapp1'] || []).push([
  ['vendors-node_modules_pnpm_lodash_4_8_0_node_modules_lodash_lodash_js'],
  {
    /***/ '../../node_modules/.pnpm/lodash@4.8.0/node_modules/lodash/lodash.js':
      /*!***************************************************************************!*\
  !*** ../../node_modules/.pnpm/lodash@4.8.0/node_modules/lodash/lodash.js ***!
  \***************************************************************************/
      /***/ function (module, exports, __webpack_require__) {
        //...
      },
  },
]);

在 j 方法里,执行__webpack_require__.l异步拉取 chunk;当一个新的 chunk 加载完成时,webpackJsonpCallback 函数会被调用,内部函数将新加载的模块添加到模块列表 __webpack_require__.m 中,并执行 installedChunks 对应模块 key 的回调 resolve;当加载失败时,执行 loadingEnded 即 installedChunks 对应模块 key 的回调 reject,完成__webpack_require__.j的执行。

__webpack_require__.f里的remotesconsumesj均执行完成后,即可执行__webpack_require__.e的回调即返回或执行对应模块。

总结

app2 会先执行入口文件 main.js,然后加载 bootstrap_js chunk 块,执行 3 个 f 方法 remotes、consumes、j,分别对应其远程模块依赖加载、共享模块依赖加载、该 chunk 文件异步加载;均加载完成后,即加载 bootstrap.js module。

  • remotes:依赖了远程模块 webpack/container/remote/component-app/Button,那么先会去下载远程模块 webpack/container/remote/component-app,即 remoteEntry.js ,然后返回 component_app 这个全局变量,然后执行 component-app.get('./xxx') 去获取对应的组件,等远程应用的资源以及 bootstrap_js 资源全部下载完成后再执行 bootstrap.js module;
  • consumes:共享依赖的模块在加载前都会先调用 __webpack_require__.I 去初始化共享依赖,使用 __webpack_require__.S 对象来保存着每个应用的共享依赖版本信息,每个应用引用共享依赖时,会根据不同的自己配制的规则从 __webpack_require__.S 获取到适合的依赖版本,__webpack_require__.S 是应用间共享依赖的桥梁;
  • j: 通过 __webpack_require__.l 加载 chunk 块;

应用场景

  1. 远程代码(组件、逻辑)依赖
  2. 公共模块共享

缺点

注: 沙箱隔离(js、css)不属于微模块的职责,不算缺点。

  1. 缺乏远程模块类型提示
  2. 缺乏版本管理
  3. 与 webpack runtime 强绑定,必须使用 webpack5

MF 总结

什么是微前端? 微前端是一种多个团队通过独立发布功能的方式来共同构建现代化 web 应用的技术手段及方法策略。

目前包含 3 种方案:

  1. 基座(容器)模式:通过搭建基座、配置中心来管理子应用。如基于 SIngle Spa 的偏通用的乾坤方案,也有基于本身团队业务量身定制的方案。 qiankun wujie microapp
  2. 自组织模式: 通过约定进行互调,但会遇到处理第三方依赖等问题。 nginx
  3. 去中心(微模块)模式: 脱离基座模式,每个应用之间都可以彼此分享资源。如基于 Webpack 5 Module Federation 实现的 EMP 微前端方案,可以实现多个应用彼此共享资源分享。 MF emp hel

模块联邦(微模块方案)是一个定义,即一种支持模块独立开发与独立部署,并在多个项目间运行时共享的技术方案,webpack5 MF、empjs 、vite-federation-plugin、hel-micro 都是其实现。