Qiankunで実現するマイクロフロントエンドなアプリケーション

# Qiankunで実現するマイクロフロントエンドなアプリケーション
## はじめに
最近マイクロサービスという言葉も一般的になってきたように感じます。
モノリシックに対するマイクロサービスですが、これはもっぱらバックエンドやインフラをさすことが多く、フロントエンドについてこの話題が上がることはあまりありません。ただモノリスサービスが直面した問題の一部はフロントエンドでも同様に抱えています。そこでこの記事では、qiankunというライブラリで実現するマイクロフロントエンドについて紹介したいと思います。

## マイクロフロントエンドで解決したいこと
巨大になったアプリケーションは多くのコンテキストを抱え、メンバー間の情報共有のコストを増大させます。また新しい試みをする場合においても、多くの制約の中で実施することになります。ライブラリのバージョン更新や基盤プログラムへの修正も影響範囲が大きすぎて、しりごみすることになるでしょう。
マイクロフロントエンドアーキテクチャーとは依存関係を持たないようにコードを分離しつつ、一つのアプリケーションとして機能させるようなアーキテクチャーのことで、上記のような問題を解決します。

また以下はAWSがマイクロサービスのメリットについて列挙している箇所ですが、マイクロフロントエンドアーキテクチャーでも同様のことが言えるので載せておきます。

> ## 俊敏性
マイクロサービスでは、サービスの所有権を持つ小規模で独立した複数のチームからなる組織が発展します。チームは、小規模でよく理解されたコンテキストにおいて行動し、より自主的かつより迅速に仕事に取り組む権限を与えられます。これにより、開発サイクルが短縮されます。組織の総スループットを大いに有効活用できます。
> ## 技術的な自由
マイクロサービスアーキテクチャは、「フリーサイズ」のアプローチを踏襲しません。チームには、特定の問題を解決するための最適なツールを選ぶ自由があります。その結果、マイクロサービスを構築するチームは、各ジョブに最適のツールを選択可能です。

https://aws.amazon.com/jp/microservices/


## Qiankunについて
[Qiankun](https://qiankun.umijs.org/)はマイクロフロントエンドの実装をより簡単にすることも目的に開発された中国発のライブラリです。[Ant Design](https://ant.design/)などで有名な[Ant Group](https://www.antgroup.com/en)のマイクロフロントエンドアーキテクチャーの技術を元にしています。
React、Angular、Vue、jQueryなど、あらゆるjavascriptフレームワークを統合することができます。
読み方ですが、「チェンクン」と発音するみたいです。

## 実装

今回実装した内容は、こちらのリポジトリにコミットしています。
https://github.com/Cohey0727/microfrontend-app

### 何を作る？
Qiankunをつかってメイン画面に複数のアイコンがならぶスマートフォン風なアプリケーションを作ってみようと思います。
この画面でもし各々が依存関係なく別の技術スタックで動いていたとしたら、めっちゃすごいですよね。
今回はReact, Vue, jQueryという異なるフレームワークを利用して実装してみようと思います。

![main.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235240/17a681c3-fc79-ed04-d379-57f024128383.png)


### 環境
| lib     | version |
| ------- | ------- |
| qiankun | 2.6.3   |
| node    | 16.13.2 |
| react   | 17.0.2  |
| vue     | 3.0.0   |


### 構成
Qiankunでは、ベースとなるメインアプリケーションの上に複数のサブアプリケーションが乗っかるような構成になります。
各々は依存関係がないのでリポジトリが分かれていようとモノレポでも問題ありません。
今回はシンプルにするため、モノレポ構成にしようと思います。
以下のようなフォルダ構成にます。メインアプリケーションはReact、サブアプリケーションはReact、Vue、jQueryで作っていこうと思います。

```shell
│   
├── packages       
│   ├── camera-app    # Reactサブアプリケーション
│   ├── file-app      # jQueryアプリケーション
│   └── calendar-app  # Vueサブアプリケーション
└── src               # Reactメインアプリケーション

```

### 実装
それでは、メインアプリケーションとサブアプリケーションを各々実装していきます。

#### メインアプリケーション
まずはメインとなるアプリケーションです。

[Create React App](https://create-react-app.dev/)でサクッと初期化して必要なライブラリをインストールします。

```shell
yarn create react-app microfrontend-app --template typescript
cd microfrontend-app
yarn add qiankun react-router-dom@6
```

サブアプリケーションを登録するためのファイルを作ります。
今回は`registerSubApps.ts`としました。
まだサブアプリケーションが一つもないので空の状態でつくります。

```src/registerSubApps.ts
import { registerMicroApps, start } from "qiankun";

const registerSubApps = () => {
  registerMicroApps([]);
  start();
};

export default registerSubApps;
```

サブアプリケーションを追加すると`registerMicroApps`の引数にパラメータを足していくことになります。

この関数を`index.tsx`で読み込んで発火させます。

```diff
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
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.
```

メインアプリケーションの下地を作っていきます。

```src/App.tsx
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
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の設定を書き換える必要があるのでインストールします。

```shell
mkdir packages
cd packages
yarn create react-app camera-app --template typescript
cd camera-app
yarn add react-router-dom@6 react-app-rewired
```

中身をこだわって作る意味はないのタイトルとメインアプリケーションに戻るためのリンクのみのアプリケーションとします。

```packages/camera-app/src/App.tsx
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`を追加します。

```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`に変更します。

```diff
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`を以下のように修正します。

```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`を追加します。

```.env.development.local
PORT=3001
```

あとはwebpackの設定を変えていきます。
今回は`react-app-rewired`を利用しているので`src/packages/car-app/config-overrides.js`を追加して記述します。

```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`も書き換えます。

```packages/camera-app/package.json.diff
   "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`に変更しておきます。

```diff
-  "name": "camera-app",
+  "name": "camera_app",
```

メインアプリケーションにアイコンとcamera_appへのリンクを貼ります。

```src/App.tsx
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;

```


あとはこれをメイン関数で呼び出すだけになります。

```src/registerSubApps.ts.diff
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();
 };

```

メインアプリケーションを起動とサブアプリケーションを同時に起動します。

```shell
yarn start
```

```shell
cd packages/camera-app
yarn start
```

![chrome-capture (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235240/3b57b101-75a1-2a64-b01a-8dd2fbf98323.gif)

いい感じに動いてますね。


##### Vue編
続いてVueでも実装していきます。

```shell
cd packages
vue create calendar-app
cd calendar-app
echo PORT=3002 >> .env
```

Reactと同様`packages/calendar-app/src/public-path.js`にファイルを置いていきます。

```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のライフサイクル関数を書きます。

```packages/calendar-app/src/main.js
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の設定を書き換えます。

```packages/calendar-app/vue.config.js
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`はシンプルなものにしておきます。

```packages/calendar-app/src/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>
```

あとはこれをメインアプリケーションから呼び出すだけです。

```src/registerSubApps.ts.diff
    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();
```


```src/App.tsx.diff
 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を触るのが久々すぎて何がデファクトかわからないですが、最小限でいこうと思います。

まずは、雛形。

```shell
cd packages
mkdir file-app
cd file-app
mkdir public
touch public/index.html
touch public/index.js
touch public/index.css
```

ファイルができたら中身を修正

```packages/file-app/public/index.html
<!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>
```

```packages/file-app/public/index.js
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);
```

```packages/file-app/public/index.css
.App {
  text-align: center;
}
```

これをメインアプリケーションから呼び出します。


```src/App.tsx.diff
+  {
+    icon: fileImage,
+    title: "File App",
+    link: "/file",
+  },
```

jQueryのアプリケーションはポート3003で起動します。

```src/registerSubApps.ts.diff
+    {
+      name: "file-app",
+      entry: "//localhost:3003",
+      container: "#sub-container",
+      activeRule: "/file",
+      props: { basename: "/file" },
+    },
```

これで完成です。

### 動作確認
すべてのアプリケーションを同時に起動するため、`npm-run-all`を入れます。

```shell
yarn add -D npm-run-all
```

`package.json`に各アプリケーションの起動コマンドを書きます。


```package.json.diff
-    "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",
```

これで準備が整いました。アプリケーションを起動します。

```shell
yarn start
```

画面を確認します。
![ezgif-2-7a080e7376.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/235240/bc637947-c59f-254f-ce6b-14f501d6c0c1.gif)

問題なく動作しています。
これがReact, Vue, jQueryと異なる技術スタックで作られているのは驚きですよね。

## 最後に
大規模なフロントエンド開発はサブドメインを切り替えるなどでも解決できるため、今後マイクロフロントエンドの流れが強くなるかと言われるとマイクロサービスほどではないと思います。ただ画面をリフレッシュさせたくないなどの要求を満たしつつ、複数チームでの開発でのを実現するには有効な手段だと思います。
また今回モノレポで実装していますが、複数チームでの開発において、リリースフローなどが異なる場合は、マルチリポジトリでも問題ないと思います。今回はすべてローカルのサーバーを参照していますが、普段はクラウドを参照し必要になったらローカルのサーバーを起動するみたいな構成もできるので。
次回はデプロイなどについても調べていければと思います。