◆ SPA にはせずページ遷移するけど画面はブラウザ側で作る
◆ HTML に JSON 埋め込みでもいいけど毎回 API 呼び出しでもいい気がする
◆ HTML に書く内容がなさすぎて HTML じゃない形式が欲しくなる

SPA にするとライブラリとかツールが色々必要になることがあって面倒が多めです
また 標準以外のライブラリ類を使うときは ある程度長く使う前提だとそのときよく使われてるしで選ぶと廃れた後に面倒が多いです
かと言って昔ながらの SSR はしたくないです
画面遷移などはブラウザにまかせる MPA で CSR すれば外部ライブラリ依存はそれほどなくていいし 各ページごとに独立するので つかいたいところでだけ使ってれば廃れたとしても影響は減りそうです

ということで最近は MPA で CSR を試してみてます

HTML にデータを埋め込む場合

少し前は ページ遷移のときに毎回 HTML ファイルをダウンロードするんだし GET リクエストでの最初の画面表示に使う分のデータは HTML に JSON で埋め込んでおくことをしてました
1 回分の API 通信が失くせますし ページごとの API ではなくリソースごとの API になっていると ページによっては初回表示のために数個の API を呼び出すことになるので通信数をさらに減らせます
画面表示が速くなるかもです

ブラウザが受け取る HTML はこういう感じです

<!doctype html>
<meta charset="utf-8"/>

<script id="data" type="application/json">
{
"module": "module-name",
"data": {"key": "value"}
}
</script>

<script type="module" src="/modules/render.js">

JSON 部分はサーバサイドで埋め込む動的なところです
module にはページを表示するモジュールの .js ファイルの名前を入れて data には画面を表示するためのデータを入れます

render.js では この JSON を読み取って モジュールをインポートしてその関数に data を渡します

const data_elem = document.getElementById("data")
const { module, data } = JSON.parse(data_elem.textContent)

import(`/modules/pages/${module}.js`).then(mod => {
mod.default(data)
})

あとは pages/foo.js だったり pages/bar.js だったりが 画面を作ります

SPA にしないので GET の場合の JSON だけが欲しいことはないのでこれで悪くはないです
ですが GET/POST での扱いの違いがあったり JSON だけでやりとりする API 専用のサーバにしてしまいたい気持ちもあったり テスト時は API のほうが楽だなと思ったりで HTML に JSON を埋め込むのが良いのか疑問もありました

あとこの方法ではルーティングはサーバサイドで決めることになります
サーバは API だけ知ってれば良くてクライアント側でどの URL だとどういうページが出てるかを知らなくていいと思うのでルーティングもクライアント側で行うようにしたいと思います

API のみにする

HTML に埋め込む必要はなくなるので HTML は常にこれを返すようにします

<!doctype html>
<meta charset="utf-8"/>

<script type="module" src="/module/router.js"></script>

最初に読み込むのが /module/router.js から変わるような場合は対応できないですが ここを変えないのなら HTML ファイルを長期キャッシュしてしまえます

router.js では location.pathname に応じてモジュールをロードし インポートした関数を呼び出します
中身は if 文の繰り返しでも 正規表現とモジュール名のマッピングでもやり方は自由です

const routes = [
["/about", "about"],
["/articles", "articles"],
[/^\/article\/(?<id>[^\/]+)$/, "article"],
]

const getMatched = function*() {
const path = location.pathname
for (const [condition, module_name] of routes) {
if (typeof condition === "string") {
if (path === condition) yield { params: {}, module_name }
} else if (condition instanceof RegExp) {
const matched = path.match(condition)
if (matched) yield { params: matched.groups, module_name }
}
}
}

for (const { module_name, params } of getMatched()) {
const { render } = await import(`/module/pages/${module_name}.js`)

// If render function returns truthy value,
// render the next matched module.
if (!(await render(params))) {
break
}
}

この例では各モジュールの render 関数を呼び出すようにしてます
この関数で各ページの画面を作ります

API 呼び出しがいらないページの例はこういうのです

/module/pages/about.js
export const render = () => {
document.title = "About"

document.body.innerHTML = `
<h1>About</h1>
<p>This is test page.</p>
`
}

API を呼び出すなら render 関数内の処理で呼び出します

/module/pages/articles.js
export const render = async () => {
const articles = await fetch("/api/articles").then(res => res.json())

document.title = "Articles"
document.body.append(...articles.map(createArticleElement))
}

const createArticleElement = (article) => {
return ...
}

ページによっては DOM を直接操作せずライブラリで画面を作りたいこともあります
そういうところは各ページでライブラリを import して使います
lit-html を使う例です

/module/pages/article.js
import { html, render as litRender } from "https://unpkg.com/lit-html@2.2.2/lit-html.js"
import css from "./article.css" assert { type: "css" }

const template = article => html`
<h1>${article.title}</h1>
<div class="flex-row flex-justify-between">
<div class="tags">
${
article.tags.map(x => html`<span>${x}</span>`)
}
</div>
<div>
${new Date(article.timestamp).toLocaleString()}
</div>
</div>
<pre>${article.body}</pre>
`

export const render = async ({ id }) => {
const article = await fetch(`/api/article/${id}`).then(res => res.json())
const render_root = document.createElement("div")
document.body.append(render_root)
litRender(template(article), render_root)

document.title = "Article: " + article.title
document.adoptedStyleSheets = [css]
}

html いらない

という感じのことをしていて MPA でも CSR が前提だと HTML ファイルいるのかなって気がしてきます

HTML ファイルの中身はほぼ空で head タグに書く少しのタグだけです
あとは JavaScript で作らないなら root という id をつけた div タグ程度でしょうか
head タグには 書かないと日本語文字が正しく表示されないので utf-8 の指定を入れて あとは script タグでロードするモジュールを指定するくらいです
タイトルや CSS やファビコン等は JavaScript から設定すればいいので HTML にいれなくてもいいです
SEO を気にするなら meta タグ系は HTML に入れておいたほうがいいかもしれませんが 個人的には気にしません

そうなると HTML というよりも単純な key/value 形式で head タグに入れる内容だけ記述するシンプルなフォーマットにしたくなります
ブラウザが標準では対応しないでしょうけど 自分が扱う分にはサーバ側で HTML ファイル形式がいらなくしたいです
ブラウザが対応しないことには HTML 形式でレスポンスを返すことになるので 専用フォーマットで書いて内部的に HTML 化してしまいます

const pageToHtml = (file) => {
const str = fs.readFileSync(file).toString()
const tags = []
for (const line of str.split("\n")) {
const idx = line.indexOf("=")
if (idx < 0) continue
const key = line.slice(0, idx).trim()
const value = line.slice(idx + 1).trim()

if (key === "module") {
tags.push(html`<script type="module" src="${value}"></script>`)
}

if (key === "script") {
tags.push(html`<script src="${value}"></script>`)
}

if (key === "css") {
tags.push(html`<link rel="stylesheet" href="${value}"/>`)
}

// ...
}
return `<!doctype html><meta charset="utf-8"/>${tags.join("")}`
}

foo.page というファイルで

module = /pages/foo.js

と書いておいて foo.page のページにアクセスがあれば上の関数を使って HTML 化したレスポンスを返します

<!doctype html><meta charset="utf-8"/><script type="module" src="/pages/foo.js"></script>

でも実際のところ module の 1 行しか書かないことがほとんどで 関数を使っての変換が必要になるなら

modules.json とかに

{
"foo": "/pages/foo.js",
"bar": "/pages/bar.js"
}

みたいなルールをまとめて書いておいて

const toHtml = (page) => {
const path = modules[page]
return `<!doctype html><meta charset="utf-8"/><script type="module" src="${path}"></script>`
}

とするだけでもいい気もします

サーバ内で HTML 化だとそこまで便利なのか微妙ですが プロキシを通してそこで独自の content-type のレスポンスなら HTML に変換して返すようにしておけば サーバとしては HTML じゃない新しい形式として書けるので面白いかもですね