likes
comments
collection
share

模块联邦(Module Federation)知识梳理

作者站长头像
站长
· 阅读数 12

一. 引言

自webpack5发布以来,推出了很多全新的特性,其中最主要的就如下三点:

① 可持续性缓存--通过cache配置可实现首次构建后一直保存缓存。

② 真正意义上的tree-shaking--让你的打包体积更小,去掉无用的代码。

③ 模块联邦(module federation)--本文需要探讨实战的特性;

二. 模块联邦

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

模块联邦可以在多个 webpack 编译产物之间共享模块、依赖、页面甚至应用,通过全局变量的组合,还可以在不同模块之前进行数据的获取,让跨应用间做到模块共享真正的插拔式的便捷使用。比如a应用如果想使用b应用中table的组件,通过模块联邦可以直接在a中进行import('b/table')非常的方便。

注: 多个 webpack 构建一起工作,从运行时的角度来看,多个构建的模块将表现得像一个巨大的连接模块图。 从开发者的角度来看,模块可以从指定的远程构建中导入,并以最小的限制来使用。

可以理解为一种解决应用集的方案, 每个构建都充当一个容器,也可将其他构建作为容器。通过这种方式,每个构建都能够通过从对应容器中加载模块来访问其他容器暴露出来的模块。

线上 Runtime(运行时) 的效果,让代码直接在项目间利用 CDN 直接共享,不再需要本地安装npm 包、构建再发布了。

简单来说,我可以将自己的模块共享出去,也可以使用其他项目的模块。

这通常被称为微前端,例如我们的single-spa,qiankun,但是模块联邦远不止如此。

三. 由来动机

模块联邦的动机是为了不同开发小组间开发一个或者多个应用。

应用将被划分为更小的应用块,一个应用块,例如头部导航或者侧边栏的前端组件。

每个应用块由不同的小组开发。

应用块需要共享给其他应用块或者容器。

四. 浅析过程

1. 每个应用块都是一个独立的构建,这些构建都将编译成容器。

2. 容器可以被其他应用或者其他容器应用。

3. 一个被引用得容器或称为remote,引用者被称为host,remote暴露模块给host,host则可以使用这些暴露的模块,这些模块被称为remote模块。

模块联邦(Module Federation)知识梳理

五. 底层分析

分为本地模块和远程模块,本地模块即为普通模块,是当前构建的一部分,远程模块不属于当前构建,是运行时从容器(另一个项目)中加载。

加载远程模块被认为是异步操作。当使用远程模块时,这些异步操作将被放置在远程模块和入口之间的下一个 chunk 的加载操作中,如果没有 chunk 加载操作,就不能使用远程模块。

注: chunk就是打包后的代码块。

chunk是webpack打包过程中,一堆module的集合,我们知道webpack的打包是从一个入口文件开始,也可以说是入口模块,入口模块引用这其他模块,模块再引用模,webpack通过引用关系逐个打包模块,这些module就形成了一个chunk。

如果我们有多个入口文件,可能会产出多条打包路径,一条路径就会形成一个Chunk。出了入口entry会产生Chunk。

chunk 的加载操作通常是通过调用 import() 实现的,但也支持像 require.ensurerequire([...]) 之类的旧语法。

容器是由容器入口创建的,该入口暴露了对特定模块的异步访问。暴露的访问分为两个步骤:

  1. 加载模块(异步的)
  2. 执行模块(同步的)

加载模块将在 chunk 加载期间完成,执行模块将在与其他(本地和远程)的模块交错执行期间完成,这样一来,执行顺序不受模块从本地转换为远程或从远程转为本地的影响。

容器可以嵌套使用,容器可以使用来自其他容器的模块。容器之间也可以循环依赖。

六. 应用场景

1. 每个页面单独构建

单页应用的每个页面都是在单独的构建中从容器暴露出来的,主体应用程序(application shell)也是独立构建,会将所有页面作为远程模块来引用。

通过这种方式,可以单独部署每个页面,在更新路由或添加新路由时部署主体应用程序。主体应用程序将常用库定义为共享模块,以避免在页面构建中出现重复。

2. 将组件库作为容器

许多应用程序共享一个通用的组件库,可以将其构建成暴露所有组件的容器。每个应用程序使用来自组件库容器的组件。

可以单独部署对组件库的更改,而不需要重新部署所有应用程序。应用程序自动使用组件库的最新版本。

注: 这可以解决很多问题,比如我一个重量级项目打包起来很慢,我是不是可以使用模块联邦,将项目分成几部分,独立打包独立部署。

或者说,我的组件库是不是可以做成可插拔的效果。

如果有公共平台,我们做二次开发是不是也可以用这个方法来做架构。

3. 动态远程容器

该容器接口支持 getinit 方法。 init 是一个兼容 async 的方法,调用时,只含有一个参数:共享作用域对象(shared scope object)。此对象在远程容器中用作共享作用域,并由 host 提供的模块填充。 可以利用它在运行时动态地将远程容器连接到 host 容器。

init.js

(async () => {
  // 初始化共享作用域(shared scope)用提供的已知此构建和所有远程的模块填充它
  await __webpack_init_sharing__('default');
  const container = window.someContainer; // 或从其他地方获取容器
  // 初始化容器 它可能提供共享模块
  await container.init(__webpack_share_scopes__.default);
  const module = await container.get('./module');
})();

容器尝试提供共享模块,但是如果共享模块已经被使用,则会发出警告,并忽略所提供的共享模块。

你可以通过动态加载的方式,提供一个共享模块的不同版本,从而实现 A/B 测试。(下面会仔细讲解开)

注: 我相信也会有小伙伴和我一样看到这个应用场景时有些迷茫,到底是做什么的,我说下我的理解。 两个字:白板。  (就是腾讯会议中的共享屏幕,可以多人在上面编辑,画画,写字) 共享作用域对象在我的理解也是一样,我们可以在对象中添加,使用一些方法,比如一个地图文件,一个模块去点亮了深圳,另一个模块去点亮了内蒙古,但是点亮深圳的模块还想去点亮内蒙古,我们这边就会提示,这块区域有模块在使用。

七. 依赖处理

我调用了其他模块,但是其他模块需要的依赖我没有啊,怎么会运行呢。

模块联邦可以将一个应用的包应用于另一个应用,同时具备整体应用(这两个应用)一起打包的公共依赖抽取能力。

**试想一下,你有一个组件包通过npm发布后,你的10个业务项目引用这个组件包。当这个组件包更新了版本,你的10个项目想要使用最新功能就必须一一升级版本、编译打包、部署,这很繁琐。但是模块联邦让组件包利用CDN的方式共享给其他项目,这样一来,当你到组件包更新了,你的10个项目中的组件也自然更新了。 是不是超级厉害? **

我们来验证一下模块联邦是如何将一个应用的包应用于另一个应用的。

APP1

src/Home.js:

export default (num) => {
  let str = '<ul>';
  for (let i = 0; i < num; i++) {
    str += `<li>item ${i}</li>`;
  }
  str += '</ul>';
  return str;
};

src/index.js:

import Home from './Home';

/**
 *  需要异步导入
 *  App2 为 remotes 中定义的资源别名
 *  ./Header 为 APP2 exposes 定义的 ./Header
 */

import('App2/Header').then((Header) => {
  const body = document.createElement('div');
  body.appendChild(Header.default());
  document.body.appendChild(body);

  const home = document.createElement('h1');
  home.textContent = '这里是 App1 的 Home 模块:';
  document.body.appendChild(home);
  document.body.innerHTML += Home(5);
});

可以看到我们APP1中引用了APP2的Header模块。

webpack.config.js:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  devServer: {
    port: '3000',
  },
  plugins: [
    new HtmlWebpackPlugin(),

    new ModuleFederationPlugin({
      // 模块联邦名字,提供给其他模块使用
      name: 'app1',
      // 提供给外部访问的资源入口
      filename: 'App1RemoteEntry.js',
      // 引用的外部资源列表
      remotes: {
        /**
         *  App2 引用其他应用模块的资源别名
         *  app2 是 APP2 的模块联邦名字
         *  http://localhost:3001 是 APP2 运行的地址
         *  App2RemoteEntry.js 是 APP2 提供的外部访问的资源名字
         *  可以访问到 APP2 通过 exposes 暴露给外部的资源
         */
        App2: 'app2@http://localhost:3001/App2RemoteEntry.js',
      },
      // 暴露给外部的资源列表
      exposes: {},
      // 共享模块,如lodash
      shared: {},
    }),
  ],
};

APP2

src/Header.js:

export default () => {
  const header = document.createElement('h1');
  header.textContent = '这里是app2 的 Header 模块';
  return header;
};

这是APP1需要的Hearder组件。

src/index.js:

import Header from './Header';

const div = document.createElement('div');
div.appendChild(Header());
document.body.appendChild(div);

webpack.config.js:

const HtmlWebpackPlugin = require('html-webpack-plugin');
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
  mode: 'production',
  entry: './src/index.js',
  devServer: {
    port: '3001',
  },
  plugins: [
    new HtmlWebpackPlugin(),

    new ModuleFederationPlugin({
      // 模块联邦名字,提供给其他模块使用
      name: 'app2',
      // 提供给外部访问的资源入口
      filename: 'App2RemoteEntry.js',
      // 引用的外部资源列表
      remotes: {},
      // 暴露给外部的资源列表
      exposes: {
        /**
         *  ./Header 是让外部应用使用时基于这个路径拼接引用路径,如:App2/Header
         *  ./src/Header.js 是当前应用的要暴露给外部的资源模块路径
         */
        './Header': './src/Header.js',
      },
      // 共享模块,值当前被 exposes 的模块需要使用的共享模块,如lodash
      shared: {},
    }),
  ],
}; 

效果:     

模块联邦(Module Federation)知识梳理

我们期望的效果已经实现了,我们再看下打包的代码。

模块联邦(Module Federation)知识梳理

可以看到 dist/main.js 包含了APP2的Header模块,是通过CDN引入的,而所在的文件是APP2RemoteEntry.js,那这个文件具体是什么呢。

webpack为了让其他应用方便访问,提供了一个统一的访问入口remoteEntry.js ,remoteEntry.js负责维护管理加载一系列共享模块,本身不涉及具体的业务代码,APP2RemoteEntry.js,就是被管理的共享模块。

这其实开辟了一种新的应用形态,即 "中心应用",这个中心应用用于在线动态分发 Runtime 子模块,并不直接提供给用户使用。

八. 原理浅析

Module Federation 主要提供了两个功能:

  1. 应用间组件互用
  2. host 应用和 remote 应用组件的依赖共享

Module Federation

  • webpack 在构建过程中,会以 entry 配置项对应的入口文件为起点,收集整个应用中需要的所有模块,建立模块之间的依赖关系,生成一个模块依赖图。然后再将这个模块依赖图,切分为多个 chunk,输出到 output 配置项指定的位置。

  • Module Federation 最后构建内容,main-chunk 和 async chunk。其中, main-chunk 为入口文件(通常为 index.js) 所在的 chunk,内部包含 runtime 模块、index 入口模块、第三方依赖模块(如 react、react-dom、antd 等)和内部组件模块(如 com-1、com-2 等);async-chunk 为异步 chunk,内部包含需要异步加载(懒加载)的模块。

  • 打包代码中,webpack_require.l 是一个方法,用于加载 async-chunk。webpack_require.l 会根据 async-chunk 对应的 url,通过动态添加 script 的方式,获取 async-chunk 对应的 js 文件,然后执行。

  • 执行入口模块 - index 对应的代码时,1. 如果遇到懒加载模块,通过 webpack_modules.l 方法获取对应的 async-chunk 并执行,然后获取相应的输出。

模块联邦(Module Federation)知识梳理

组件互用的逻辑是怎么实现的呢?

remote 应用生成一个 remoteEntry-chunk 和多个 expose-chunk。 host 应用启动后,通过 remotes 配置项指定的 url,去动态加载 remote 应用的 remoteEntry-chunk 和 expose-chunk,然后执行并渲染 remote 应用的组件。

host 应用和 remote 应用组件的依赖共享

在 一个常见的 webpack 构建如何工作 的构建结果中,我们可以发现多个 webpack 构建之间其实是相互隔离、无法互相访问的。那么 module federation 是如何打破这种隔离的呢? 答案是 sharedScope - 共享作用域。应用启动以后, host 应用和 remote 应用的 remote-chunk 之间会建立一个可共享的 sharedScope,内部包含可共享的依赖。

九. 注意事项

  • webapack和vite用联邦模块能互相分享各自的组件吗?

不能。 vite能引用webpack分享的组件,但webpack不能引用vite分享的组件,vite之间能互相引用。 原因:vite打包出来的chunk,浏览器请求完无法直接解析,而联邦模块说到底就是通过浏览器请求这份chunk,然后解析。 解决办法:​ 1. 使用webpack打包vite项目。 2. 使用插件,浏览器请求完这个chunk后,通过插件去解析。

  • 在使用 Module Federation 时,Host、Remote 必须同时配置 shared,且一致。
  • module federation 是否可以做到与技术栈无关?

与技术栈无关。假设两个应用, host 应用使用 react 技术栈, remote 应用使用 vue 技术栈,host 应用在使用 remote 应用提供的组件时,不能直接使用,需要额外执行 vue.mount('#xxx') 方法,将 remote 组件挂载的指定位置。

  • 共享依赖的版本控制

module federation 在初始化 shareScope 时,会比较 host 应用和 remote 应用之间共享依赖的版本,将 shareScope 中共享依赖的版本更新为较高版本。在加载共享依赖时,如果发现实际需要的版本和 shareScope 中共享依赖的版本不一致时,会根据 share 配置项的不同做相应处理:

  • 如果配置 singleton 为 ture,实际使用 shareScope 中的共享依赖,控制台会打印版本不一致警告;
  • 如果配置 singleton 为 ture,且 strictVersion 为 ture,即需要保证版本必须一致,会抛出异常;
  • 如果配置 singleton 为 false,那么应用不会使用 shareScope 中的共享依赖,而是加载应用自己的依赖;

综上,如果 host 应用和 remote 应用共享依赖的版本可以兼容,可将 singleton 配置为 ture;如果共享依赖版本不兼容,需要将 singleton 配置为 false。

  • 多个应用(超过 2 个) 是否可共用一个 shareScope ?

假设有这么一个场景, 三个应用 - app1、app2、app3, app2 是 app1 的 remote 应用, app3 是 app2 的 remote 应用, 那么他们是否可共用一个 shareScope ? 使用 module federation 功能以后,所有建立联系的应用,共用一个 shareScope。

十. 缺点

模块联邦并不是完美的,目前来看模块联邦只是NPM形式的拓展, 它依然有着它的缺陷。

例如, 模块联邦并没有样式隔离机制, 这意味着, 当主子应用很有可能会互相造成样式污染。

十一. 实践建议

在我们的项目中, 模块联邦是与 external-remotes-plugin 一同使用的。

  1. 在webpack配置文件中, 指定了每个模块对应一个 window 上的一个全局变量名,这些变量名会以特殊前缀开头,避免被其他变量或意外影响。

  2. 在配置中心,维护了每个模块对应的最新的remoteEntry CDN地址,这个文件名包含了打包的hash值, 方便一眼看出加载的版本是否符合预期。

  3. 在主应用BFF层,返回主应用HTML时,会从配置中心读取模块联邦配置,并注入到html中, 这样remoteEntry就伴随着html一同下发,主应用启动时就能加载所有远程模块。

  4. 发布子模块更新或回滚时,只需要发布CDN后在配置中心修改模块对应的CDN地址,页面刷新之后就能指向最新的子模块版本。

  5. remoteEntry和所有子应用的文件,由于文件名中包含当此打包的hash值,所以统一设置有效期很长的强缓存头,避免刷新后重新加载或是发起协商缓存。

  6. 联调时,假如配置中心不支持联调环境独立配置,还可以使用代理劫持remoteEntry。通过匹配CDN地址中的模块名,将remoteEntry劫持到联调版本的remoteEntry地址,达到访问特定子模块版本的目的。

十二. 故障排除

1. Uncaught Error: Shared module is not available for eager consumption

应用程序正急切地执行一个作为全局主机运行的应用程序。有如下选项可供选择:

你可以在模块联邦的高级 API 中将依赖设置为即时依赖,此 API 不会将模块放在异步 chunk 中,而是同步地提供它们。这使得我们在初始块中可以直接使用这些共享模块。但是要注意,由于所有提供的和降级模块是要异步下载的,因此,建议只在应用程序的某个地方提供它,例如 shell。

我们强烈建议使用异步边界(asynchronous boundary)。它将把初始化代码分割成更大的块,以避免任何额外的开销,以提高总体性能。

例如,你的入口看起来是这样的:

index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));

让我们创建 bootstrap.js 文件,并将入口文件的内容放到里面,然后将 bootstrap 引入到入口文件中:

index.js

+ import('./bootstrap');
- import React from 'react';
- import ReactDOM from 'react-dom';
- import App from './App';
- ReactDOM.render(<App />, document.getElementById('root'));

bootstrap.js

+ import React from 'react';
+ import ReactDOM from 'react-dom';
+ import App from './App';
+ ReactDOM.render(<App />, document.getElementById('root'));

这种方法有效,但存在局限性或缺点。

通过 ModuleFederationPlugin 将依赖的 eager 属性设置为 true

webpack.config.js

// ...
new ModuleFederationPlugin({
  shared: {
    ...deps,
    react: {
      eager: true,
    },
  },
});

2. Uncaught Error: Module "./Button" does not exist in container

错误提示中可能不会显示 "./Button",但是信息看起来差不多。这个问题通常会出现在将 webpack beta.16 升级到 webpack beta.17 中。

在 ModuleFederationPlugin 里,更改 exposes:

new ModuleFederationPlugin({
  exposes: {
-   'Button': './src/Button'
+   './Button':'./src/Button'
  }
});

3. Uncaught TypeError: fn is not a function

此处错误可能是丢失了远程容器,请确保在使用前添加它。 如果已为试图使用远程服务器的容器加载了容器,但仍然看到此错误,则需将主机容器的远程容器文件也添加到 HTML 中。

4. 来自多个 remote 的模块之间的冲突

如果你想从不同的 remote 中加载多个模块,建议为你的远程构建设置 output.uniqueName 以避免多个 webpack 运行时之间的冲突。 If you're going to load multiple modules from different remotes, it's advised to set the output.uniqueName option for your remote builds to avoid collisions between multiple webpack runtimes.

十三. github Demo

github.com/module-fede…

里面有很多模块联邦的官方例子,基本上所想到的需求这里面都有实现。

模块联邦(Module Federation)知识梳理

参考文献

参考: www.webpackjs.com/concepts/mo…

参考: juejin.cn/post/704966…