見出し画像

マイクロフロントエンドについて調査したのでまとめてみた[実践編]

はじめに

みなさんこんにちは! ワンキャリアの西川(X :takashi54461358)です!
本記事は、マイクロフロントエンドについて調査で得た知見を共有するシリーズ記事の第3弾になります!

マイクロフロントエンドについての基本的な知識がない方は、ぜひ「マイクロフロントエンドについて調査したのでまとめてみた」の[概念編]と[詳細編]をご覧ください。マイクロフロントエンドの基本的な概念、仕組みや、導入のメリット・デメリットについて解説しています。

第3弾では、マイクロフロントエンドについて調査した後、導入検討のために作成したマイクロフロントエンドアプリケーションの構成と、作成する中で得た知見を中心に解説していきます。
アプリケーションは、マイクロフロントエンド用のライブラリを用いずに垂直分割・クライアントサイド組成パターンで構成しています。マイクロフロントエンドの動作や、マイクロフロントエンド用のライブラリが解決している問題を理解するために、ぜひ参考にしていただければと思います。 
では、実装解説に入ります。



解説

検証アプリケーション構成解説

今回実装したアプリケーションは、下記のような構成になっています。マイクロフロントエンド用のライブラリを用いず、垂直分割・クライアントサイド組成パターンで実装しています。

それぞれのアプリケーションの役割は以下の通りです。

  • ContainerApp:マイクロフロントエンドにおけるコンテナアプリケーション。Reactで実装。

  • Nginx:パスによって、ReactApp、VueApp、NuxtAppへのアクセスを振り分けるリバースプロキシ。

  • ReactApp・VueApp・NuxtApp:マイクロフロントエンドアプリケーションのフラグメントアプリケーション。それぞれ、React、Vue、Nuxt.jsで実装しています。加えて、各フレームワークごとのruntime、iframe、Web Component統合を使った実装を用意しました。

  • ApiServer:バックエンドAPIサーバー。各フラグメントアプリケーションからのリクエストを受け取り、データを返却する。今回の検証用アプリケーションではAPIサーバーを立ち上げていますが、アプリケーションと通信を行っていません。

手元でアプリケーションを動かしたい、あるいは、コードを見たいという方は、下記のリポジトリをご参照ください。
マイクロフロントエンド検証用レポジトリ

この検証用アプリケーションは、以下の項目について検証するために作成しました。基本的に画面上で確認できるようにしています(CookieのみAPIサーバーのコードを書き換えて、ブラウザの検証機能などで値を確認する必要があります)。

  • ローカルストレージにデータを保存し、他のフラグメントアプリケーションから取得する

  • コンテナアプリケーションとフラグメントアプリケーションでStoreが共有されているかを確認する

  • Web Components, iframe, runtime統合の間に挙動の違いがあるかを確認する

  • Cookieにデータを保存し、コンテナアプリケーションと各フラグメントアプリケーションから取得する

  • 各フラグメントアプリケーション間のページ遷移を確認する


検証から得た3つの知見

ここから、今回のアプリケーションを構成する際に得た知見を「グローバルスコープにおける値の競合」「データの共有」「統合パターン固有の問題」の3つのポイントに分けて解説します。

1.グローバルスコープにおける値の競合
windowオブジェクト
今回のアプリケーションでは、Nuxt.jsが作成するwindowオブジェクト同士が競合し、うまく動作しない挙動を確認できました。iframeを除き、全てのフラグメントアプリケーションではwindowオブジェクトが共有されてしまいます。 Nuxt.jsはwindowにアプリデータを挿入しているため、複数のマイクロフロントエンドが同じフレームワークを共有している今回のような場合は変数の衝突が起こる可能性があります。

アセットファイル
マイクロフロントエンドをiframe以外でクライアント統合する場合、各マイクロフロントエンドのエントリーポイント以外のファイル(CSSファイル、画像ファイル、JavaScriptファイルなど)へのアクセスに工夫が必要です。 今回の検証用アプリケーションでは、フラグメントアプリケーションごとにURL上でパスを分け、NginxでURLのパスごとに各フラグメントアプリケーションへと繋げることで、アセットファイルへのアクセスを可能にしました。具体的には下記のようにNginxの設定ファイル、各フラグメントアプリケーションのビルド設定ファイルを変更しました。Nginxで、ReactのフラグメントアプリケーションへのURLパスを http://localhost/react/ と設定し、Reactではビルドの設定でアセットファイルを/react/の下に配置することで、アセットファイルへのアクセスを可能にしました。詳しい設定については、GitHubのリポジトリを参照ください。

// nginx.conf
location /react/ {
    proxy_pass http://react:3001;
    proxy_redirect off;
}


2.データの共有
マイクロフロントエンド内のアプリケーション間で、データを共有したい場合があります。 例えば、認証情報や、ユーザーの設定情報などです。 データの共有について、以下の3つのパターンの動作を検証しました。

  • ウェブストレージ

  • Cookie

  • Store

ウェブストレージとCookieは、iframeを除いて、フラグメントアプリケーション間でデータの共有が可能です。また、iframeの場合でも同一オリジン間であれば、データの共有が可能です。
一方、Storeは各フラグメントアプリケーション間でデータが共有されません。もし、Storeを利用してデータの共有を行いたい場合は、コンテナアプリケーションにStoreを配置し、コンテナアプリケーションのStoreを参照する仕組みを作成する必要があります。 (そもそも、Store使うほどのデータの共有が必要な場合は、マイクロフロントエンドの設計を見直すことを検討したほうが良さそうな気もします)


3.統合パターン固有の問題
マイクロフロントエンドアプリケーションを構成する際には、統合パターンによって、問題が発生する可能性があります。例えば、Web Components、iframe、runtime統合の3つの統合パターンにおいて、それぞれの問題が発生します。

Web Components (Shadow DOM)を利用した統合
Web ComponentsはShadow DOMにより、外部からWeb Components内部へのJavaScriptやCSSのアクセスを防ぎます。 しかし、iframeとは異なり、JavaScriptの実行環境は隔離されていないため、グローバル変数が競合する可能性があります。この問題に対処するために、コンパイル時にJavaScriptのグローバル変数にプレフィックスを付ける設定が必要となります。

// 参考
// https://github.com/takatakunishi/microfrontend/blob/9d6c6ab21100f55fd18fd9a28a972a4c2a17be7d/mfe_react/front/vite.config.runtime.ts#L34

// vite.config.runtime.ts
export default defineConfig({
  ...,
  build: {
    manifest: true,
    rollupOptions: {
        output: {
            ...
            name: `__mfereact-runtime[name]`, // globalのnamespaceが衝突しないようにする
        }
    }
  }
})

また、今回の検証用アプリケーションを作成している際に別の問題にも遭遇しました。 それは、CSSの処理です。 Web Componentsは、CSSもJavaScriptにインライン化させることで、JavaScriptを実行するだけでデザインを含めたUIを構築することができます。 Reactは全てのコンポーネントのCSSをインライン化してくれるため、Web Components化する際にCSSの問題は発生しません。 しかし、Vueは全コンポーネントのCSSをインライン化させることができず、Web Components化した際に子コンポーネントのCSSが反映されません。 この問題に対処するためには、コンパイラの書き換えが必要となります。また、Vue自体、コンポーネントひとつひとつをWeb Components化することを推奨しているため、子要素を含めたWeb Components化は難しいと考えられます。

iframeを利用した統合
iframeは、外部からのアクセスを完全に隔離するため、グローバル変数の競合が発生しません。これはメリットでもありますが、デメリットもあります。下記に今回の実装で分かったiframeのデメリットを記載します。
Host(コンテナ)とRemote(フラグメント)間のデータ連携:
Web storage(localStorage、IndexedDB、CacheStorage)を使用するには同じoriginの設定が必要です。originの設定が異なる場合は、データ共有にはpostMessageを使用する必要があります。

CORS:
同じドメイン内では大きな問題はないですが、Cookie関連の問題が発生する可能性があります。

RemoteとHostのlocationの変化の同期:
対応策として、postMessageやaタグの_parent指定でコンテナのリンクを強制的に変更する方法があります。しかし、親がリロードされるため、Storeのデータが消失し、画面がチラつく問題が発生します。URLのハッシュ値を使用する方法もありますが、通常のページ遷移の挙動とは異なるため非推奨です。
手元では試せていないのですが、postMessageを使用する場合であれば、この問題を解決することもできそうです。RemoteとHostのページ遷移をpostMessageで互いに通知し、HostがHistory API操作して子のrouterはアプリケーション内の遷移を管理します。詳しくは下記の記事をご覧ください。

runtime統合
最後に、runtime統合についても触れておきます。 基本的にはグローバル変数が競合しないように、ビルド時にプレフィックスを付ける設定が必要ですが、この設定をしておけば問題は発生しません。 しかし、Nuxt.jsのようにscriptタグやstyleタグを挿入することで動作するライブラリやフレームワークの場合は、読み込み時にscript、 link、 styleタグをparseして埋め込み直すという処理が必要です。

// 参考 https://github.com/takatakunishi/microfrontend/blob/9d6c6ab21100f55fd18fd9a28a972a4c2a17be7d/mfe_container/front/src/runtime/index.tsx#L55-L100

// Nuxt.jsの場合
function NuxtRuntime() {
    const host = "http://localhost/nuxt-runtime"
    const currentPath = window.location.pathname.replace("/runtime/nuxt", "")
    const sccriptId = `micro-frontend-script-nuxt-runtime`;
    const requestPath = host + currentPath
    fetch(requestPath).then(h => h.text()).then(data => {
        if (document.getElementById("__nuxt")) return
        const runtimeRoot = document.getElementById("nuxt-runtime")
        const parser = new DOMParser();
        const html = parser.parseFromString(data, "text/html");
        const html_nuxt_elements = html.body.querySelector("div")
        const html_nuxt_window_elements = [...html.body.querySelectorAll('script')];

        const fake = [...html_nuxt_window_elements]
        fake.flatMap((h_e, i) => {
            if (!(h_e.id === "") && document.getElementById(h_e.id)) return
            if (document.getElementById(`nuxt-runtime-${i}`)) return
            if (!(h_e.id === "")) {
                document.body.append(h_e)
            } else {
                const script = document.createElement("script");
                script.innerHTML = h_e.textContent || "console.log('hello')"
                script.id = `nuxt-runtime-${i}`
                document.body.append(script)
            }
        })
        runtimeRoot?.appendChild(html_nuxt_elements!)
        const html_elements = html.head.querySelectorAll('style');
        for (let h_e of html_elements) {
            runtimeRoot?.appendChild(h_e)
        }
    }).then(() => {
        if (document.getElementById(sccriptId)) return
        const script = document.createElement("script");
        script.id = sccriptId;
        script.src = "http://localhost/_nuxt/nuxt/bundle.js";
        script.onload = () => {
            console.log("load nuxt runtime js")
        };
        document.head.appendChild(script)
    })
    return (
        <div id="nuxt-runtime">
        </div>
    )
}


おわりに

今回は、マイクロフロントエンドアプリケーションを構成する際に得た知見を共有しました。 気づけば、1つのテーマで3つの記事を書いていました。取り組んだのは半年以上前で、その間にも新しい情報が出ているかもしれません。もし、新しい情報があれば、ぜひ教えていただければと思います。連載記事を執筆するのは初めてだったので、なんとか形にすることができ一安心しています。 また機会があれば、新しいテーマで記事を書いていきたいと思います。それでは、またどこかで。
See you again! Have a nice day!

▼ワンキャリアのエンジニア組織のことを知りたい方はまずこちら

▼カジュアル面談を希望の方はこちら

▼エンジニア求人票


この記事が参加している募集

この記事が気に入ったらサポートをしてみませんか?