今回ポートフォリオの作成で、Micro Frontendsという技術を採用したので、その概要や方法についてまとめていきたいと思います。
Micro Frontendsとは
Micro Frontendsとは、バックエンドのMicro Servicesの考え方をフロントエンドにも取り入れた考え方です。
複雑で規模の大きいアプリケーションでは、バックエンド側だけではなくフロントエンド側もモノリスで複雑性が増して管理がしにくくなるといった問題がありますが、Micro Frontendsの考え方はその問題を解決する手段の一つです。
Micro Frontendsのメリット
Micro Frontendsのメリットとしては、各フロントエンドのサービスごとに技術の選定が行え、疎結合なので耐障害性に強く、特定のミッションを達成するためのタスクの明確性をはかれるなどの恩恵を受けることができます。
Micro Frontendのデメリット
Micro Frontendsのデメリットとしては、Gitのリポジトリーが増えたり、技術スタックがチームごとに異なっていたり、日本語文献が少なかったり、CSSの影響範囲の管理をしなければいけなかったりなどのことが挙げられます。
そのため、ある程度規模があるサービスでフロントエンドが複雑な状況であったり、チームごとにフロントエンドの技術スタックを分けたいという考えがあったり、サービスをまたいで共通したフロントエンドのコンポーネントを使いまわしたいという要望があったり、などの場合に採用の検討がされるのではないかと思います。
Micro Frontendsの実現方法
Micro Frontendsを提唱した、ThoughtWorks社の記事では、実現方法として以下の5つのパターンがあります。
「Server-side template composition」
「Build-time integration」
「Run-time integration via iframes」
「Run-time integration via JavaScript」
「Run-time integration via Web Components」
今回は表示を読み込むコンテナ側と、表示を提供するプロダクト側の依存が低く、導入がしやすいという観点から、Run-time integration via JavaScriptを採用しました。
この方式では各プロダクトがSPAで動作するように作られている前提で、各プロダクトを Micro Frontends のコンテナ側で読み込み、各プロダクトをパスの変更のみで提供するという動作するといった方法になります。
ポートフォリオの構成
ポートフォリオは世界のコロナ感染者と、日本の医療逼迫状況をグラフで表示するアプリケーションとなっており、React(Function Component)とTypeScriptで、Micro Frontendsを実現しています。
Micro Frontendsの表示を読み込むコンテナ側で、表示を作成するWorldコンポーネントとJapanコンポーネントはプロダクト側となっています。
(ちなみにせっかくマイクロフロントエンドにしてるんで、Kubernetesクラスターでフロントエンドとバックエンドの各マイクロサービスをコンテナ管理しったり、バックエンドはGoで書いたり、ArgoCDを取り入れたりしてます)
プロダクト側の設定
表示を作成するコンポーネントであるプロダクト側の設定を行います。
パッケージインストール
まず必要なパッケージをインストールします。
プロダクト側では、カレントパスの取得と設定を行うhistory、ルーティングを行うreact-router-dom、webpack環境のejectをさせないためのreact-app-rewiredが必要となります。
# yarn add react-app-rewired history react-router-dom
描画とアンマウントの設定
外部からコンポーネントの描画とアンマウントを行えるように、windowsオブジェクトの設定をindex.tsxで定義します。
実際にWorldコンポーネントで記載しているコードは以下のようになります。
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import * as H from 'history';
import App from './App';
import * as serviceWorker from './serviceWorker';
import { Provider } from 'react-redux';
import { store } from './app/store';
declare global {
interface Window {
renderWorld: (containerId: string, history: H.History) => void;
unmountWorld: (containerId: string) => void;
}
}
window.renderWorld = (containerId, history) => {
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App history={history} />
</Provider>
</React.StrictMode>,
document.getElementById(containerId)
);
serviceWorker.unregister();
};
window.unmountWorld = (containerId) => {
ReactDOM.unmountComponentAtNode(
document.getElementById(containerId) as HTMLElement
);
};
プロダクト側のコンポーネントを描画するために、renderWorldというwindowsオブジェクトのメソッドを新たに追加しています。
描画する場所については、引数としてわたってきたcontaineridのid名のエレメントに展開されます。
それとは反対にアンマウントする際は、umountWorldというwindowsオブジェクトのメソッドを追記し、id名のエレメントを削除します。
Webpackの設定
Run-time integration via JavaScriptでの読み込むJavaScriptファイルは、Sigle Chunkでビルド後に一つのJavaScriptファイルにまとめる必要があります。
そのためプロダクト側のWebpackのオーバーライドの設定ファイルを作成します。
module.exports = {
webpack: (config, env) => {
config.optimization.runtimeChunk = false;
config.optimization.splitChunks = {
cacheGroups: {
default: false,
},
};
config.output.filename = "static/js/[name].js";
config.plugins[5].options.filename = "static/css/[name].css";
config.plugins[5].options.moduleFilename = () => "static/css/main.css";
return config;
},
};
ビルドとサーバー起動時の設定
ビルドとサーバー起動時について、react-app-rewiredで行うようにする設定を、package.jsonに記載します。(create-react-appではreact-scriptsが採用されているが、JavaScriptが複数ファイルに分けられているので、react-app-rewiredでのSigle Chunkの設定が必要)。
(snip)
"scripts": {
"start": "PORT=3001 react-app-rewired start",
"build": "react-app-rewired build",
}
(snip)
以上の設定でサーバーを3001番ポートで起動して、asset-manifest.jsonにアクセスしてみると、Webpackでオーバーライドしているので以下のような表示になると思います。
(snip)
"entrypoints": [
"static/css/main.css",
"static/js/main.js"
]
(snip)
上記のパスをコンテナ側で読み込みます。
CORSの有効設定
異なるオリジン間でのアクセスを必要とするため、Access-Control-Allow-Originの許可設定を行います。(制限が必要な場合は適宜書き換える)
module.exports = (app) => {
app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
next();
});
}
コンテナ側の設定
プロダクト側のファイルを読み込み、コンテナ側でコンポーネントの表示を行う設定を行います。
パッケージインストール
まず必要なパッケージをインストールします。
コンテナ側では、カレントパスの取得と設定を行うhistory、ルーティングを行うreact-router-domが必要となります。
# yarn add history react-router-dom
プロダクト側のコンポーネントの読込設定
プロダクト側のコンポーネントを読み込むために、以下のようなMicroFrontend.tsxを作成します。
import React, { useEffect } from 'react';
import * as H from 'history';
import axios from 'axios';
type MicroFrontendType = {
name: string;
host: string;
history: H.History;
};
declare global {
interface Window {
renderWorld: (containerId: string, history: H.History) => void;
unmountWorld: (containerId: string) => void;
renderJapan: (containerId: string, history: H.History) => void;
unmountJapan: (containerId: string) => void;
[key: string]: (containerId: string, history?: H.History) => void;
}
}
const MicroFrontend: {
({ name, host, history }: MicroFrontendType): JSX.Element;
} = ({ name, host, history }: MicroFrontendType) => {
useEffect(() => {
const scriptId = `micro-frontend-script-${name}`;
const renderMicroFrontend = () => {
if (window[`render${name}`]) {
window[`render${name}`](`${name}-container`, history);
}
};
if (document.getElementById(scriptId)) {
renderMicroFrontend();
return undefined;
}
(async () => {
const { data } = await axios.get(`${host}/asset-manifest.json`);
const script = document.createElement('script');
script.id = scriptId;
script.crossOrigin = '';
script.src = `${host}${data.files['main.js']}`;
script.onload = () => {
renderMicroFrontend();
};
document.head.appendChild(script);
const link = document.createElement('link');
link.rel = 'stylesheet';
link.crossOrigin = '';
link.type = 'text/css';
link.href = `${host}${data.files['main.css']}`;
link.onload = () => {
renderMicroFrontend();
};
document.head.appendChild(link);
})();
return () => {
if (window[`unmount${name}`]) {
window[`unmount${name}`](`${name}-container`);
}
};
});
return <main id={`${name}-container`} />;
};
export default MicroFrontend;
以上の設定では、rendeMicrofrontendでプロダクト側のasset-manifest.jsonで出力した情報を読み込んでコンポーネントを描画をしています。
パスを切り替えた場合などは、id名が設定されている既存のコンポーネントはアンマウントされ、新しいコンポーネントが描画されます。
そしてこのMicroFrontendを呼びだす、コンポーネントの設定を行います。
import React from 'react';
import { HistoryType } from '../../../App';
import MicroFrontend from '../MicroFrontend';
const World = ({ history }: HistoryType): JSX.Element => {
return (
<>
<MicroFrontend
history={history}
host="localhost:3001"
name="World"
/>;
</>
);
};
export default World;
さらに該当するパスにアクセスした際のルーティングの設定を、App.tsxで定義します。
BrowserRouterは、historyAPIを外部から渡すことができるパッケージになります。
import React from 'react';
import * as H from 'history';
import { BrowserRouter, Switch, Route } from 'react-router-dom';
import Japan from './features/covid/Japan/Japan';
import World from './features/covid/World/World';
import NotFound from './features/covid/NotFound/NotFound';
export type HistoryType = {
history: H.History;
};
const App: React.FC = () => {
return (
<BrowserRouter>
<Switch>
<Route exact path="/world" component={World} />
<Route exact path="/japan" component={Japan} />
<Route component={NotFound} />
</Switch>
</BrowserRouter>
);
};
export default App;
以上の設定で、Run-time integration via JavaScript方式のMicro Frontendsの設定は完了です。
history APIの根本的な理解が必要だったり、日本語文献がなかったり、React+TypeScriptで実装されている例を見つけられなかったりで苦労し、現状複雑になってしまっていますが今後ブラッシュアップしていきたいと思います。
参考URL