single-spa でマイクロフロントエンドを検証する
microfrontendby mizchi created at 2020/08/05/23:18
マイクロフロントエンドとは
[翻訳記事]マイクロフロントエンド - マイクロサービスのフロントエンドへの応用
自分の理解だと、フロントエンドを一定以上にスケールさせようとすると、レガシーの古いビルド境界や、コンウェイの法則によるチーム単位のコンポーネントが出てくる。
これらをメタに管理するシステムを作って、複数のビルドを協調させる試み。
実装
マイクロフロントエンドの実装に必ずしもライブラリを使う必要はないのだが、調べた感じ、single-spa というライブラリが土台にある。
ドキュメントに中国産が多い。大きなSPAをたくさん作るお国柄な気がする。
single-spa
マイクロフロントエンドの用の土台となるルーティング機能、各コンポーネントのライフサイクルだけを管理するもの。
このドキュメントでは System.js や import-map を推奨しているが、無視する。 System.js はESMと仕様が乖離していて未来がないし、 import-map は早すぎる。
import { registerApplication, start, navigateToUrl } from 'single-spa';
import { h, render, Fragment } from 'preact';
// navigateToUrl を発行するだけの x-link 要素
customElements.define("x-link", class extends HTMLElement {
connectedCallback() {
this.style.textDecoration = "underline";
this.style.color = "blue";
this.addEventListener("click", (ev) => {
ev.preventDefault();
navigateToUrl(this.getAttribute("href"));
})
}
});
const createHeader = async () => ({
bootstrap: async () => {
},
mount: async () => {
const header = document.querySelector("#header");
render(h("nav", null,
h("x-link", { href: "/" }, "/"),
"|",
h("x-link", { href: "/xxx" }, "/xxx"),
"|",
h("x-link", { href: "/yyy" }, "/yyy"),
), header);
},
unmount: async () => {
// do not call
}
});
const main = document.querySelector("#app")
const createApplication = async (name) => ({
bootstrap: async () => {
console.log(`${name}:bootstrap`);
},
mount: async () => {
console.log(`${name}:mount`);
render(h("h1", null, name), main);
},
unmount: async () => {
console.log(`${name}:unmount`);
render(h(Fragment), main);
}
})
registerApplication('header', () => createHeader(), _location => true);
registerApplication('xxx', () => createApplication('xxx'), loc => loc.pathname.startsWith('/xxx'));
registerApplication('yyy', () => createApplication('yyy'), loc => loc.pathname.startsWith('/yyy'));
start();
ほとんどの example では、System.import が使われているが、これを省いてよく読むと、覚えることは少ない。
- 第二引数では、
() => Promise<{ async mount(): void; async unmount(): void; async bootstrap(): void }>
みたいなライフサイクルを返却する。 - 第三引数では、 window.location が与えられるので、そのパスにおいて、自分がマウント可能かどうかを返す関数を定義する。
nagivateToUrl(...)
が実行されるたびに、第三引数の関数が実行され、mount されるかどうかが決定される。
mount に自分のコンポーネントをDOMに置く操作を書き、unmount で回収する。これだけ。
なにがしかの実装があるというより、規約ベースで複数のコンポーネントをつなぎ合わせる土台、といった印象を受けた。
sigle-spa-layout
注意: まだベータ版。
HTML としてレイアウトの雛形を定義する。ルーティングの度に、これを簡易なテンプレートエンジンとして展開する。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<template id="single-spa-layout">
<single-spa-router>
<header id="header">
<application name="header"></application>
</header>
<main id="app">
<route path="/xxx">
<application name="xxx"></application>
</route>
<route path="/yyy">
<application name="yyy"></application>
</route>
</main>
</single-spa-router>
</template>
</head>
<body>
</body>
</html>
これを読み込むJS。
import { registerApplication, start, navigateToUrl, getAppNames, getMountedApps } from 'single-spa';
import { h, render, Fragment } from 'preact';
import {
constructApplications,
constructRoutes,
constructLayoutEngine,
} from 'single-spa-layout';
// navigateToUrl を発行するだけの x-link 要素
customElements.define("x-link", class extends HTMLElement {
connectedCallback() {
this.style.textDecoration = "underline";
this.style.color = "blue";
this.addEventListener("click", (ev) => {
ev.preventDefault();
navigateToUrl(this.getAttribute("href"));
})
}
});
const createHeader = async () => ({
bootstrap: async () => {
},
mount: async () => {
const header = document.querySelector("#header");
render(h("nav", null,
h("x-link", { href: "/" }, "/"),
"|",
h("x-link", { href: "/xxx" }, "/xxx"),
"|",
h("x-link", { href: "/yyy" }, "/yyy"),
"|",
h("x-link", { href: "/zzz/a" }, "/zzz/a"),
"|",
h("x-link", { href: "/zzz/b" }, "/zzz/b"),
"|",
h("x-link", { href: "/foo" }, "/foo"),
), header);
},
unmount: async () => {
// do not call
}
})
const createApplication = async (name) => ({
bootstrap: async () => {
console.log(`${name}:bootstrap`);
},
mount: async () => {
console.log(`${name}:mount`);
const main = document.querySelector("#app");
render(h("h1", null, name), main);
},
unmount: async () => {
console.log(`${name}:unmount`);
const main = document.querySelector("#app");
render(h(Fragment), main); // unmount
}
});
const routes = constructRoutes(document.querySelector('#single-spa-layout'));
const applications = constructApplications({
routes,
async loadApp(props) {
console.log("load", props);
if (props.name === "header") {
return createHeader();
} else {
return createApplication(props.name);
}
},
});
const layoutEngine = constructLayoutEngine({ routes, applications });
applications.forEach(registerApplication);
start();
見た感じ、良さそうだが、試した感じ /user/:id
みたいな動的ルーティングがとれない。これは結構致命的。
とはいえ、まだベータ版だし、テストコードを見た感じサポートしようとしてるように見える。
https://github.com/single-spa/single-spa-layout/blob/master/test/matchRoute.test.js#L45
single-spa の自由度と比べると、静的定義しようとした点で、だいぶ自由度が落ちている。使いづらそうなので個人的にパス。
qiankun
umijs/qiankun: 📦 🚀 Blazing fast, simple and completed solution for micro frontends.
single-spa のラッパー。ある程度値を決め打ちにして、preload などを追加してる。
import { registerMicroApps, start } from 'qiankun';
registerMicroApps([
{
name: 'react app', // app name registered
entry: '//localhost:7100',
container: '#yourContainer',
activeRule: '/yourActiveRule',
},
{
name: 'vue app',
entry: { scripts: ['//localhost:7100/main.js'] },
container: '#yourContainer2',
activeRule: '/yourActiveRule2',
},
]);
start();
ilc
single-spa と tailorx で、SSR可能にしたもの。
tailorx
Better streaming layouts for front-end microservices with Tailor – O’Reilly
自分はまだよくわかってないのだけど、SSRやパフォーマンス最適化を含むマイクロフロントエンド用のサーバー。見た感じある種のテンプレートエンジンとして振る舞う。
https://github.com/StyleT/tailorx/blob/master/examples/basic-css-and-js/templates/index.html
どうするのが良さそうか
ベストプラクティスといえるほど枯れてないので、まずは規約として single-spa に従って、その上でユースケースごとにラッパーを作るのが良さそう。
実戦投入するには、vue-router や next.js, react-router と協調できるかの実験が必要そう。仕組み的にwebpack v5 の module federation と組み合わせるとよさそうなので、明日はその検証をする。