模块联邦

动机

多个独立构建应该形成一个单一的应用程序。这些独立的构建充当容器,可以在彼此之间暴露和消费代码,从而创建一个单一的、统一的应用程序。

这通常被称为微前端(Micro-Frontends),但并不局限于此。

低级概念

我们区分本地模块和远程模块。本地模块是当前构建的常规模块。远程模块是不属于当前构建但在运行时从远程容器加载的模块。

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

分块加载操作通常是一个 import() 调用,但也支持 require.ensurerequire([...]) 等旧的构造。

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

  1. 加载模块(异步)
  2. 评估模块(同步)。

步骤1将在分块加载期间完成。步骤2将在模块评估期间与其它(本地和远程)模块交错进行。这样,评估顺序不会因模块从本地转换为远程或反之而受到影响。

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

高级概念

每个构建都充当一个容器,并消费其他构建作为容器。通过这种方式,每个构建都能够通过从其容器加载任何其他暴露的模块来访问它们。

共享模块是既可被覆盖又作为覆盖提供给嵌套容器的模块。它们通常指向每个构建中的同一个模块,例如,同一个库。

packageName 选项允许设置包名以查找 requiredVersion。默认情况下,它会自动为模块请求推断,当需要禁用自动推断时,将 requiredVersion 设置为 false

构建块

ContainerPlugin(低级)

此插件使用指定的暴露模块创建一个额外的容器入口。

ContainerReferencePlugin(低级)

此插件将对容器的特定引用添加为外部依赖,并允许从这些容器导入远程模块。它还调用这些容器的 override API 为其提供覆盖。本地覆盖(通过 __webpack_override__ 或当构建也是容器时的 override API)和指定的覆盖都将提供给所有引用的容器。

ModuleFederationPlugin(高级)

ModuleFederationPlugin 结合了 ContainerPluginContainerReferencePlugin

概念目标

  • 应该可以暴露和消费 webpack 支持的任何模块类型。
  • 分块加载应并行加载所有必需的内容(web:单次往返服务器)。
  • 从消费者到容器的控制
    • 模块覆盖是单向操作。
    • 同级容器不能覆盖彼此的模块。
  • 概念应该与环境无关。
    • 可在 web、Node.js 等环境中使用。
  • 共享中的相对和绝对请求
    • 即使未使用,也始终会提供。
    • 将相对于 config.context 解析。
    • 默认不使用 requiredVersion
  • 共享中的模块请求
    • 仅在使用时提供。
    • 将匹配构建中所有已使用的相等模块请求。
    • 将提供所有匹配的模块。
    • 将从图中此位置的 package.json 中提取 requiredVersion
    • 当您有嵌套的 node_modules 时,可以提供和消费多个不同版本。
  • 共享中带有尾随 / 的模块请求将匹配所有以此前缀开头的模块请求。

用例

每页独立构建

单页应用程序的每个页面都从容器构建中暴露在一个单独的构建中。应用程序外壳也是一个独立的构建,将所有页面作为远程模块引用。这样,每个页面都可以单独部署。当路由更新或添加新路由时,应用程序外壳会进行部署。应用程序外壳将常用库定义为共享模块,以避免在页面构建中重复。

组件库作为容器

许多应用程序共享一个公共组件库,该库可以构建为容器,并暴露每个组件。每个应用程序都从组件库容器中消费组件。对组件库的更改可以单独部署,而无需重新部署所有应用程序。应用程序会自动使用组件库的最新版本。

动态远程容器

容器接口支持 getinit 方法。init 是一个与 async 兼容的方法,它带有一个参数:共享作用域对象。此对象在远程容器中用作共享作用域,并填充来自主机的提供的模块。可以利用它在运行时将远程容器动态连接到主机容器。

init.js

(async () => {
  // Initializes the shared scope. Fills it with known provided modules from this build and all remotes
  await __webpack_init_sharing__('default');
  const container = window.someContainer; // or get the container somewhere else
  // Initialize the container, it may provide shared modules
  await container.init(__webpack_share_scopes__.default);
  const module = await container.get('./module');
})();

容器会尝试提供共享模块,但如果共享模块已被使用,则会发出警告并忽略所提供的共享模块。容器可能仍将其用作回退。

通过这种方式,您可以动态加载一个 A/B 测试,它提供不同版本的共享模块。

示例

init.js

function loadComponent(scope, module) {
  return async () => {
    // Initializes the shared scope. Fills it with known provided modules from this build and all remotes
    await __webpack_init_sharing__('default');
    const container = window[scope]; // or get the container somewhere else
    // Initialize the container, it may provide shared modules
    await container.init(__webpack_share_scopes__.default);
    const factory = await window[scope].get(module);
    const Module = factory();
    return Module;
  };
}

loadComponent('abtests', 'test123');

查看完整实现

基于 Promise 的动态远程模块

通常,远程模块使用 URL 进行配置,如本例所示

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: 'app1@https://:3001/remoteEntry.js',
      },
    }),
  ],
};

但您也可以向此远程模块传入一个 promise,它将在运行时解析。您应该使用任何符合上述 get/init 接口的模块来解析此 promise。例如,如果您想通过查询参数传入应使用哪个版本的联邦模块,您可以执行以下操作

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        app1: `promise new Promise(resolve => {
      const urlParams = new URLSearchParams(window.location.search)
      const version = urlParams.get('app1VersionParam')
      // This part depends on how you plan on hosting and versioning your federated modules
      const remoteUrlWithVersion = 'https://:3001/' + version + '/remoteEntry.js'
      const script = document.createElement('script')
      script.src = remoteUrlWithVersion
      script.onload = () => {
        // the injected script has loaded and is available on window
        // we can now resolve this Promise
        const proxy = {
          get: (request) => window.app1.get(request),
          init: (...arg) => {
            try {
              return window.app1.init(...arg)
            } catch(e) {
              console.log('remote container already initialized')
            }
          }
        }
        resolve(proxy)
      }
      // inject this script with the src set to the versioned remoteEntry.js
      document.head.appendChild(script);
    })
    `,
      },
      // ...
    }),
  ],
};

请注意,使用此 API 时,您必须解析一个包含 get/init API 的对象。

动态公共路径

提供一个主机 API 来设置 publicPath

可以通过从远程模块暴露一个方法,允许主机在运行时设置远程模块的 publicPath。

当您将独立部署的子应用程序挂载到主机域的子路径上时,这种方法特别有用。

场景

您有一个托管在 https://my-host.com/app/* 的主机应用程序和一个托管在 https://foo-app.com 的子应用程序。子应用程序也挂载在主机域上,因此,https://foo-app.com 预计可以通过 https://my-host.com/app/foo-app 访问,并且 https://my-host.com/app/foo-app/* 请求通过代理重定向到 https://foo-app.com/*

示例

webpack.config.js (remote)

module.exports = {
  entry: {
    remote: './public-path',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote', // this name needs to match with the entry name
      exposes: ['./public-path'],
      // ...
    }),
  ],
};

public-path.js (remote)

export function set(value) {
  __webpack_public_path__ = value;
}

src/index.js (host)

const publicPath = await import('remote/public-path');
publicPath.set('/your-public-path');

//bootstrap app  e.g. import('./bootstrap.js')

从脚本推断 publicPath

可以从脚本标签的 document.currentScript.src 推断 publicPath,并在运行时使用 __webpack_public_path__ 模块变量设置它。

示例

webpack.config.js (remote)

module.exports = {
  entry: {
    remote: './setup-public-path',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'remote', // this name needs to match with the entry name
      // ...
    }),
  ],
};

setup-public-path.js (remote)

// derive the publicPath with your own logic and set it with the __webpack_public_path__ API
__webpack_public_path__ = document.currentScript.src + '/../';

故障排除

Uncaught Error: Shared module is not available for eager consumption

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

您可以在模块联邦的高级 API 中将依赖设置为 eager,这不会将模块放入异步分块中,而是同步提供它们。这使我们能够在初始分块中使用这些共享模块。但请注意,所有提供的和回退的模块将始终被下载。建议只在应用程序的某个点(例如外壳)提供它。

我们强烈建议使用异步边界。它将把一个较大分块的初始化代码拆分出来,以避免任何额外的往返请求并全面提高性能。

例如,您的入口如下所示

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,
    },
  },
});

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'
  }
});

Uncaught TypeError: fn is not a function

您可能缺少远程容器,请确保已添加。如果您已加载要消费的远程容器,但仍看到此错误,请也将主机容器的远程容器文件添加到 HTML 中。

来自不同远程模块的冲突

如果您要从不同的远程加载多个模块,建议为您的远程构建设置 output.uniqueName 选项,以避免多个 webpack 运行时之间的冲突。

11 贡献者

sokrachenxsanEugeneHlushkojamesgeorge007ScriptedAlchemysnitin315XiaofengXie16KyleBastienAlevaleburhanudayRexSkz