🌟

vite と single-spa で作るマイクロフロントエンド基盤

2021/07/14に公開

超巨大フロントエンドを分割する基盤を作ろうとしたものの紹介します。

この記事の前提

  • 巨大フロントエンドを分割統治したい
  • SSR は考えない
  • モダンブラウザのみ対応する(IE11 非対応)

この記事では single-spa とマイクロフロントエンドの紹介はしません。こちらの記事を読んでください。

single-spa はアプリケーションのライフサイクルに簡単な規約を導入するもので、おそらく一番使われてるものです。これを基本的に vite と組み合わせて各アプリケーションを構成しますが、 webpack でも同様のことは可能です。

動いてるもの

デモ

https://microfront-base.netlify.app/

ここで実現したこと

  • 共通ヘッダ
  • 異なる環境でビルドされたコンテンツをルーティングごとに切り替える
  • react-router のアプリと vue-router のアプリの共存
    • 各アプリケーションはそれぞれの担当範囲でルーティングを持てるが、他のアプリケーションへも遷移できる
  • 個別にデプロイできるように分割統治
    • 開発環境で大多数は本番に向けつつ、部分的にローカルビルドを動かすようにできるようにする
    • => 実行に全てのアプリケーションのビルドを要求しない

GitHub: https://github.com/mizchi/microfront-base

以下、実装手順の紹介。

single-spa のホスト環境を作る

single-spa は色々な機能がありますが、主に次の2つの機能を使います。

  • 生存判定: activeWhen: (loc: Location) => boolean で次の URL でその Application が生存するかを定義
  • ライフサイクル: mount(props) {...}unmount(props) {..} を定義して、生存判定で生き残らなかったときの処理を定義

URL のみが判定条件で、それぞれが特定の DOM に紐付いてるわけではないのに注意してください。

今回は、常に存在する header と、 / に反応する Home, /about に反応する about の application を登録してみます。

まず、shell という名前の single-spa の host 環境を作ります。

(この名前は AppShell モデルの shell を借用してるつもりです。 App Shell モデル  |  Web  |  Google Developers
)

mkdir microfrost-base
cd microfront-base
yarn create @vitejs/app shell
cd shell
yarn add single-spa

ここに single-spa ホスト環境を実装します。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Microfront Base</title>
  </head>
  <body>
    <div id="header"></div>
    <div id="main"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

まず、マウント用に header と main の2つを用意しました。

で、そこを管理する Application を登録します。

// src/main.ts
import {registerApplication, start} from "single-spa";

const header = document.querySelector("#header");
registerApplication({
  name: "header",
  activeWhen: () => true,
  app: {
    bootstrap() {},
    mount() {
      // ここに header に対する実装を書く
      header.textContent = "mounted";
    },
    unmonut() {
      // ここに header の終了処理を書くが、実際はマウントしっぱなしなので呼ばれない
      header.textContent = ""
    }
  }
});

const main = document.querySelector("#main");
registerApplication({
  name: "home",
  activeWhen: "/",
  app: {
    bootstrap() {},
    mount() {
      // about に飛ぶボタンをマウントする
      const button = document.createElement("button");
      button.textContent = "go to about";
      button.addEventListner("click", () => {
        navigateToUrl("/about");
      });
      main.appendChild(button);
    },
    unmonut() {
      // 終了処理
      main.textContent = "";
    }
  }
});

// about
registerApplication({
  name: "about",
  activeWhen: "/about",
  app: {
    bootstrap() {},
    mount() {
      main.textContent = "about";
    },
    unmonut() {
      main.textContent = "";
    }
  }
});

start();

アプリケーションのライフサイクルを定義します。ここはフレームワーク非依存で、実際は各 Application をマウントする処理を実装することになります。

single-spa の navigateToUrl でページ間を遷移できます。

外部 URL を指定した Application を Mount する

自分は外部のアセットを初期化するために、次のようなユーティリティを書きました。

// shell/src/utils.ts

const permanentRoot = document.querySelector("#main") as HTMLElement;
let el: HTMLElement | null;

export function getRoot(): HTMLElement {
  if (permanentRoot.firstChild == null) {
    cleanup();
  }
  return el as HTMLElement;
}

let cycle = 0;

export function cleanup() {
  el?.remove();
  el = document.createElement("div");
  el.id = "root";
  el.dataset.cycle = (cycle++).toString();
  permanentRoot.appendChild(el);
}

export function createExternalApp(options: { endpoint: string }): Application {
  let unmountListeners: Array<Disposable> = [];
  return {
    async bootstrap() {},
    async mount(props) {
      console.log("[external:mount]", options.endpoint);
      const root = getRoot();
      // vite の dynamic import を無効化する。つまりそのまま ES Modules を使う
      const mod = await import(/* @vite-ignore */ options.endpoint);
      try {
        const isActiveYet = !!root.parentElement;
        if (isActiveYet) {
          const disposable = await mod.default(props);
          unmountListeners.push(disposable);
        } else {
          console.warn("disposed");
        }
      } catch (err) {
        console.warn("mount error", err);
      }
    },
    async unmount() {
      console.log("[external:unmount]", options.endpoint);
      await Promise.all(unmountListeners.map((disposable) => disposable()));
      unmountListeners = [];
      cleanup();
    },
    async update() {},
  };
}

dynamic import で起動するアプリケーションを定義しています。こ

こでちょっと工夫したのが、 main の下の root 要素を毎回再生成しています。 これは後々わかったのですが、いろんなフレームワークにHTML要素を渡すと、加工されていろんなリスナやプロパティが残ってしまいます。とくに vue に渡したときは unmount 後は他のフレームワークで利用不可能なほどに独自プロパティで汚された状態になっていました。

このユーティリティをつかって single-spa の application として追加します

// shell/src/main.ts
// ...
import {createExternalApp, getRoot} from "./utils"
registerApplication({
  name: "external",
  activeWhen: (loc) => loc.pathname.startsWith("external")),
  app: createExternalApp({ endpoint: "/external.js" }),
  customProps: {
    getRoot
  }
});

custompProps は props として追加する関数を渡せます。ここで getRoot を呼べるようになるので、ここでマウント用のルート要素を取り出して渡します。

vite だと public がそのまま静的アセットとして追加されるので、そこに今回の規約を満たす pulbic/external.js を一つ配置してみます。

// shell/public/external.js
export default async (props) => {
  const root = props.getRoot();
  root.textContent = "external";
  return () => {
    console.log('unmount external')
  };
};

これが一番簡単な External Application です。

vite でビルドする External Application

先程 shell から指定した endpoint の shell/public/external.js は実際にはビルド済みの JS であることを想定しています。

今回は vite で複数の chunk からなるアプリケーションをマウントさせてみます。

あたらしくプロジェクトルートで nested というアプリケーションを作成します。

$ yarn create @vitejs/app nested

プリセット指定で、 react-ts を選択しました。

yarn dev で そのまま vite アプリケーションとして起動しますが、これをベースに createExternalApp で読み込む方式に改造します。

vite.config.js を次のように編集します。

// nested/vite.config.js
import { defineConfig } from "vite";
import reactRefresh from "@vitejs/plugin-react-refresh";
import path from "path";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [reactRefresh()],
  base: `/nested/`,
  build: {
    emptyOutDir: true,
    // 今回は単独でデプロイせず、 shell の public に吐き出す。
    outDir: path.join(__dirname, "../shell/public/nested"),
    lib: {
      formats: ["es"],
      entry: path.join(__dirname, "src/index.tsx"),
    },
  },
});

大事なのは base と outDir の指定です。 base は出力された js の chunk を読み込むときの相対パスのルートです。
今回はちょっとサボっていて、本来ならデプロイ単位を別にするために自身で CDN を指定してアップロードするべきなんですが、一旦 outDir をいじって shell の public に突っ込んでいます。 このとき、 shell のプレビュー環境から見たときの nested のアセットの相対パスが /nested/ になるわけです。

出力ファイル名は package.json の name になるので、 "name": "nested" を指定。

で、 entry で指定した src/index.tsx を次のような感じに。

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

export default (props) => {
  const root = props.getRoot();
  ReactDOM.render(<App ...>, root);
  return () => {
    // 開放処理
    ReactDOM.unmountComponentAtNode(root);
  }
}

この状態で、 yarn build すると、 shell/pubilc/nested/nested.es.js が生成されるはず。

今こういう感じになってるはずです。

nested/
  src/...
  vite.config.js
shell/
  public/
    nested/
      nested.es.js
      assets/...
  src/
    main.ts
    utils.ts

これを、 shell から読み込むように追加します。

// shell/src/main.ts
import {createExternalApp, getRoot} from "./utils"
registerApplication({
  name: "nested",
  activeWhen: (loc) => loc.pathname.startsWith("/nested")),
  app: createExternalApp({ endpoint: "/nested/nested.es.js" }),
  customProps: {
    getRoot
  }
});

shell を vite dev で起動して、http://localhost:3000/nested で起動します。
これで複数チャンクに分割された状態でも、単独のアプリケーションとして立ち上がります。

この nested の中で react-router で遷移しても起動します。

部分的なローカル実行

k8s だと Telepresence をつかってクラスタの一部でローカル環境に差し替えて実行したりできます。これをマイクロフロントエンドでもやりたいと考えました。

Home | Telepresence

各画面は構成が独立しているので、そのまま本番 or staging に向けても問題ないとして、自分が担当する部分だけローカルに差し替えれるようにできそうです。

それぞれのフロントエンドがCDNにアップロードするとして、このような設定ファイルで切り替えができそう。

type RemoteConfig = {
  name: string;
  endpoint: string;
};

const HOST = `${location.protocol}//${location.host}`;

const remoteConfigList: RemoteConfig[] = [
  {
    name: "foo",
    // endpoint: "http://localhost:3001/foo/foo.es.js",
    endpoint: "https://cdn.example.com/dist/foo/foo.es.js"
  },
  {
    name: "bar",
    // endpoint: "http://localhost:3001/foo/foo.es.js",
    endpoint: "https://cdn.example.com/dist/bar/bar.es.js"
  },
];

remoteConfigList.forEach((config) => {
  registerApplication({
    name: config.name,
    activeWhen: (loc) => loc.pathname.startsWith("/" + config.name),
    app: createExternalApp({ endpoint: config.endpoint }),
    customProps: sharedProps,
  });
});

必要に応じてコメントアウトして向き先をコントロールすれば、自分の担当する画面以外をローカルで実行する必要がなくなります。


以下は本当に運用したらあとで書く、かも…

github actions によるデプロイ管理

認証とAPI

Discussion