🧩

マイクロフロントエンドフレームワーク single-spa を使って異なるUIフレームワークを共存させる : その2

に公開

こんにちは、ebi_yuです。

今回は、2つの記事に分けて 「マイクロフロントエンドアーキテクチャ」「single-spaを使ったマイクロフロントエンドアーキテクチャの実装方法」 について解説していきます。

この記事ではその2として 「single-spaを使ったマイクロフロントエンドアーキテクチャの実装方法」 について解説します。

前回の記事のおさらい

前回の記事では、マイクロフロントエンドアーキテクチャとsingle-spaの概要 について解説しました。

  • マイクロフロントエンドアーキテクチャとはフロントエンドアプリケーションを複数の「UIコンポーネントやロジックの単位の小さなアプリケーション」に分割して開発・運用するアーキテクチャです。
  • マイクロフロントエンドアーキテクチャを採用することで 「異なるUIフレームワークの共存」「UIコンポーネント単位での責任分離」「UIコンポーネントの再利用」 などのメリットがあります。
  • single-spaは 「マイクロフロントエンドアーキテクチャを実現するためのJavaScriptフレームワーク」 であり、「ルートアプリケーション(root-config)」とルートアプリケーションに読み込まれる「子アプリケーション(microfrontend)」 の2つの要素で構成されます。
  • 子アプリケーション(microfrontend)には UIコンポーネント単位で分割した「Application」「Parcel」 と、ロジック単位で分割したUIを持たない「Utility」 があります。

https://single-spa.js.org/

single-spaの構成要素の詳細

ここからは実際に実装例を交えながら、single-spaの構成要素について詳しく解説していきます。
以下のリポジトリに実装例を公開していますので、ぜひ参考にしてください。

https://github.com/ebi-yu/single-spa-microfrontends-sample

ルートアプリケーション(root-config)

ルートアプリケーション(root-config)は、single-spaで管理したいマイクロフロントエンドアプリケーションを起動するためのルート構成です。
ルートHTMLファイルとスクリプトファイルで構成されます。

主な役割

  • single-spaにマイクロフロントエンドを登録する
  • single-spaライブラリを起動する

ルート HTML

ルートHTMLは、single-spaのエントリーポイントとなるHTMLファイルです。
主に 「SystemJS Import Map による子アプリケーション(microfrontend)や共通依存ライブラリの読み込み」「スクリプトファイルの読み込み」 を行います。

<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Single-SPA</title>
    <!-- 1. SystemJSライブラリのインポート -->
    <script src="https://unpkg.com/systemjs/dist/system.min.js"></script>
    <!--2. Import Mapによる依存関係の読み込み  -->
    <script type="systemjs-importmap">
      {
        "imports": {
          "single-spa": "https://unpkg.com/single-spa@6.0.3/lib/es5/system/single-spa.min.js",
          "vue": "https://cdn.jsdelivr.net/npm/vue@3.5.21/dist/vue.global.min.js",
          "react": "https://unpkg.com/react@18/umd/react.development.js",
          "react-dom": "https://unpkg.com/react-dom@18/umd/react-dom.development.js",
          "react-app": "http://localhost:4174/assets/0.0.2-main.js",
          "vue-app": "http://localhost:4173/assets/0.0.2-main.js",
        }
      }
    </script>
  </head>

  <body>
    <!-- 3. スクリプトファイルの呼び出し  -->
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

1. SystemJSライブラリのインポート

SystemJSは JavaScriptモジュールを動的にインポートするためのライブラリ です。
single-spaではSystemJSを使用することが推奨されています。
ここでは、CDNからSystemJSライブラリをインポートしています。

2. Import Mapによる依存関係の読み込み

Import Mapは モジュールの名前とURLのマッピングを定義するための仕組み です。
Import Mapを使用することで、モジュールの名前を指定してJavaScriptモジュールをインポートできるようになります。
ここでは single-spaライブラリ共通依存ライブラリ(Vue.js/React)子アプリケーション(microfrontend) のURLをインポートマップとして定義しています。

3. スクリプトファイルの呼び出し

実際ににsingle-spaを起動するためのスクリプトファイルを呼び出します。

スクリプトファイル

スクリプトファイルでは 「マイクロフロントエンドアプリケーションの登録」「single-spaの起動」 を行います。

import { registerApplication, start } from "single-spa";

// Vueをグローバルに利用できるようにする
System.import("vue").then(() => {
  System.set(System.resolve("vue"), window.Vue);
});

// 1. 子アプリケーション(microfrontend)の登録

/**
 * 子アプリケーション(microfrontend)の登録を行う
 * @params
 * name : アプリケーションの名前
 * app : 登録したい子アプリケーション(microfrontend)の本体
 * activeWhen : 子アプリケーション(microfrontend)がどのURLにアクセスした時に起動するかを指定する
 * customProps : 子アプリケーション(microfrontend)に渡したい任意のプロパティ
 */
registerApplication<Record<string, any>>({
  name: "vue-app",
  app: async () => System.import("vue-app"),
  activeWhen: ["/vue"],
  customProps: {
    message: "Hello from Single-SPA Vue app!",
  },
});
registerApplication<Record<string, any>>({
  name: "react-app",
  app: async () => System.import("react-app"),
  activeWhen: ["/react"],
  customProps: {
    message: "Hello from React app!",
  },
});

// 2. single-spaの起動

/**
 * single-spaを起動する
 * @params
 * urlRerouteOnly : デフォルトでtrue。trueに設定すると、クライアント側のルートが変更されない限り、history.pushState()およびの呼び出しhistory.replaceState()はシングルSPAリルートをトリガーしません。
 */
start({ urlRerouteOnly: true });

1. 子アプリケーション(microfrontend)の登録

single-spaライブラリのregisterApplicationを呼んで、ルートHTMLで定義した子アプリケーション(microfrontend)をsingle-spaに登録します。
registerApplicationには以下のパラメータを指定します。

  • name : アプリケーションの名前
  • app : 登録したい子アプリケーション(microfrontend)の本体
  • activeWhen : 子アプリケーション(microfrontend)がどのURLにアクセスした時に起動するかを指定
    • 例: ["/vue"]と指定した場合、URLがhttps://${domain}/vueにアクセスした時に起動
  • customProps : 子アプリケーション(microfrontend)に渡したい任意のプロパティ

2. single-spaの起動

single-spaライブラリのstartを呼んで、single-spaを起動します。

子アプリケーション(microfrontend)

子アプリケーション(microfrontend)は、ルートアプリケーション(root-config)に読み込まれる小さなアプリケーションです。子アプリケーション(microfrontend)は静的なJavaScriptモジュールとしてホスティングされ、ルートアプリケーションにおいて読み込まれます。

子アプリケーション(microfrontend)には UIコンポーネント単位で分割した「Application」「Parcel」ロジック単位で分割したUIを持たない「Utility」 があります。

Utilityは通常のライブラリやモジュールとして使用でき、single-spaに登録する必要がありません。

ここではApplication/Parcelの実装方法 について解説します。

Application/Parcel

ApplicationとParcelはUIコンポーネントを持つmicrofrontendです
「ApplicationがURLへのアクセスによって起動が制御される」 のに対し、「Parcelは親アプリケーションから呼び出されることで起動」 します。
ApplicationとParcelの実装方法に違いはなく、single-spaにApplicationとして登録するか、Parcelとして登録するによって、振る舞いが変わります。

single-spa公式ではVueやReactで作られたUIコンポーネントをApplication/Parcel化するライブラリが公開されているので、それらを使用した実装例を紹介します。

Application/Parcelの条件

  • ライフサイクル関数の公開 : 一連のライフサイクル関数を公開する必要があります。
    • bootstrap : 初期化にフックして呼び出される
    • mount : マウントにフックして呼び出される
    • unmount : アンマウントにフックして呼び出される
    • update : (非必須)アップデートにフックして呼び出される
  • 親アプリケーションからのプロパティ受け取り : ルートアプリケーション(root-config)から渡されたプロパティを受け取れるようにする必要があります。
    • mountParcel : Parcelをマウントするための関数
    • customProps : ルートアプリケーション(root-config)から渡された任意のプロパティ

VueコンポーネントをApplication/Parcel化する

single-spa-vueを使用して、VueコンポーネントをApplication/Parcel化します。
基本的には通常のVueアプリケーションの実装と同じですが、起動エントリーポイント(main.ts)における実装が少し異なります。

main.ts

import "./assets/base.css";
import "./assets/main.css";

import singleSpaVue, { type AppOptions } from "single-spa-vue";
import { createApp, h } from "vue";
import App from "./App.vue";

// 1. singleSpaVue()の呼び出し

// singleSpaVueでApp.vueインスタンスをラップする。
const vueLifecycles = singleSpaVue({
  createApp,
  appOptions: {
    render(this: AppOptions) {
      return h(App, {
        mountParcel: this.mountParcel,
        // カスタムのプロパティを渡す
        message: this.message,
      });
    },
  },
  handleInstance: () => {
    // vueアプリの通常の設定と一緒
  },
});

// 2. ライフサイクル関数を定義する

export const bootstrap = vueLifecycles.bootstrap; // 初期化時
export const mount = vueLifecycles.mount; // マウント時
export const unmount = vueLifecycles.unmount; // アンマウント時
export const update = vueLifecycles.update; // アップデート時

1. singleSpaVue()の呼び出し

singleSpaVue() を呼び出して、Vueアプリケーションをmicrofrontendとしてラップします。
singleSpaVue()には以下のパラメータを指定します。

  • createApp : Vueアプリケーションを作成するための関数
  • appOptions : Vueアプリケーションのオプション
    • render : Vueアプリケーションのレンダリング関数
      • mountParcel : Parcelをマウントするための関数
      • customProps : ルートアプリケーション(root-config)から渡された任意のプロパティ
  • handleInstance : Vueアプリケーションのインスタンスを処理するための関数。vue-routerの登録など、通常のVueアプリケーションの設定を行う

2. ライフサイクル関数の外部公開

singleSpaVue()の戻り値であるvueLifecyclesから、ライフサイクル関数を外部に公開します。

  • bootstrap : 初期化にフックして呼び出される
  • mount : マウントにフックして呼び出される
  • unmount : アンマウントにフックして呼び出される
  • update : (非必須)アップデートにフックして呼び出される

3. Vueコンポーネント内からプロパティを受け取る

App.vueにおいてsingle-spaで渡されたプロパティを受け取るには、definePropsを使用します。

<template>
  <div id="vue-app">
    <header>
      <img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
      <div class="wrapper">
        <HelloWorld msg="You did it!" />
        <!-- root-configからのメッセージを表示 -->
        <p v-if="props.message" class="root-message">{{ props.message }}</p>
      </div>
    </header>
  </div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import HelloWorld from "./components/HelloWorld.vue";

// propsでsingle-spaから渡されたプロパティを受け取る
const props = defineProps<{
  mountParcel?: (config: any, props: any) => any;
  message?: string;
}>();
</script>

ReactコンポーネントをApplication/Parcel化する

single-spa-reactを使用して、ReactコンポーネントをApplication/Parcel化します。
基本的には通常のReactアプリケーションの実装と同じですが、起動エントリーポイント(main.tsx)における実装が少し異なります。

import React from "react";
import ReactDOMClient from "react-dom/client";
import singleSpaReact from "single-spa-react";
import App from "./App";
import "./index.css";

export const { bootstrap, mount, unmount } = singleSpaReact({
  React,
  ReactDOMClient,
  rootComponent: App,
  errorBoundary() {
    // https://reactjs.org/docs/error-boundaries.html
    return <div>This renders when a catastrophic error occurs</div>;
  },
});

1. singleSpaReact()の呼び出し

singleSpaReact() を呼び出して、Reactアプリケーションをmicrofrontendとしてラップします。

2. ライフサイクル関数の外部公開

singleSpaReact()の戻り値から、ライフサイクル関数を外部に公開します。

  • bootstrap : 初期化にフックして呼び出される
  • mount : マウントにフックして呼び出される
  • unmount : アンマウントにフックして呼び出される
  • update : (非必須)アップデートにフックして呼び出される

3. Reactコンポーネント内からプロパティを受け取る

App.tsxからsingle-spaで渡されたプロパティを受け取るには、propsを使用します。

// single-spaから渡されたプロパティを受け取る
function App(props: { mountParcel?: (config: any, props: any) => any; message?: string }) {
    return (
    <>
      <div id="react-app">
        <h1>Vite + React</h1>
        {props.message && <p className="root-message">{props.message}</p>}
      </div>
    </>
  );
}

Parcelに親子関係を持たせる

Application/Parcel内のコードからsingle-spaにParcelを登録することで、親子関係を持たせることができます。
親子関係を持たせると、親アプリケーションがマウントされた時に子アプリケーションもマウントされ、親アプリケーションがアンマウントされた時に子アプリケーションもアンマウントされます。

子のParcelを登録するにはpropsとして渡されたmountParcelを使用します。

Vueコンポーネント内からParcelをマウントする

<template>
   <!-- 1. Parcelをマウントするための領域を用意する -->
    <div id="react-app-container">
      <h3>React App (mounted via mountParcel):</h3>
    </div>
  </div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import HelloWorld from "./components/HelloWorld.vue";
import TheWelcome from "./components/TheWelcome.vue";

// 2. propsでmountParcel関数を受け取る
const props = defineProps<{
  mountParcel?: (config: any, props: any) => any;
  message?: string;
}>();

const reactParcel = ref<any>(null);

onMounted(async () => {
  try {
    // 3. parcelの本体をSystemJSで読み込む
    const reactAppModule = await System.import("react-app");
    const domElement = document.getElementById("react-app-container");
    if (domElement && reactAppModule && props.mountParcel) {
      // 4. mountParcel関数でParcelをマウントする
      reactParcel.value = props.mountParcel(reactAppModule, {
        domElement,
        customProps: {
          message: "Hello from Vue app!",
        },
      });
    } else {
      console.warn("mountParcel prop is not available or domElement not found");
    }
  } catch (error) {
    console.error("Failed to load or mount react-app:", error);
  }
});
</script>

1. Parcelをマウントするための領域を用意する

Parcelをマウントするための領域を用意します。

2. propsでmountParcel関数を受け取る

propsで渡されたmountParcel関数を受け取ります。

3. parcelの本体をSystemJSで読み込む

SystemJSを使用して、親子関係を持たせたいParcelの本体を動的に読み込みます。

4. mountParcel関数でParcelをマウントする

受け取ったmountParcel関数を使用して、Parcelをマウントします。
mountParcelには以下のパラメータを指定します。

  • config : マウントしたいParcelの本体
  • props : Parcelに渡したい任意のプロパティ
    • domElement : ParcelをマウントするためのDOM要素
    • customProps : Parcelに渡したい任意のプロパティ

Parcelをルートに登録する

Parcelは親子関係を持たせず、ルートアプリケーション(root-config)に登録することも可能です。
その場合はsingle-spaライブラリのmountRootParcelを使用します。

import { mountRootParcel } from "single-spa";

// 1. parcelの本体をSystemJSで読み込む
const vueParcelModule = await System.import("vue-parcel");
const domElement = document.getElementById("vue-parcel-container");
if (domElement && vueParcelModule) {
  // 2. mountRootParcel関数でParcelをマウントする
  const vueParcel = mountRootParcel(vueParcelModule, {
    domElement,
    customProps: {
      message: "Hello from root-config!",
    },
  });

ビルド設定を調整する

子アプリケーション(microfrontend)をビルドする際に、以下の設定を行うことで、single-spaに適したバンドルを生成できます。

共通依存ライブラリのexternal化

マイクロフロントエンドアーキテクチャでは、「複数の子アプリケーション(microfrontend)が同じ依存ライブラリを持つ場合、バンドルサイズが肥大化する」 という問題があります。

この問題を解決するために、共通依存ライブラリをexternal化し、ルートアプリケーション(root-config)において一度だけ読み込むように設定します。
こうすることで、各アプリケーションはルートアプリケーションの依存ライブラリを共有でき、バンドルサイズを削減できます。

以下はVueの場合の例です。

vite.config.ts

import { defineConfig } from "vite";

// https://vite.dev/config/
export default defineConfig({
  build: {
    rollupOptions: {
      input: "src/main.ts",
      output: {
        entryFileNames: `assets/${version}-[name].js`,
        chunkFileNames: `assets/${version}-[name].js`,
        assetFileNames: `assets/${version}-[name].[ext]`,
        format: "system",
      },
      // 共通的なライブラリであるVueやVue Routerをexternal化する
      external: ["vue","vue-router"],
      preserveEntrySignatures: "strict",
      plugins: [],
    },
    sourcemap: false,
  },
});

CSSをApplication/Parcelに含める

そのままApplication/Parcel化woすると、CSSがバンドルに含まれず、UIが崩れるため、vite-plugin-css-injected-by-jsを使用してCSSをJavaScriptに含めるようにします。

vite.config.ts

import { fileURLToPath, URL } from "node:url";

import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite";
import cssInjectedByJsPlugin from "vite-plugin-css-injected-by-js";
import vueDevTools from "vite-plugin-vue-devtools";

// https://vite.dev/config/
export default defineConfig({
  plugins: [vue(), vueDevTools(), cssInjectedByJsPlugin()],
});

その他有用な機能

以下はsingle-spaを使用する上で便利そうな機能を記載します。

single-spa全体で管理されるライフサイクル

single-spaでは以下のwindowイベントにフックして、全体で管理されるライフサイクル を提供しています。

発火の順番 イベント名 発火条件
1 single-spa:before-app-change
single-spa:before-no-app-change
アプリケーションのステータスが変更される直前
2 single-spa:before-routing-event ページ遷移の直前
3 single-spa:before-mount-routing-event
4 single-spa:before-first-mount アプリケーションがマウントされる直前
5 single-spa:first-mount アプリケーションがマウントされたとき
6 single-spa:app-change
または
single-spa:no-app-change
アプリケーションのステータスが変更時
7 single-spa:routing-event ページ遷移終了時

SSR(サーバーサイドレンダリング)への対応

single-spa-layoutを使用することで、single-spaでSSR(サーバーサイドレンダリング)を実現できるらしいです。

ちょっと自分では試してないので、興味がある方は以下のドキュメントを参照してください。

https://single-spa.js.org/docs/getting-started-overview

まとめ

この記事では、single-spaを使ったマイクロフロントエンドアーキテクチャの実装方法 について解説しました。

  • ルートアプリケーション(root-config)は 「子アプリケーション(microfrontend)や共通依存ライブラリの読み込み」 を行い 「single-spaに子アプリケーション(microfrontend)を登録し、single-spaを起動します」
  • 子アプリケーション(microfrontend)にはUIコンポーネント単位で分割した「Application/Parcel」 と、ロジック単位で分割したUIを持たない「Utility」 があります
    • Application/Parcelには 「ライフサイクル関数の公開」「親アプリケーションからのプロパティ受け取り」 を実装する必要があります
    • Application/Parcel内からsingle-spaにParcelを登録することで、子アプリケーション(microfrontend)に親子関係を持たせることができます
    • 共通依存ライブラリをexternal化し、ルートアプリケーション(root-config)において一度だけ読み込むことで、バンドルサイズを削減 できます
  • single-spaでは全体で管理されるライフサイクル を提供しています

single-spaはマイクロフロントエンドアーキテクチャを実現するための強力なツールですが、必ずしも全てのプロジェクトに適しているわけではありません。
小規模なプロジェクトや、単一のUIフレームワークのみを使用している場合は、single-spaはオーバースペックになる可能性があります。

プロジェクトの要件やチームのスキルセットを考慮し、single-spaの導入を検討してください。

Discussion