こんにちは!フロントエンド開発の現場で、日々ビルドやパッケージ管理の最適化に頭を悩ませていませんか?
「複数のマイクロフロントエンドアプリを結合したら、なぜかReactのフックでエラーが起きた…」
「別チームが管理するコンポーネントを読み込んだら、バージョンの不一致でバンドルサイズが巨大化した…」
そんな絶望的な状況に直面したことはありませんか?今回は、Webpackの真骨頂である Module Federation において、複数チームの大規模開発で確実に問題になる「バージョン競合とランタイムエラーの回避戦略」を、現場の知見を交えて徹底的に解説します。
これをマスターすれば、バラバラのチームが独立してデプロイしながらも、ブラウザのランタイム上で完璧に依存関係調停を行い、無駄な重複ダウンロードを防ぐ洗練されたアーキテクトになれますよ。毎日の開発が劇的に楽になる感覚を、一緒に味わっていきましょう!
—
そもそも Module Federation とは?なぜバージョン競合が起きるのか
Module Federation(モジュールフェデレーション)は、Webpack 5で導入された革命的な機能です。これまでは1つのアプリケーションとして一体でビルドしていたものを、「完全に独立してビルド・デプロイされた複数のSPA(シングルページアプリケーション)同士が、ブラウザのランタイム上でリアルタイムにJavaScriptのコードを読み込み合う」 ことが可能になります。
例えば、以下のような構成を考えてみてください。
- Host(ホストアプリ): 全体のレイアウトやルーターを管理する親アプリ
- Remote(リモートアプリA): 「商品詳細機能」を担当する子アプリ
- Remote(リモートアプリB): 「カート機能」を担当する子アプリ
ここで問題になるのが 「依存関係(dependencies)の重複」 です。HostもRemote AもRemote Bも、共通して `react` や `react-dom` を使いますよね。もし、それぞれが勝手に `react` の異なるバージョンをバンドルしてしまったらどうなるでしょうか?
1. ブラウザに巨大な `react` が3つもロードされ、ネットワーク帯域とメモリを無駄に食う。
2. さらに最悪なことに、Reactの内部状態(Contextなど)がインスタンスごとに分離してしまい、「Invalid hook call」などの致命的なランタイムエラーを引き起こします。
この地獄を回避するのが、Webpackの `shared` オプションによるバージョン制御です。
—
基礎セットアップ:安全な Module Federation の土台を作る
理屈はこれくらいにして、実際に動く最小限の環境を作りながら、その仕組みを体得していきましょう。今回はシンプルに、Host(親) と Remote(子) の2つのプロジェクトを想定し、Webpackの設定を紐解きます。
1. 最小構成のディレクトリ構造
今回は、モノレポまたは別々のディレクトリにあると仮定し、Host側の設定にフォーカスします。まずは、最も堅牢な `webpack.config.js` の設定を見てください。
2. ホスト側(Host)の `webpack.config.js`
const HtmlWebpackPlugin = require(‘html-webpack-plugin’);
const { ModuleFederationPlugin } = require(‘webpack’).container;
const path = require(‘path’);
module.exports = {
mode: ‘development’,
entry: ‘./src/index.js’,
output: {
publicPath: ‘http://localhost:3000/’, // ホスト自体の配信オリジン
clean: true,
},
devServer: {
port: 3000,
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: ‘babel-loader’,
exclude: /node_modules/,
options: {
presets: [‘@babel/preset-react’],
},
},
],
},
plugins: [
new ModuleFederationPlugin({
name: ‘host_app’,
// リモート側から読み込むモジュールの定義
remotes: {
remoteApp: ‘remote_app@http://localhost:3001/remoteEntry.js’,
},
// 超重要:共有パッケージの定義
shared: {
react: {
singleton: true, // ブラウザ上で単一のインスタンスのみを強制する
strictVersion: true, // 指定バージョンが一致しない場合にビルドエラー/警告を発生させる
requiredVersion: ‘^18.2.0’, // 許容するバージョンの範囲
},
‘react-dom’: {
singleton: true,
strictVersion: true,
requiredVersion: ‘^18.2.0’,
},
},
}),
new HtmlWebpackPlugin({
template: ‘./public/index.html’,
}),
],
};
ここがプロのこだわりポイント!設定の急所解説
- `singleton: true`:
これが最大の命綱です。複数のマイクロフロントエンドが同じパッケージを要求した際、ブラウザのメモリ上に「たった1つのインスタンス」だけをロードし、それを共有させます。これにより、ReactのContextやHooksの多重インスタンス化によるバグを根絶します。
- `strictVersion: true`:
「バージョンが少しぐらい違っても動くやろ」という甘い期待を断ち切る設定です。要求されたバージョン(`requiredVersion`)と、実際に提供されるバージョンのセマンティックバージョニング(SemVer)が一致しない場合、ランタイムで警告を出したり、フォールバック挙動を厳密にコントロールします。
- `requiredVersion`:
チーム間で「我が社のマイクロフロントエンド群は、React 18.2系をベースにする」という合意をコードレベルで強制力を持たせます。
—
精度高い HelloWorld 的な動作確認:依存関係の衝突をシミュレートする
それでは、この設定がどのように機能しているのか、ローカル環境での動作確認をしてみましょう。
Step 1: リモート側の用意
別のポート(3001)で動くRemoteアプリ側でも、同様に `shared` セクションで `react` を共有設定しておきます。
// remote側の webpack.config.js の抜粋
plugins: [
new ModuleFederationPlugin({
name: ‘remote_app’,
filename: ‘remoteEntry.js’,
exposes: {
‘./Button’: ‘./src/Button’,
},
shared: {
react: { singleton: true, requiredVersion: ‘^18.2.0’ },
‘react-dom’: { singleton: true, requiredVersion: ‘^18.2.0’ },
},
}),
]
Step 2: ホスト側からリモートコンポーネントを呼び出す
ホスト側のコード(`src/App.jsx`)で、リモートアプリのコンポーネントを非同期インポート(Dynamic Import)します。
import React, { Suspense } from ‘react’;
// Module Federation経由でリモートのコンポーネントを遅延ロード
const RemoteButton = React.lazy(() => import(‘remoteApp/Button’));
const App = () => {
return (
メインホストアプリケーション (Port: 3000)
以下のボタンは、別サーバー(Port: 3001)から動的に読み込まれています。
}>