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

この記事が気に入ったらサポートをしてみませんか?