モジュールフェデレーション

動機

複数の個別のビルドが単一のアプリケーションを形成する必要があります。これらの個別のビルドはコンテナのように機能し、ビルド間でコードを公開および消費して、単一の統合されたアプリケーションを作成できます。

これはマイクロフロントエンドとしてよく知られていますが、それだけには限定されません。

低レベルの概念

ローカルモジュールとリモートモジュールを区別します。ローカルモジュールは、現在のビルドの一部である通常のモジュールです。リモートモジュールは、現在のビルドの一部ではなく、実行時にリモートコンテナからロードされるモジュールです。

リモートモジュールのロードは、非同期操作と見なされます。リモートモジュールを使用する場合、これらの非同期操作は、リモートモジュールとエントリポイントの間にある次のチャンクロード操作に配置されます。チャンクロード操作なしでリモートモジュールを使用することはできません。

チャンクロード操作は通常import()呼び出しですが、require.ensurerequire([...])のような古い構成もサポートされています。

コンテナは、特定のモジュールへの非同期アクセスを公開するコンテナエントリを介して作成されます。公開されたアクセスは2つのステップに分かれています。

  1. モジュールのロード(非同期)
  2. モジュールの評価(同期)

ステップ1は、チャンクロード中に行われます。ステップ2は、他の(ローカルおよびリモート)モジュールとインターリーブして、モジュール評価中に行われます。この方法では、ローカルからリモートにモジュールを変換するか、その逆の場合でも、評価順序は影響を受けません。

コンテナをネストすることは可能です。コンテナは他のコンテナのモジュールを使用できます。コンテナ間の循環依存関係も可能です。

高レベルの概念

各ビルドはコンテナとして機能し、他のビルドもコンテナとして消費します。この方法では、各ビルドは、コンテナからロードすることで、公開されている他のモジュールにアクセスできます。

共有モジュールは、オーバーライド可能であり、ネストされたコンテナへのオーバーライドとして提供されるモジュールです。これらは通常、各ビルドで同じモジュール(たとえば、同じライブラリ)を指します。

packageNameオプションを使用すると、requiredVersionを検索するパッケージ名を設定できます。これはデフォルトでモジュールリクエストに対して自動的に推論されます。自動推論を無効にする場合は、requiredVersionfalseに設定します。

構成要素

ContainerPlugin(低レベル)

このプラグインは、指定された公開モジュールを使用して追加のコンテナエントリを作成します。

ContainerReferencePlugin(低レベル)

このプラグインは、コンテナへの特定の参照を外部として追加し、これらのコンテナからリモートモジュールをインポートできるようにします。また、これらのコンテナのoverrideAPIを呼び出して、それらにオーバーライドを提供します。ローカルオーバーライド(ビルドがコンテナでもある場合は、__webpack_override__またはoverrideAPIを介して)と、指定されたオーバーライドが、参照されるすべてのコンテナに提供されます。

ModuleFederationPlugin(高レベル)

ModuleFederationPluginは、ContainerPluginContainerReferencePluginを組み合わせたものです。

コンセプトの目標

  • webpackがサポートするモジュールタイプを公開および消費できる必要があります。
  • チャンクロードは、必要なものをすべて並行してロードする必要があります(Web:サーバーへの1回のラウンドトリップ)。
  • コンシューマーからコンテナへの制御
    • モジュールのオーバーライドは、一方向の操作です。
    • 兄弟コンテナは、互いのモジュールをオーバーライドすることはできません。
  • コンセプトは環境に依存しない必要があります。
    • Web、Node.jsなどで使用できます。
  • 共有の相対要求と絶対要求
    • 使用されていなくても、常に提供されます。
    • config.contextを基準にして解決されます。
    • デフォルトではrequiredVersionを使用しません。
  • 共有のモジュールリクエスト
    • 使用されている場合にのみ提供されます。
    • ビルドで使用されている同じモジュールリクエストすべてと一致します。
    • 一致するすべてのモジュールを提供します。
    • グラフのこの位置にあるpackage.jsonからrequiredVersionを抽出します。
    • ネストされたnode_modulesがある場合は、複数の異なるバージョンを提供および消費できます。
  • 共有の末尾に/が付いたモジュールリクエストは、このプレフィックスが付いたすべてのモジュールリクエストと一致します。

ユースケース

ページごとの個別ビルド

シングルページアプリケーションの各ページは、個別のビルドのコンテナビルドから公開されます。アプリケーションシェルも、すべてのページをリモートモジュールとして参照する個別のビルドです。このようにして、各ページを個別にデプロイできます。ルートが更新されたり、新しいルートが追加されたりすると、アプリケーションシェルがデプロイされます。アプリケーションシェルは、ページビルドでの重複を避けるために、一般的に使用されるライブラリを共有モジュールとして定義します。

コンポーネントライブラリをコンテナとして

多くのアプリケーションは、各コンポーネントが公開されたコンテナとしてビルドできる共通のコンポーネントライブラリを共有しています。各アプリケーションは、コンポーネントライブラリコンテナからコンポーネントを消費します。コンポーネントライブラリへの変更は、すべてのアプリケーションを再デプロイする必要なく、個別にデプロイできます。アプリケーションは、コンポーネントライブラリの最新バージョンを自動的に使用します。

動的なリモートコンテナ

コンテナインターフェースは、get メソッドと init メソッドをサポートしています。initasync 互換のメソッドで、引数として共有スコープオブジェクトを受け取って呼び出されます。このオブジェクトは、リモートコンテナ内で共有スコープとして使用され、ホストから提供されたモジュールで満たされます。これは、リモートコンテナをランタイム時に動的にホストコンテナに接続するために活用できます。

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@http://localhost: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 = 'http://localhost: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を含むオブジェクトを解決する必要があることに注意してください。

動的なパブリックパス

publicPathを設定するためのホストAPIの提供

リモートモジュールからメソッドを公開することで、ホストがランタイム時にリモートモジュールのpublicPathを設定できるようにすることができます。

このアプローチは、独立してデプロイされた子アプリケーションをホストドメインのサブパスにマウントする場合に特に役立ちます。

シナリオ

https://my-host.com/app/*でホストされているホストアプリと、https://foo-app.comでホストされている子アプリがあるとします。子アプリもホストドメインにマウントされているため、https://foo-app.comhttps://my-host.com/app/foo-appを介してアクセスできることが期待され、https://my-host.com/app/foo-app/*のリクエストはプロキシを介してhttps://foo-app.com/*にリダイレクトされます。

webpack.config.js (リモート)

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 (リモート)

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

src/index.js (ホスト)

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 (リモート)

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 (リモート)

// 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として設定できます。これにより、モジュールは非同期チャンクには配置されませんが、同期的に提供されます。これにより、初期チャンクでこれらの共有モジュールを使用できます。ただし、提供されたモジュールとフォールバックモジュールはすべて常にダウンロードされるため、注意が必要です。アプリケーションの1箇所(シェルなど)でのみ提供することをお勧めします。

非同期境界を使用することを強くお勧めします。これにより、大きなチャンクの初期化コードが分割され、追加のラウンドトリップを回避し、全体的なパフォーマンスが向上します。

たとえば、エントリが次のようになっているとします。

index.js

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

bootstrap.jsファイルを作成し、エントリの内容をそれに移動し、そのブートストラップをエントリにインポートします。

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に追加してください。

異なるリモートからのモジュール間の衝突

異なるリモートから複数のモジュールをロードする場合は、複数のwebpackランタイム間の衝突を避けるために、リモートビルドのoutput.uniqueNameオプションを設定することをお勧めします。

10 貢献者

sokrachenxsanEugeneHlushkojamesgeorge007ScriptedAlchemysnitin315XiaofengXie16KyleBastienAlevaleburhanuday