module federation模块联邦与微前端

module federation是什么

webpack5新增了module federation,module federation的作用,将每个构建(build)作为容器(这是一个概念),构建后的资源可以正常部署,同时还具备在运行时对外暴露其中的模块,这就意味着多个构建可以独立完成,独立部署,所需的依赖可以在运行时加载。对于多个构建公共的依赖,可以通过shared来指定,这些依赖也可以在运行时加载,并且只加载一次。

事实上,公共依赖模块也可以通过npm包的形式来实现共享,这种方式的共享不得不依赖于app shell这种容器预先加载共享包。module federation只在第一次加载模块A时加载共享包,加载模块B时共享包已被缓存。

模块与容器的概念有点重叠,容器实际上是一些模块的集合,与构建相关,是我们传统意义上的bundle,只是在加入module federation能力后,容器可以对外导出成员,这一点跟模块比较接近而已。

这种能力刚好与微前端架构所需的前端集成能力一致。前端集成技术,就是应用A将应用B、C等应用的某些页面、组件、片段等等,集成到自己页面里。

加入module federation的构建

传统的构建过程,模块之间如果存在依赖关系,这些模块会在一个构建过程中打包成一个bundle。

而module federation让这种存在依赖关系的模块,各自有各自的构建过程,并各自实现自己的bundle和部署,最终在运行时异步获取依赖模块。这种方式提高了模块的自主性,但可能因为异步的原因,降低了首屏渲染性能、运行时的用户交互体验等等。

有对外导出的容器示例

这里展示一个module federation的示例使用。products项目展示一些产品名称,其工程目录如下

js 复制代码
- products/
  - public/
    - index.html // 模板
  - src/
    - bootstrap.js // 导入faker生成假数据,导出一个mount方法,将html内容挂载到某个DOM节点上
    - index.js  // 入口文件,导入bootstrap.js模块
  - package.json 
  - webpack.config.js // 配置module federation的配置文件

bootstrap.js的内容如下

js 复制代码
// 声明为shared的模块,会被拆分为异步模块,所以需要异步加载
const faker = await import("faker");

function mount(el) {
  let products = "";

  for (let i = 1; i <= 5; i++) {
    products += `<div>${faker.commerce.productName()}</div>`;
  }
  el.innerHTML = products;
}

if (process.env.NODE_ENV === "development") {
  // 依赖该模块的容器,必须提供一个id属性为'dev-products'的DOM元素
  const el = document.querySelector("#dev-products");
  if (el) mount(el);
}

export { mount };
构建产物

webpack的module federation相关配置示例

js 复制代码
  devServer: {
    port: 8081,
  },
  // 省略其他传统的配置
  plugins: [
    new ModuleFederationPlugin({
      name: "products", // 当前容器的名称,其他容器导入该容器时的标识
      filename: "remoteEntry.js", // 当前容器的入口文件,与output.filename不是一回事
      exposes: {
        // 导出成员对应的模块会被拆离为异步模块
        "./Index": "./src/bootstrap.js", // 指定对外暴露的模块列表,标识符: 模块地址
      },
      shared: { // 公共的共享模块,其他容器也会使用faker这个模块,共享模块在构建产物会被作为单独的包存在,由容器异步加载
        faker: {
          singleton: true,
        },
      },
    }),
],

在加入module federation的webpack配置下,构建产物发生了一定的变化,除了传统的bundle外,还会有如下产物

  • module federation插件产生的容器入口文件,如上面配置的remoteEntry.js;
  • 对外暴露的模块,如上面配置的"./Index": "./src/bootstrap.js"的产物src_bootstrap_js.js;
  • 共享模块,如上面配置的faker,产物是vendors-node_modules_faker_index_js.js;

而通过模板生成的index.html中,不仅有传统的bundle产物main.js,也会有remoteEntry.js。对于传统的部署而言,remoteEntry.js是没必要的。main.js与remoteEntry.js有很多重复的webpack胶水代码。

容器对外的入口文件remoteEntry.js

查看由module federation生成的容器入口文件,可以看到与传统的bundle不一样的地方在于,容器入口文件包含一个变量声明var products, 与配置name: "products"一致。

remoteEntry.js会包含一个moduleMap,包含模块标识符'./Index'与src_bootstrap_js.js

remoteEntry.js包含本地容器的初始化init方法和获取导出成员get方法,被加载后导出了products变量,携带init和get方法。

js 复制代码
/***/ // "webpack/container/entry/products":
/*!***********************!*\
  !*** container entry ***!
  \***********************/
/**
 * remoteEntry.js中 "webpack/container/entry/products" 对应的函数内部的eval代码整理如下
 */

var moduleMap = {
  "./Index": () => {
    return __webpack_require__
      .e("src_bootstrap_js")
      .then(
        () => () =>
          __webpack_require__(/*! ./src/bootstrap.js */ "./src/bootstrap.js")
      );
  },
};
// container的getter,将container中的module加载,并返回加载后的module
var get = (module, getScope) => {
  __webpack_require__.R = getScope;
  getScope = __webpack_require__.o(moduleMap, module)
    ? moduleMap[module]()
    : Promise.resolve().then(() => {
        throw new Error('Module "' + module + '" does not exist in container.');
      });
  __webpack_require__.R = undefined;
  return getScope;
};
// 初始化容器,通过shareScope来提供对外共享的module,如果声明了shared,每个build都会有shared的module,即便有重复
// 如果共享module已经被使用了,那么该容器的共享module会被忽略,但会作为fallback
var init = (shareScope, initScope) => {
  if (!__webpack_require__.S) return;
  var name = "default";
  var oldScope = __webpack_require__.S[name];
  if (oldScope && oldScope !== shareScope)
    throw new Error(
      "Container initialization failed as it has already been initialized with a different share scope"
    );
  __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,
});

//# sourceURL=webpack://products/container_entry?;

有导入其他容器的容器示例

通常导入别的容器的容器会作为一个app shell,加载其他容器的模块,这正是微前端的客户端集成方案。这里的container项目,加载products的bootstrap模块,使用mount方法挂载HTML内容。目录示例如下

js 复制代码
- container/
  - public/
    - index.html // 模板
  - src/
    - bootstrap.js // 导入products的bootstrap模块,使用mount方法,将html内容挂载到某个DOM节点上
    - index.js  // 入口文件,导入bootstrap.js模块
  - package.json 
  - webpack.config.js // 配置module federation的配置文件

其中,bootstrap.js的内容如下

js 复制代码
// 这里不使用const { mount: mountProducts } = await import("products/Index")的语法
// 是因为模块本身不导入导出任何成员,webpack不认为是ESM,只有ESM才能使用顶层的await语法
// 在这种情况下,使用import ESM语法,那么index.js必须使用import('./bootstrap.js')的动态导入语法,因为远程模块的导入必须是异步的
import { mount as mountProducts } from "products/Index"
// 模板index.html中已经有<div id="prod-products"></div>
mountProducts(document.querySelector("#prod-products"));

相关的module federation配置如下

js 复制代码
  devServer: {
    port: 8080
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "container", // 容器名称
      remotes: { 
        // 指定远程模块依赖
        // 模块别名: '模块别名@模块入口地址',模块别名是要在代码里导入该模块成员时使用
        products: "products@http://localhost:8081/remoteEntry.js", // 模块名称: 模块地址
      }
    }),

container容器应用可以拿到products容器应用的bootstrap模块,使用mount方法来挂载HTML内容了。可以尝试一下,启动8080端口,可以看到页面有一些由products容器应用的bootstrap模块挂载的HTML内容。

构建产物

由于container容器没有对外暴露的模块,因此没有remoteEntry.js这样的入口文件,也没有共享模块,所以container容器的构建产物与传统的构建一致,只有bundle。bundle文件中,有包含products容器的引用和模块加载代码

js 复制代码
/***/ "webpack/container/reference/products":
/*!****************************************************************!*\
  !*** external "products@http://localhost:8081/remoteEntry.js" ***!
  \****************************************************************/
/***/ ((module, __unused_webpack_exports, __webpack_require__) => {

var __webpack_error__ = new Error();
module.exports = new Promise((resolve, reject) => {
	if(typeof products !== "undefined") return resolve();
	__webpack_require__.l("http://localhost:8081/remoteEntry.js", (event) => {
		if(typeof products !== "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__);
	}, "products");
}).then(() => (products));

/***/ })

模块成员标识符规则

模块对外导出的成员应该如何标识?例如,products对外导出的./Index能不能写成'Index'或者'Hello'?

在导入成员时,使用import xxx from 'products/Index',webpack会转换为'./Index'作为模块标识符,因此products对外导出成员时的标识符不能随意写,要按照规则./[name]的形式来书写;

webpack内部使用了一系列映射关系来确定导出成员,如下面代码所示

js 复制代码
        var chunkMapping = {
            /******/ // src/bootstrap.js中导入了products/Index和products/World
            "src_bootstrap_js": [/******/ 
            "webpack/container/remote/products/Index", /******/
            "webpack/container/remote/products/World"/******/
            ]/******/
        };
        /******/
        var idToExternalAndNameMapping = {
            /******/
            "webpack/container/remote/products/Index": [/******/
            "default", /******/
            "./Index", /******/ 导入成员的标识符
            "webpack/container/reference/products"/******/
            ],
            /******/
            "webpack/container/remote/products/World": [/******/
            "default", /******/
            "./World", /******/ 导入成员的标识符
            "webpack/container/reference/products"/******/
            ]/******/
        };

异步模块有异步依赖时使用异步导入还是同步导入

module federation导致的模块拆分,如果是异步模块A依赖了异步模块B,在A中可以同步导入模块Bimport xxx from 'module-B',因为webpack会使用Promise.all来加载模块A和被A依赖的模块B。所以只要使用动态导入`import('module-A')即可,不需要在A中使用动态导入B了。当然,动态导入B模块也是可以的。

总结

module federation是一种支持当前应用在运行时加载其他运行时应用内部模块的技术,在webpack配置时,当前应用需要用remote指定要加载的应用名称, 其他应用使用exposes指定对外暴露的内部模块,使用shared指定公共的共享模块。应用可以各自独立构建,独立部署,只在运行时产生耦合(加载)。各个应用在开发构建时都是独立的,降低了开发构建时的耦合性。

相关推荐
垣宇11 小时前
Vite 和 Webpack 的区别和选择
前端·webpack·node.js
小纯洁w1 天前
Webpack 的 require.context 和 Vite 的 import.meta.glob 的详细介绍和使用
前端·webpack·node.js
海盗强1 天前
Webpack打包优化
前端·webpack·node.js
祈澈菇凉2 天前
如何优化 Webpack 的构建速度?
前端·webpack·node.js
懒羊羊我小弟2 天前
常用 Webpack Plugin 汇总
前端·webpack·npm·node.js·yarn
祈澈菇凉3 天前
Webpack的持久化缓存机制具体是如何实现的?
前端·webpack·gulp
懒羊羊我小弟4 天前
Webpack 基础入门
前端·webpack·rust·node.js·es6
刽子手发艺4 天前
Selenium+OpenCV处理滑块验证问题
opencv·selenium·webpack
懒羊羊我小弟4 天前
常用Webpack Loader汇总介绍
前端·webpack·node.js
真的很上进6 天前
【1.8w字深入解析】从依赖地狱到依赖天堂:pnpm 如何革新前端包管理?
java·前端·vue.js·python·webpack·node.js·reactjs