マイクロフロントエンドフレームワーク 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」 があります。
single-spaの構成要素の詳細
ここからは実際に実装例を交えながら、single-spaの構成要素について詳しく解説していきます。
以下のリポジトリに実装例を公開していますので、ぜひ参考にしてください。
ルートアプリケーション(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
にアクセスした時に起動
- 例: ["/vue"]と指定した場合、URLが
- 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)から渡された任意のプロパティ
-
render : Vueアプリケーションのレンダリング関数
- 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(サーバーサイドレンダリング)を実現できるらしいです。
ちょっと自分では試してないので、興味がある方は以下のドキュメントを参照してください。
まとめ
この記事では、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