Qiankunで実現するマイクロフロントエンドなアプリケーション
はじめに
最近マイクロサービスという言葉も一般的になってきたように感じます。
モノリシックに対するマイクロサービスですが、これはもっぱらバックエンドやインフラをさすことが多く、フロントエンドについてこの話題が上がることはあまりありません。ただモノリスサービスが直面した問題の一部はフロントエンドでも同様に抱えています。そこでこの記事では、qiankunというライブラリで実現するマイクロフロントエンドについて紹介したいと思います。
マイクロフロントエンドで解決したいこと
巨大になったアプリケーションは多くのコンテキストを抱え、メンバー間の情報共有のコストを増大させます。また新しい試みをする場合においても、多くの制約の中で実施することになります。ライブラリのバージョン更新や基盤プログラムへの修正も影響範囲が大きすぎて、しりごみすることになるでしょう。
マイクロフロントエンドアーキテクチャーとは依存関係を持たないようにコードを分離しつつ、一つのアプリケーションとして機能させるようなアーキテクチャーのことで、上記のような問題を解決します。
また以下はAWSがマイクロサービスのメリットについて列挙している箇所ですが、マイクロフロントエンドアーキテクチャーでも同様のことが言えるので載せておきます。
俊敏性
マイクロサービスでは、サービスの所有権を持つ小規模で独立した複数のチームからなる組織が発展します。チームは、小規模でよく理解されたコンテキストにおいて行動し、より自主的かつより迅速に仕事に取り組む権限を与えられます。これにより、開発サイクルが短縮されます。組織の総スループットを大いに有効活用できます。
技術的な自由
マイクロサービスアーキテクチャは、「フリーサイズ」のアプローチを踏襲しません。チームには、特定の問題を解決するための最適なツールを選ぶ自由があります。その結果、マイクロサービスを構築するチームは、各ジョブに最適のツールを選択可能です。
Qiankunについて
Qiankunはマイクロフロントエンドの実装をより簡単にすることも目的に開発された中国発のライブラリです。Ant Designなどで有名なAnt Groupのマイクロフロントエンドアーキテクチャーの技術を元にしています。
React、Angular、Vue、jQueryなど、あらゆるjavascriptフレームワークを統合することができます。
読み方ですが、「チェンクン」と発音するみたいです。
実装
今回実装した内容は、こちらのリポジトリにコミットしています。
https://github.com/Cohey0727/microfrontend-app
何を作る?
Qiankunをつかってメイン画面に複数のアイコンがならぶスマートフォン風なアプリケーションを作ってみようと思います。
この画面でもし各々が依存関係なく別の技術スタックで動いていたとしたら、めっちゃすごいですよね。
今回はReact, Vue, jQueryという異なるフレームワークを利用して実装してみようと思います。
環境
lib | version |
---|---|
qiankun | 2.6.3 |
node | 16.13.2 |
react | 17.0.2 |
vue | 3.0.0 |
構成
Qiankunでは、ベースとなるメインアプリケーションの上に複数のサブアプリケーションが乗っかるような構成になります。
各々は依存関係がないのでリポジトリが分かれていようとモノレポでも問題ありません。
今回はシンプルにするため、モノレポ構成にしようと思います。
以下のようなフォルダ構成にます。メインアプリケーションはReact、サブアプリケーションはReact、Vue、jQueryで作っていこうと思います。
│
├── packages
│ ├── camera-app # Reactサブアプリケーション
│ ├── file-app # jQueryアプリケーション
│ └── calendar-app # Vueサブアプリケーション
└── src # Reactメインアプリケーション
実装
それでは、メインアプリケーションとサブアプリケーションを各々実装していきます。
メインアプリケーション
まずはメインとなるアプリケーションです。
Create React Appでサクッと初期化して必要なライブラリをインストールします。
yarn create react-app microfrontend-app --template typescript
cd microfrontend-app
yarn add qiankun react-router-dom@6
サブアプリケーションを登録するためのファイルを作ります。
今回はregisterSubApps.ts
としました。
まだサブアプリケーションが一つもないので空の状態でつくります。
import { registerMicroApps, start } from "qiankun";
const registerSubApps = () => {
registerMicroApps([]);
start();
};
export default registerSubApps;
サブアプリケーションを追加するとregisterMicroApps
の引数にパラメータを足していくことになります。
この関数をindex.tsx
で読み込んで発火させます。
diff --git a/src/index.tsx b/src/index.tsx
index ad9cbbb..0a9547b 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
+import registerSubApps from "./registerSubApps";
ReactDOM.render(
<React.StrictMode>
@@ -11,6 +12,8 @@ ReactDOM.render(
document.getElementById("root")
);
+registerSubApps();
+
サブアプリケーションはhtml上のどこかにマウントするのでそのためのdiv
をひとつ追加します。
今回はid="sub-container"
とします。
diff --git a/public/index.html b/public/index.html
index aa069f2..ca7292e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -29,6 +29,7 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
+ <div id="sub-container"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
メインアプリケーションの下地を作っていきます。
import React from "react";
function App() {
return (
<div
style={{
width: "100vw",
height: "100vh",
backgroundImage: 'url("/bg.jpg")',
}}
>
{/* ここにアプリアイコンを表示する */}
</div>
);
}
export default App;
パスが/
以外の時はAppを表示させたくないのでReact Routerで調整します。
diff --git a/src/index.tsx b/src/index.tsx
index 0a9547b..1fc5a26 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,5 +1,6 @@
import React from "react";
import ReactDOM from "react-dom";
+import { BrowserRouter, Route, Routes } from "react-router-dom";
import "./index.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
@@ -7,7 +8,12 @@ import registerSubApps from "./registerSubApps";
ReactDOM.render(
<React.StrictMode>
- <App />
+ <BrowserRouter>
+ <Routes>
+ <Route path="/" element={<App />} />
+ <Route path="/*" element={<></>} />
+ </Routes>
+ </BrowserRouter>
</React.StrictMode>,
document.getElementById("root")
);
まだサブアプリケーションがないのでこれで終了です。
サブアプリケーションを実装したときに、逐次メインアプリケーションにも追記していきます。
サブアプリケーション
React編
camera-app
というアプリケーションを作っていきます。
react-app-rewired
はwebpackの設定を書き換える必要があるのでインストールします。
mkdir packages
cd packages
yarn create react-app camera-app --template typescript
cd camera-app
yarn add react-router-dom@6 react-app-rewired
中身をこだわって作る意味はないのタイトルとメインアプリケーションに戻るためのリンクのみのアプリケーションとします。
import React from "react";
import "./App.css";
function App() {
return (
<div className="App">
<h1>Camera App Powered by React</h1>
<a href="/">Home</a>
</div>
);
}
export default App;
以下のチュートリアルに従って修正していきます。
https://qiankun.umijs.org/guide/tutorial#react-micro-app
packages/camera-app/src/public-path.js
を追加します。
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
packages/camera-app/public/index.html
のマウントするidをroot
からsub-root
に変更します。
メインアプリケーションとサブアプリケーションでマウントするDOMのidが被ってしまうのでサブアプリケーション側のidをsub-root
に変更します。
index aa069f2..ecf1096 100644
--- a/packages/camera-app/public/index.html
+++ b/packages/camera-app/public/index.html
@@ -28,7 +28,7 @@
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
- <div id="root"></div>
+ <div id="sub-root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
packages/camera-app/src/index.tsx
を以下のように修正します。
import React from "react";
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import "./public-path";
import App from "./App";
type RenderProps = {
basename?: string;
};
const rootElementId = "sub-root";
function render(props: RenderProps) {
const { basename = "/" } = props;
ReactDOM.render(
<React.StrictMode>
<BrowserRouter basename={basename}>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById(rootElementId)
);
}
// 単独で起動させる用
if (!(window as any).__POWERED_BY_QIANKUN__) {
render({});
}
export async function bootstrap() {
console.log("[react17] react app bootstraped");
}
export async function mount(props: RenderProps) {
console.log("[react17] props from main framework", props);
render(props);
}
export async function unmount(props: any) {
ReactDOM.unmountComponentAtNode(document.getElementById(rootElementId)!);
}
これらはqiankunのライフサイクル関数であり、メインアプリケーションからマウントされたときやアンマウントされたときなどの処理を記述します。
window.__POWERED_BY_QIANKUN__
でサブアプリケーションとして起動しているか、単独で起動しているかを判定できので単独起動の時は通常のReactとして機能させるための分岐を入れます。
ローカルで同時にサブアプリケーションを立ち上げる場合は、ポートが被ってしまうので.env.development.local
を追加します。
PORT=3001
あとはwebpackの設定を変えていきます。
今回はreact-app-rewired
を利用しているのでsrc/packages/car-app/config-overrides.js
を追加して記述します。
const { name } = require("./package.json");
module.exports = {
webpack: function override(config, env) {
config.output.library = `${name}_[name]`;
config.output.libraryTarget = "umd";
config.output.chunkLoadingGlobal = `webpackJsonp_${name}`;
return config;
},
devServer: (configFunction) => {
return function (proxy, allowedHost) {
const config = configFunction(proxy, allowedHost);
config.open = false;
config.headers = { "Access-Control-Allow-Origin": "*" };
return config;
};
},
};
package.json
も書き換えます。
"scripts": {
- "start": "react-scripts start",
- "build": "react-scripts build",
- "test": "react-scripts test",
+ "start": "react-app-rewired start",
+ "build": "react-app-rewired build",
+ "test": "react-app-rewired test",
"eject": "react-scripts eject"
},
config.output.library
がハイフンが利用できないみたいなのでpackage.json
のname
をcar_app
に変更しておきます。
- "name": "camera-app",
+ "name": "camera_app",
メインアプリケーションにアイコンとcamera_appへのリンクを貼ります。
import React from "react";
import { Link } from "react-router-dom";
import cameraImage from "./images/camera-icon.png";
const apps = [
{
icon: cameraImage,
title: "Camera App",
link: "/camera",
},
];
function App() {
return (
<div
style={{
width: "100vw",
height: "100vh",
backgroundImage: 'url("/bg.jpg")',
}}
>
<div
style={{
width: "100%",
height: "100%",
display: "grid",
padding: 32,
boxSizing: "border-box",
gridTemplateColumns: "1fr 1fr 1fr 1fr 1fr 1fr",
}}
>
{apps.map((app) => {
return (
<div key={app.link} style={{ padding: "0 16px" }}>
<Link to={app.link}>
<img
width="100%"
src={app.icon}
alt={app.title}
style={{ borderRadius: 16 }}
/>
</Link>
</div>
);
})}
</div>
</div>
);
}
export default App;
あとはこれをメイン関数で呼び出すだけになります。
diff --git a/src/registerSubApps.ts b/src/registerSubApps.ts
index 9e97796..f07dc3b 100644
--- a/src/registerSubApps.ts
+++ b/src/registerSubApps.ts
@@ -1,7 +1,15 @@
import { registerMicroApps, start } from "qiankun";
const registerSubApps = () => {
- registerMicroApps([]);
+ registerMicroApps([
+ {
+ name: "camera-app",
+ entry: "//localhost:3001",
+ container: "#sub-container",
+ activeRule: "/camera",
+ props: { basename: "/" },
+ },
+ ]);
start();
};
メインアプリケーションを起動とサブアプリケーションを同時に起動します。
yarn start
cd packages/camera-app
yarn start
いい感じに動いてますね。
Vue編
続いてVueでも実装していきます。
cd packages
vue create calendar-app
cd calendar-app
echo PORT=3002 >> .env
Reactと同様packages/calendar-app/src/public-path.js
にファイルを置いていきます。
if (window.__POWERED_BY_QIANKUN__) {
// eslint-disable-next-line
__webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}
ここには、qiankunのライフサイクル関数を書きます。
import { createApp } from "vue";
import "./public-path";
import App from "./App.vue";
let instance = null;
function render(props) {
console.log(props);
instance = createApp(App).mount("#app");
}
// when run independently
if (!window.__POWERED_BY_QIANKUN__) {
render();
}
export async function bootstrap() {
console.log("[vue] vue app bootstraped");
}
export async function mount(props) {
console.log("[vue] props from main framework");
render(props);
}
export async function unmount() {
if (instance) {
instance.$destroy();
instance = null;
}
}
webpackの設定を書き換えます。
const { name } = require("./package");
module.exports = {
devServer: {
headers: {
"Access-Control-Allow-Origin": "*",
},
},
configureWebpack: {
output: {
library: `${name}-[name]`,
libraryTarget: "umd", // bundle the micro app into umd library format
jsonpFunction: `webpackJsonp_${name}`,
},
},
};
App.vue
はシンプルなものにしておきます。
<template>
<h1>Calendar App Powered by Vue</h1>
<a href="/">Home</a>
</template>
<script>
export default {
name: "App",
};
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
</style>
あとはこれをメインアプリケーションから呼び出すだけです。
const registerSubApps = () => {
entry: "//localhost:3001",
container: "#sub-container",
activeRule: "/camera",
props: { basename: "/camera" },
},
+ {
+ name: "calendar-app",
+ entry: "//localhost:3002",
+ container: "#sub-container",
+ activeRule: "/calendar",
+ props: { basename: "/calendar" },
+ },
]);
start();
import React from "react";
import { Link } from "react-router-dom";
import cameraImage from "./images/camera-icon.png";
+import calendarImage from "./images/calendar-icon.png";
const apps = [
{
@@ -8,6 +9,11 @@ const apps = [
title: "Camera App",
link: "/camera",
},
+ {
+ icon: calendarImage,
+ title: "Calendar App",
+ link: "/calendar",
+ },
];
これでVue編は終了です。
jQuery編
さて続いてjQueryで作っていきます。jQueryを触るのが久々すぎて何がデファクトかわからないですが、最小限でいこうと思います。
まずは、雛形。
cd packages
mkdir file-app
cd file-app
mkdir public
touch public/index.html
touch public/index.js
touch public/index.css
ファイルができたら中身を修正
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script
src="https://code.jquery.com/jquery-3.6.0.min.js"
integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4="
crossorigin="anonymous"
></script>
<link rel="stylesheet" type="text/css" href="index.css" />
<title>Purehtml Example</title>
</head>
<body>
<div class="App">
<h1>File App Powered by jQuery</h1>
<a href="/">Home</a>
</div>
</body>
<script src="index.js"></script>
</html>
const render = ($) => {
return Promise.resolve();
};
((global) => {
global["purehtml"] = {
bootstrap: () => {
console.log("purehtml bootstrap");
return Promise.resolve();
},
mount: () => {
console.log("purehtml mount");
return render($);
},
unmount: () => {
console.log("purehtml unmount");
return Promise.resolve();
},
};
})(window);
.App {
text-align: center;
}
これをメインアプリケーションから呼び出します。
+ {
+ icon: fileImage,
+ title: "File App",
+ link: "/file",
+ },
jQueryのアプリケーションはポート3003で起動します。
+ {
+ name: "file-app",
+ entry: "//localhost:3003",
+ container: "#sub-container",
+ activeRule: "/file",
+ props: { basename: "/file" },
+ },
これで完成です。
動作確認
すべてのアプリケーションを同時に起動するため、npm-run-all
を入れます。
yarn add -D npm-run-all
package.json
に各アプリケーションの起動コマンドを書きます。
- "start": "react-scripts start",
+ "start": "run-p start:*",
+ "start:main": "react-scripts start",
+ "start:camera-app": "cd packages/camera-app && yarn start",
+ "start:calendar-app": "cd packages/calendar-app && yarn serve",
+ "start:file-app": "cd packages/file-app && serve -s public -p 3003 -C",
"build": "react-scripts build",
"test": "react-scripts test",
これで準備が整いました。アプリケーションを起動します。
yarn start
問題なく動作しています。
これがReact, Vue, jQueryと異なる技術スタックで作られているのは驚きですよね。
最後に
大規模なフロントエンド開発はサブドメインを切り替えるなどでも解決できるため、今後マイクロフロントエンドの流れが強くなるかと言われるとマイクロサービスほどではないと思います。ただ画面をリフレッシュさせたくないなどの要求を満たしつつ、複数チームでの開発でのを実現するには有効な手段だと思います。
また今回モノレポで実装していますが、複数チームでの開発において、リリースフローなどが異なる場合は、マルチリポジトリでも問題ないと思います。今回はすべてローカルのサーバーを参照していますが、普段はクラウドを参照し必要になったらローカルのサーバーを起動するみたいな構成もできるので。
次回はデプロイなどについても調べていければと思います。