single-spa 試してみた
複数の SPA (Single Page Application) を一つにまとめるヘルパーライブラリー。それぞれの SPA が別のフレームワークで実装されていてもよい。Micro-frontends の文脈。
とりあえず動かす
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<base href="/" />
<script type="module" src="root-config.js"></script>
</head>
<body></body>
</html>
// root-config.js
import {
registerApplication,
start,
} from 'https://unpkg.com/single-spa@5.2.0/lib/esm/single-spa.min.js'
registerApplication(
'app1',
() => import('./app1.js'),
location => location.pathname.startsWith('/app1'),
)
start()
// app1.js
let domEl
export async function bootstrap() {
domEl = document.createElement('div')
domEl.id = 'app1'
document.body.appendChild(domEl)
}
export async function mount() {
domEl.textContent = 'App 1 is mounted!'
}
export async function unmount() {
domEl.textContent = ''
}
ローカルポート 5000 にサーバーを立てるなどして http://localhost:5000/app1 を開くと、"App 1 is mounted!" が見られる。それ以外だと真っ白な画面になる。
single-spa API
root-config.js で使ったのは registerApplication と start の二つ。
registerApplication
アプリの識別子、アプリの読み込み関数、アプリをアクティブにすべきとき true を返す関数 の 3 つを引数にとる。アプリの読み込み関数は、後述の Lifecycle hooks を公開しておく必要がある(app1.js のように)。
start
single-spa を起動する。
Lifecycle hooks
必須なのは bootstrap, mount, unmount で、unload はオプション。
bootstrap
アプリが初めてアクティブになったときに一度だけ呼ばれる。
mount
アプリがアクティブに切り替わったタイミングで呼ばれる。bootstrap と異なりアクティブ・非アクティブが切り替わるたびに呼ばれるので、複数回実行しても問題ない作りにしておく。
React でいうと ReactDOM.render() するタイミング。
unmount
mount の逆。複数回実行される点は同じ。
React では ReactDOM.unmountComponentAtNode() するのが適当。
現実のアプリに適用する上での問題点
新規開発するなら、そもそもフレームワークの異なる SPA をまとめようという発想が下策だと思う(スキルセットや政治の違いを教育なり調整なりで埋めたほうが、複雑さという負債を抱えるよりはマシ)。なので single-spa を適用したくなるシーンは、既存のアプリがあって、それらを統合したいときか段階的に別フレームワークに置き換えたいときになるはず。つまり、既存のビルド設定やそれに依存したアプリケーションコードをきちんと扱えるかが肝要になる。
軽く触ってみた感じ、次の問題に対処する必要がありそう:
単一アプリとして実行する前提のビルド
既存のアプリが、実行中の DOM 空間をフルに使う前提で作られている場合(大体そうだろうが・・・)。たとえば ReactDOM.render() で対象にした DOM 要素だけがアプリの支配対象であればこうならない。CSS in JS で動的に style 要素を埋め込むなどしていて、別アプリへの切り替えがややこしくなる状況を指す。unmount によって適切にクリーンアップする必要ある。
<script src="bundle.js"></script> で読み込む前提のビルド
これも、大体そうやって作られているだろうが、変えてやらないと single-spa による読み込みに適応できない。webpack.config.js の output.library オプションを有効にし、bundle.js を npm ライブラリー的に使えるようビルドしてやる必要がある。さらに厄介なのは、split chunk や dynamic import が絡んでくるケース(たとえば bundle.js だけでなく vendor.js も必要なケース)。
export async function mount() {
import('./dist/vendor.js')
import('./dist/bundle.js')
}
このように mount lifecycle を定義すれば、webpack バンドルは ES module 対応していないものの副作用は起こせるので、bundle.js 内の ReactDOM.render() が効くはず・・・と思ったが、どうもうまくいかなかった(要調査)。このケースの対処法は調査中。
single-spa エントリーポイントのビルドをどうやるか
冒頭のサンプルのように index.html と root-config.js を手書きできる構成なら問題はないが、しかし現実は、HTML 自体 html-webpack-plugin でビルド別に生成するといった要件が絡んでくることもある。すると single-spa エントリーポイントのビルドも必要だが、このビルドと各アプリのビルドは別プロセスにすべきか否か、見えていない。現実のプロジェクトにまだ適用していないからわからないものの、別プロセスにしなかった場合はビルド時間の長大化が懸念される。あとアプリごとに TypeScript のバージョンが異なるようなパターンはどうなるのだろう(要調査)。
まとめ
フロントエンドはフレームワークの流行り廃りが激しいので、複数フレームワークを共存させながらマイグレーションするのはとても現実的なシナリオである。いまの現場では index.html ごと分けて新旧 SPA を行き来する構成ゆえに往来時の読み込み待ちの体験が悪いため、そういった点の改善に役立てられたら、将来的にも有用なパターンが確立できそうだ。
余談
Isn't single-spa sort of a redundant name?
Yep.
https://single-spa.js.org/docs/getting-started-overview#isnt-single-spa-sort-of-a-redundant-name