26
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-02-17

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

はじめに

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

マイクロフロントエンドで解決したいこと

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

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

俊敏性

マイクロサービスでは、サービスの所有権を持つ小規模で独立した複数のチームからなる組織が発展します。チームは、小規模でよく理解されたコンテキストにおいて行動し、より自主的かつより迅速に仕事に取り組む権限を与えられます。これにより、開発サイクルが短縮されます。組織の総スループットを大いに有効活用できます。

技術的な自由

マイクロサービスアーキテクチャは、「フリーサイズ」のアプローチを踏襲しません。チームには、特定の問題を解決するための最適なツールを選ぶ自由があります。その結果、マイクロサービスを構築するチームは、各ジョブに最適のツールを選択可能です。

Qiankunについて

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

実装

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

何を作る?

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

main.png

環境

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としました。
まだサブアプリケーションが一つもないので空の状態でつくります。

src/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.

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

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 --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

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

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

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.jsonnamecar_appに変更しておきます。

-  "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();
 };

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

yarn start
cd packages/camera-app
yarn start

chrome-capture (1).gif

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

Vue編

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

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

まずは、雛形。

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を入れます。

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",

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

yarn start

画面を確認します。
ezgif-2-7a080e7376.gif

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

最後に

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

26
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?