背景
私たちの管理システムは3年前に Vue 2 で構築しました。最初は数ページしかなく、快適でした。しかし3年後、それは巨大なモンスターになっていました:
- 50以上のルーティングページ
- 200以上のコンポーネント
- 3チーム、12人が同時に開発
- Webpack のビルドが最低4分
- メインバンドルが3MB以上で、初回表示がどんどん遅くなる
さらに悪いことに、毎回のリリースが全量デプロイでした。受注モジュールで文言を1箇所変えただけで、システム全体を再デプロイする必要がありました。先週、在庫モジュールがバグをリリースしてバックオフィス全体をダウンさせましたが、私たちのユーザーモジュールとは全く関係がなかったのに、全員のチケットシステムが崩壊しました。
チームの誰かが冗談で言いました:「このプロジェクトはもう1人で管理できない。」実は冗談ではありませんでした。
マイクロフロントエンドとは
マイクロフロントエンドの概念は、本質的にバックエンドのマイクロサービスの考え方をフロントエンドに持ち込むことです。
バックエンドはすでにモノリスからマイクロサービスへの分割を経験しました。大きな Java アプリを複数の独立してデプロイ可能なサービスに分割し、それぞれが独自のデータベースと独自のリリースサイクルを持ちます。フロントエンドも今まさにその段階に来ています。
コアコンセプト:
従来のモノリシック SPA:
┌─────────────────────────────────────┐
│ モノリシックフロントエンドアプリ │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ユーザー│ │注文 │ │在庫 │ │
│ └──────┘ └──────┘ └──────┘ │
└─────────────────────────────────────┘
1つのリポジトリ、1つのビルド成果物、1つのデプロイ単位
マイクロフロントエンド:
┌─────────────────────────────────────┐
│ ホストアプリ(シェル) │
│ ┌──────────┐ ┌──────────┐ │
│ │ユーザーApp │ │注文App │ │
│ │独自リポジトリ│ │独自リポジトリ│ │
│ │独自デプロイ │ │独自デプロイ │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────┘
各モジュールが独立して開発・ビルド・デプロイ
各サブアプリは独自の技術スタックと独自のリリースサイクルを持ち、チーム間で互いに干渉しません。
既存アプローチの比較
着手する前に、1週間かけていくつかのアプローチを調査しました。
アプローチ1:iframe
最も古くて直接的な方法。ホストアプリがナビゲーションフレームを提供し、サブアプリは iframe で読み込みます。
<!-- ホストアプリのシェル -->
<div id="layout">
<sidebar-nav />
<main>
<iframe :src="currentAppUrl" frameborder="0"></iframe>
</main>
</div>
分離性は非常に優れています — スタイル、JS、グローバル変数が完全に独立しています。デメリットも明らかです:ルート同期が困難、iframe の境界でモーダルが切り取られる、アプリ間通信は postMessage のみ、iframe のローディング体験が悪い。
アプローチ2:Nginx ルートディスパッチ
異なるパスを異なるフロントエンドアプリのデプロイにルーティング:
location /user/ {
proxy_pass http://user-app-server/;
}
location /order/ {
proxy_pass http://order-app-server/;
}
シンプルですが、パス切り替え時にページ全体がリロードされ、UX が悪化します。共通部分(ナビゲーション、ユーザー情報)を各サブアプリで再実装する必要があります。
アプローチ3:npm パッケージ分割
共通モジュールを npm パッケージとして抽出し、各チームが独自のリポジトリを管理して、最終的にホストアプリで組み立てます。
このアプローチはビルドパイプラインへの変更が最も少ないですが、本質的に独立デプロイの問題を解決しません — どのモジュールが更新されても、ホストアプリは再ビルド・再デプロイが必要です。
アプローチ4:single-spa
最近注目している single-spa というプロジェクトはランタイムフレームワークを提供し、複数のフロントエンドアプリが同じページに共存できるように、ライフサイクル管理でマウントとアンマウントを調整します:
import { registerApplication, start } from "single-spa";
registerApplication(
"user-app",
() => System.import("@org/user-app"),
(location) => location.pathname.startsWith("/user"),
);
registerApplication(
"order-app",
() => System.import("@org/order-app"),
(location) => location.pathname.startsWith("/order"),
);
start();
サブアプリは bootstrap、mount、unmount の3つのライフサイクルフックを公開する必要があります。コンセプトは良いですが、コミュニティはまだ小さく、ドキュメントも不十分で、Vue サポートには追加の適応が必要です。有望な方向性ですが、現時点では本番環境へのリスクは小さくありません。
私たちの試み:iframe PoC
チームの現在の技術力とリスク許容度を考慮して、最も保守的な iframe アプローチで PoC を実施し、ユーザー管理モジュールをメインアプリから分離することにしました。
全体アーキテクチャ:
┌────────────────────────────────────────────┐
│ ホストアプリ シェル(Vue 2) │
│ ┌──────────────────────────────────────┐ │
│ │ トップナビ + サイドバー │ │
│ └──────────────────────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ │ │
│ │ <iframe :src="userAppUrl" /> │ │
│ │ │ │
│ └──────────────────────────────────────┘ │
└────────────────────────────────────────────┘
│ │
▼ ▼
ホストアプリデプロイ ユーザーサブアプリデプロイ
(他の40以上のページ) (Vue 2、独立リポジトリ)
重要な課題はログイン状態の共有でした。私たちのアプローチ:ログイン後、ホストアプリが .company.com ドメインスコープのクッキーにトークンを書き込み、サブアプリがクッキーから読み取ります:
// ホストアプリ:ログイン成功後
document.cookie = `auth_token=${token}; domain=.company.com; path=/`;
// サブアプリ:起動時に読み取り
function getAuthToken() {
const match = document.cookie.match(/auth_token=([^;]+)/);
return match ? match[1] : null;
}
// サブアプリ:リクエスト時にトークンを付与
axios.interceptors.request.use((config) => {
const token = getAuthToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
サブアプリ間は postMessage で通信します:
// ホスト → サブアプリ:ユーザー情報を渡す
iframe.contentWindow.postMessage(
{ type: "USER_INFO", payload: { userId: 123, role: "admin" } },
"https://user.company.com",
);
// サブアプリのリスナー
window.addEventListener("message", (event) => {
if (event.data.type === "USER_INFO") {
store.commit("setUserInfo", event.data.payload);
}
});
// サブアプリ → ホスト:ルートナビゲーションをトリガー
window.parent.postMessage(
{ type: "NAVIGATE", payload: "/order/detail/456" },
"https://admin.company.com",
);
踏んだ落とし穴
PoC 中にいくつかの印象的な問題に遭遇しました。
1. iframe の高さ自動調整
iframe はデフォルトでコンテンツに合わせて伸びません。height: 100% を設定するとダブルスクロールバーが発生します。対策として、サブアプリが postMessage でホストにコンテンツの高さを通知します:
// サブアプリ:コンテンツの高さが変化したときにホストに通知
const observer = new ResizeObserver(() => {
window.parent.postMessage(
{ type: "RESIZE", height: document.body.scrollHeight },
"*",
);
});
observer.observe(document.body);
// ホスト:高さを受け取って iframe のスタイルを設定
window.addEventListener("message", (event) => {
if (event.data.type === "RESIZE") {
iframe.style.height = event.data.height + "px";
}
});
2. モーダルとオーバーレイの切り取り
iframe 内のモーダルやトーストは iframe の境界に制限され、画面全体をカバーできません。これが iframe ベースのマイクロフロントエンドで最も頭を悩ます問題です。妥協策として、モーダルをホストアプリで実装し、サブアプリが postMessage でホストにモーダル表示を依頼します。ただし、これはカップリングを増やします。
3. ブラウザの前進/後退
iframe 内のルート変更はブラウザの履歴に記録されません。ホストとサブアプリのルートを同期させる必要があります — サブアプリがルート変更をホストに通知し、ホストが URL クエリパラメーターを更新し、iframe がクエリパラメーターに基づいてナビゲートします。ロジックが複雑で保守しにくいです。
マイクロフロントエンドはいつ使うべきか
これだけの落とし穴があっても、マイクロフロントエンドは価値がないのでしょうか?そうではありません。ただし、すべてのプロジェクトに適しているわけでもありません。
適したシナリオ:
- 複数チームが大型フロントエンドアプリを保守していて、コラボレーションの衝突が頻繁
- 異なるモジュールが独立してリリースする必要があり、デプロイリスクを低減したい
- レガシーシステムを段階的に移行したい(例:jQuery から Vue へ)
- モジュール間の境界が明確で、クロスモジュールのやり取りが比較的シンプル
適さないシナリオ:
- プロジェクトが小さく、2〜3人で管理できる — 過剰設計はしない
- モジュール間に複雑な連携がある — 分割後の通信コストが高くなる
- 複数のリポジトリと複数のデプロイパイプラインをサポートする DevOps 基盤がない
つまり、マイクロフロントエンドが解決するのはチームコラボレーションと独立デリバリーという組織的な問題であり、純粋な技術的問題ではありません。モノリスに「首を絞められていない」チームは、急いでマイクロフロントエンドを導入する必要はありません。
まとめ
- マイクロフロントエンドはバックエンドのマイクロサービスの考え方をフロントエンドに持ち込み、大規模アプリの各モジュールを独立して開発・ビルド・デプロイできるようにする
- 2018年の主要アプローチ:iframe、Nginx ルートディスパッチ、npm パッケージ分割、JS ランタイム統合
- iframe アプローチは最もシンプルで分離性も最高だが、ルート同期・モーダル切り取り・通信コストが課題
- single-spa は有望なランタイム統合フレームワークだが、まだ成熟していない — 注目する価値はあるが採用は慎重に
- マイクロフロントエンドは本質的にチームコラボレーションと独立デリバリーの問題を解決する。小さなプロジェクトには不要
- 導入を決めた場合は、境界が明確な1つのモジュールから PoC を始め、最初から全量分割しない