Module Federationによるマイクロフロントエンド:巨大なReactアプリのための「分割統治」ソリューション

Development tutorial - IT technology blog
Development tutorial - IT technology blog

モノリス・フロントエンドという悪夢

私はかつて、コードベースが5万行を超えるReactプロジェクトを担当していました。当時の感覚は、まさに悪夢そのものでした。HeaderのCSSを1行修正するだけで、Jenkinsのビルドが完了するまで20分も待たなければなりませんでした。さらに悪いことに、Profileページの小さなロジックエラーが原因で、Checkoutページまで巻き添えでダウンしてしまうこともありました。その時、チームの規模が拡大するにつれ、モノリス(単一構成)アーキテクチャが限界に達していることを痛感しました。

マイクロフロントエンドは、マイクロサービスがバックエンドの常識を変えたのと同じように、救世主として登場しました。巨大なソースコードを一つの塊として抱え込むのではなく、独立して動作するモジュールに分割します。各チームは、他のチームの顔色を伺うことなく、自分たちの担当部分を自由に開発、テスト、デプロイできるようになります。

マイクロフロントエンドを実装する3つの一般的な手法

コードを書く前に、適切なツールを選択する必要があります。万能な解決策はなく、現在の課題に対して最も適した解決策があるだけです。

1. iFrames:古き前身

これは最も単純な方法です。各マイクロアプリは、<iframe>タグ内に収められた個別のウェブページです。エラーの隔離性は非常に高いですが、パフォーマンスとSEOに関しては致命的です。postMessageを介したアプリ間のデータ共有も非常に煩雑です。

2. NPMパッケージ:安全だが低速

モジュールをライブラリとしてパッケージ化し、メインアプリにインストールする方法です。この方法はバージョン管理が非常に厳格に行えます。しかし、チームAがボタン一つを更新するたびに、チームB(ホストアプリ)は再インストールと全体の再ビルドを行う必要があります。これでは、私たちが望む独立したデプロイという課題を解決できません。

3. Module Federation:Webpack 5による革命

これは現代的なプロジェクトにとっての「本命」です。Module Federationを使用すると、アプリケーションは実行時(ランタイム)に別のサーバーから動的にコードをロードできます。リモートアプリが変更されても、ホストアプリを再ビルドする必要はありません。最近のプロジェクトでは、この方法により全体のビルド時間を85%削減することができました。

柔軟性の代償:メリットとデメリット

非常に強力なModule Federationですが、魔法の杖ではありません。本番環境で何度か苦い経験をした後、いくつか注意すべき点をまとめました。

  • メリット:
    • リソースの最適化: 両方のアプリがReactを使用している場合、ブラウザは1つのインスタンスのみをロードします。
    • 絶対的な自主性: Dashboardチームは、Profileチームに影響を与えることなく、1日に10回デプロイすることが可能です。
    • 超軽量なバンドルサイズ: ユーザーに5MBのバンドルをダウンロードさせる代わりに、必要なモジュールごとに200KBだけをダウンロードさせることができます。
  • デメリット:
    • 複雑な設定: Webpackの設定は、括弧一つ間違えるだけでアプリケーションが真っ白(ホワイトアウト)になります。
    • CSSの衝突: CSS ModulesやTailwindを使用しない場合、あるアプリのクラスが別のアプリのスタイルを上書きしてしまうことがよくあります。
    • 状態管理: アプリ間でのユーザー情報の同期には、Event Busや薄いグローバルステートの戦略が必要です。

コードを書いてみよう:React + Webpack 5

ここでは、Host(メインページ)とRemote(製品ウィジェット)の2つのアプリがあると仮定します。私の経験則では、モジュールを分割する前にユニットテストを徹底的に書くことをお勧めします。そうしないと、バグがアプリ間を飛び回り、追跡が非常に困難になります。

ステップ1:Remoteアプリ側の設定

remote-app/webpack.config.jsにて、ModuleFederationPluginを使用してコンポーネントを公開(expose)します。

const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const deps = require("./package.json").dependencies;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: "remoteApp",
      filename: "remoteEntry.js",
      exposes: {
        "./ProductWidget": "./src/components/ProductWidget",
      },
      shared: {
        ...deps,
        react: { singleton: true, requiredVersion: deps.react },
        "react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
      },
    }),
  ],
};

注意:singleton: trueは、Reactのインスタンスがバックグラウンドで1つだけ動作することを保証し、Hookの衝突エラーを避けるために必須です。

ステップ2:Hostアプリ側の設定

host-app/webpack.config.jsにて、Remoteからのコードを受け取るように登録します。

new ModuleFederationPlugin({
  name: "hostApp",
  remotes: {
    remoteApp: "remoteApp@http://localhost:3001/remoteEntry.js",
  },
  shared: {
    ...deps,
    react: { singleton: true, requiredVersion: deps.react },
    "react-dom": { singleton: true, requiredVersion: deps["react-dom"] },
  },
}),

ステップ3:コンポーネントをUIに組み込む

コードはネットワーク経由でロードされるため、ロード待ちでアプリケーションがフリーズしないよう、必ずReact.lazyを使用します。

import React, { Suspense } from "react";
const ProductWidget = React.lazy(() => import("remoteApp/ProductWidget"));

const App = () => (
  <div>
    <h1>メイン画面</h1>
    <Suspense fallback={<p>製品モジュールに接続しています...</p>}>
      <ProductWidget />
    </Suspense>
  </div>
);

本番環境への導入における実践的な教訓

localhostで動作させるのは、道のりの50%に過ぎません。実際の環境に導入する際は、以下の点に注意してください。

  1. 動的URL: localhostのURLをハードコードしないでください。環境変数を使用して、StagingまたはProductionの適切なドメインを指すようにします。
  2. Error Boundary: リモートコンポーネントは常にError Boundaryでラップしてください。製品モジュールを保持しているサーバーがダウンしても、メインサイトはクラッシュすることなく他の部分を表示し続ける必要があります。
  3. バージョニング: 新しいコードをアップデートした際のブラウザキャッシュによるエラーを避けるため、remoteEntry.jsのバージョニング戦略を検討してください。

マイクロフロントエンドへの移行は、単なる技術の変更ではなく、チーム開発の考え方の変革です。もしプロジェクトが肥大化し、チーム同士が互いの足を引っ張り始めているなら、Module Federationは現在最も効果的な解決策となるでしょう。

Share: