◆ 常に index.html を表示
  ◆ URL に応じたモジュールをロードして render 関数を実行
  ◆ render 関数が各ページの初期化を行う
  ◆ ページ遷移も URL 書き換えて移動先の render 関数を実行
◆ 事前にページごとの HTML ファイルを用意
  ◆ 同名の .js ファイルをモジュールとしてロードして render 関数を実行
  ◆ render 関数が各ページの初期化を行う
  ◆ ページ遷移は HTML を fetch して head と body を書き換えて .js ファイルをロードして render

SPA サイトを作るのに使ったツールを紹介してるネットの記事を見かけて その時のツールに静的サイトジェネレータも入ってました
静的サイトジェネレータって ルーティングとかを解決して HTML ファイル出力してサーバサイドの処理は不要で見えるようにするだけだと思ってました

ページ間でデータを共有するアプリケーション的なページでもないと SPA にする必要無いかなと思ってましたが ただの独立したページでもロードが高速になるという意味では SPA にするのもありなのかもしれません
ということで SPA にできる仕組みを思いつきで作ってみました
以前の document.write で全書き換えよりはまともです

方法 1

SPA らしく常に index.html を表示するようにします
例では とりあえず .htaccess を置いてます

index.html では URL に応じたモジュールをロードしてその render メソッドを実行します
各ページはモジュールで管理して render メソッドでページを表示させます
document.title を設定したり document.body に HTML を配置したりです

ページ遷移は history.push して遷移先のページの URL のモジュールをロードして render メソッドの実行です
すべての render メソッドは前のページの render 結果が残った状態で呼び出されます
そのページでは head タグを使わなくても head 内の style タグの削除などクリア処理も必要です

URL からモジュールを決める処理は router.js に書きます
router.js では getRoot, getRoute, getNotFoundRoute, getErrorRoute 関数をエクスポートします

getRoute では URL オブジェクトを受け取ってルート情報を返します
ルート情報は { module_url: String } のオブジェクトです
module_url は index.html で import(module_url) としてインポートします
params プロパティも追加できて render 関数の引数で受け取れます
「/article/{id}」 みたいな URL の場合に article モジュールをロードしてその render 関数に id を渡す使い方ができます

getRoot ではアプリケーションを配置するルートの URL を返します
base タグの href と同じものを指定します

getNotFoundRoute は getRoute で指定した module_url のインポートに失敗した場合に呼び出されます
見つからなかった場合のルート情報を返します
404 エラーを表示するモジュールを指定します

getErrorRoute は getNotFoundRoute のモジュールが見つからなかったり render でエラーが起きると呼び出されます
エラー時のルート情報を返します
500 エラーを表示するモジュールを指定します

使い方

router.js の関数を必要に応じて変更します
デフォルトでの getRoute は getRoot で取得した root から URL への相対パスを pages フォルダ内から探します
/a/b/c/ が root で /a/b/c/d/e にアクセスすると pages/d/e.js がモジュールのパスです
見つからなかった場合は not-found.js で エラーの場合は error.js です

配置する root の場所に応じて getRoot と index.html の base タグを書き換えます

あとは pages フォルダ内にページの JavaScript ファイルを好きに配置します
デフォルトの page1.js などは lit-html を前提にしてますが グローバルに影響して競合するものでなければなんでもいいです

いいところ

ルーティング機能があるので自由度があります
404 や 500 に当たる処理も用意しています
それでいて仕組みは単純です
URL に応じたモジュールをロードして render 関数を呼び出すだけです
ページの初期化は各ページの render 任せです
タイマーみたいに全体に影響することをしなければ 問題もほぼありません

SPA なので pages/tpl.js みたいな共通のモジュールのロードは一回で済みます
lit-html での render を使うとこの部分のテンプレートは同じなので 共通部分の書き換えを減らせます

わるいところ

独立したページを SPA にまとめる仕組みなので ページごとに使うライブラリが違うとロードするモジュールが増えます
同じモジュールでもページごとに違うバージョンかもしれません
import は URL 変更時に動的なので開いてないページのためのモジュールはロードされませんが リロード無しで多くのページを移動しているとメモリ使用量がすごいことになりそうです

もうひとつ サーバサイドの処理は不要で 静的ファイルサーバで良いのですが .htaccess を解釈してくれたり ファイルが見つからない場合にデフォルトとして index.html を返してくれる必要があります

方法 2

こっちは .htaccess 的な処理も不要なバージョンです
HTML をページ分用意して直接 HTML にアクセスします
その分 404 エラーはサーバのデフォルトに任せることになります

SPA にするためのライブラリが spa.js です
各ページごとに HTML ファイルを作って JavaScript はこのファイルのみ script タグで module としてロードします
すると自動で同名の .js ファイルがインポートされます
インポート後は render 関数が呼び出されるので初期化処理を書いておきます
他のモジュールのロードが必要なら この .js ファイルからインポートします

ページ遷移では移動先の HTML を fetch して spa.js をロードしているか判断します
ロードしているならその HTML の head と body で置き換えてから 移動先の HTML と同名の .js ファイルをインポートします
head を置き換えても script タグは実行されないので .js ファイルのインポートで実行します
この都合で HTML ファイルがロードする script タグは spa.js だけにします

使い方

アクセスするパスに HTML を配置して spa.js をロードします
必要なら body に要素を追加します
JavaScript の処理を同名の .js ファイルに書きます

いいところ

単純に HTML を開くだけです
シンプルでわかりやすいです
.htaccess で書き換えや見つからないとき用の 404 ページの設定のような処理は不要です

わるいところ

方法 1 と同じくモジュールのロードが多くなる問題は残ります

ルーティングがないので自由度は下がります
ほぼ同じ見た目でも別のページなら HTML を別に用意することになります
ただそれらが同じモジュールをロードすれば共通モジュールはキャッシュされますし ほぼファイルを作る苦労程度です

ページ遷移が HTML ファイルの fetch なので サーバのキャッシュ設定によっては毎回通信が必要です
ただそれ以降のモジュール部分はロード済みなら通信しません

その他

他には 方法 2 で HTML を fetch するのが非効率なので 事前に HTML を同名 .js ファイルとは別の .js ファイルに変換することを考えました
この .js ファイルをインポートすると head と body の DocumentFragment と同名 .js ファイルの render 関数を受け取れるというものです
作るのはそれほど難しくないですが 事前にビルドが必要になるのは避けたかったのでやめました

ビルドするなら 方法 1 をルーティング固定にして .js ファイルから HTML に変換して初回のアクセスを index.html から各 HTML ファイルにするのでも良さそうです

まとめ

とりあえず作ってみましたが使うかと言うとどうなんでしょう
ビルドなしで独立したページを SPA 表示するならこういう感じにしかならなそうですけど そこまで積極的に使いたい気持ちにはなってません

やっぱりデータを共有するようなページでもないなら独立して新規にページを開いてくれる方が気持ち的にスッキリするのかもしれません