Chienomi

SSGを新たな次元に。これが「本当の」Jamstack, そしてThe Lean Web

プログラミング::technique

Signal & Marginaliaの記事で、私は「動的要素をJavaScriptにすることで、PureBuilder Simplyの可能性を広げる」という話をした。

ただ、あの記事の内容だと、単なるHybrid CSR1を新たな発見であるかのように大げさに言っているように感じるかもしれない。

たが、実際はもっと徹底した手法であり、新しい(あるいは注目されていない)アプローチである。

そしてそれは、Mathias Billmannが提唱した本来のJamstackの姿でもある。

ここではそのアプローチがどういうものか、解説していこう。

原理主義的最小要素

私はSignal & Marginaliaでは「サイトの基本要素をまとめたスクリプト」というChienomiでもとられているような手法を選択した。

だが、実はもっと原理主義な方法がある。 ここでは最新記事をサイドバーに含め、なおかつリビルドを避けるためにCSRを選択したとしよう。Signal & Marginaliaと同じようにだ。

このスクリプトは次のようにロードされる。

<script src="/scripts/elements/recent-articles.js" type="module"></script>

recent-articles.jsは次のようなスクリプトだ。

import data from '/dyn/recent-articles.json' with {type: 'json'}
const ul = document.getElementById("RecentArticles")

data.forEach(i => {
  const li = document.createElement("li")
  const a = document.createElement("a")
  a.href = i.path
  a.textContent = `${i.title} (${i.date})`
  li.appendChild(a)
  ul.appendChild(li)
})

こうしておくとrecent-articles.jsrecent-articles.jsonはまるで一体であるかのようにロードされる。 これらの構成要素をHTMLに並べておけば、それらは並列にロードされる。

importの形をとっているとJSONファイルは非常に強くキャッシュされやすい。 これは、HTMLに並べられているスクリプトと同等に、ということだ。 この方法で非常に速くロードできるのはHTTP/2.0を使っていることが前提になるが、キャッシュされている状態であればこの前提も必要ない。

recent-articles.jsonは強くキャッシュして良いのか?

この答えはそのデータの性質による。 リアルタイム性を持っているデータならばそのようにキャッシュすべきではなく、Signal & Marginaliaが採用した方法のほうが適している。

const ul = document.getElementById("RecentArticles")
fetch("/meta/recent.json").then(async res => {
  // ...
}

スクリプトを単一のコンテンツをロードするだけのものにすることで、失敗に対する考慮を減らせる。 何らかの理由でロード、あるいはレンダリングに失敗してスクリプトが死んだとしても、それはその要素がレンダリングされないという結果に閉じ込められるからだ。 例外処理したところであまり救えない部分(せいぜい「ロードに失敗しました」と表示する程度)なので、例外処理を放棄することすら可能になる。

そして、「スクリプト」というとプログラムであるという考え方が普通だが、このスクリプトは単にコンテンツをロードし、DOMに変換しているだけだ。 もしHTMLに直接的にJSONをDOMに変換するための方法が提供されていたならば、スクリプトは必要ない可能性がある。

このDOM操作は一種のテンプレートのようなものであり、JSXほど直感的ではないが、書き心地はJavaScriptというよりHTMLに近い。 フォーカスしているのは「ここに埋め込むべきコンテンツのURLとHTML」だからだ。

もっと原理主義的コード

まず言っておこう。

「CSRはレイアウトシフトを発生させる」という主張がある。

そんな人に聞くが、YouTubeやXやFacebookやInstagramで、タップしたときと描画がズレて違う要素が反応する、というような体験をしたことはないのだろうか。

SPAならCLSを発生させない、というのは幻想だ。 ここで問題になるのは純粋なタイムラグだ。そして、タイムラグは大きなスクリプトをロードし、膨大な計算をして描画するよりも、極めて小さなスクリプトをロードし、一周して描画するだけのほうが圧倒的に小さい。

だがそれでも原理的にCLSが発生するのは悪だ、と主張する人もいるだろう。 別にそれがCSRだから発生するわけではないにも関わらずだ。

だが、この手法においてはその主張を完全に退ける方法がある。同期的描画だ。 例えばこんな感じ。

<div id="RecentArticles"></div>
<script src="/scripts/elements/recent-articles.js"></script>

知覚出来ない一瞬に発生する更新処理を排除することが並列実行を阻害しないことよりも重要だと主張するのであれば、この方法が完全な解になる。 もちろんおすすめはしない。なぜならば、さきほどの原理主義的コードによって表示が反映されるまでの時間を、私は知覚できないからだ。

だがこの方法にもメリットはある。「ここに埋め込む」ということを宣言的に書けることだ。

この場合、私は別の方法を推奨する。 ページの構成要素をレンダリングするためのスクリプトは、予めロードしてしまう。ここは同期的にロードする必要がある。

そこではこのように書いておく。

function recentArticles() {
  const ul = document.createElement("ul")
  fetch("/meta/recent.json").then(async res => {
    const data = await res.json()
    data.forEach(i => {
      const li = document.createElement("li")
      const a = document.createElement("a")
      a.href = i.path
      a.textContent = `${i.title} (${i.date})`
      li.appendChild(a)
      ul.appendChild(li)
    })
  })
  return ul.outerHTML
}

そしてこのようにして使う。

<div id="RecentArticles">
  <script>document.write(recentArticles())</script>
</div>

そう、早々に邪悪の塊扱いされていたdocument.write()の復活である。 HTMLの見た目的には埋め込みコンテンツのロードと変わらないので意外と良い。

return ul.outerHTML

でなく

document.write(ul.outerHTML)

してもいいのだけど、document.write()はストリームをクローズしたあとに呼び出すと問題があり、そのような副作用のある関数を生み出すよりは、関数は要素をreturnするだけでそれをHTMLに埋め込むのはインラインスクリプトにするほうが綺麗だと思う。 もちろん、これはインラインスクリプトを禁止する昨今の流れには反する。

なお、私のおすすめは先述の通り、レイアウトシフトなど気にせず後からコンテンツを埋めることである。 昨今の巨大なフレームワークでやるよりは百倍マシなので。

事前生成戦略の拡張

先の記事で述べた通り、PureBuilderシリーズはvs SSR (WordPress)という意識が強いソフトウェアである。

これはつまり、「動的vs静的」という土俵に上がりやすく、「動的に見える要素も事前に生成することは可能だ」という主張をせざるをえないことになりやすい。

これはこれで事実なのだが、これを実現するために静的なHTMLにこだわってしまうと、どうしてもリビルドコストが上がる。 そうなると「生成はプレビルトかオンデマンドか」という話になってしまい、生成頻度に依存する焦点になる。

ここにハイブリッドCSRを持ち込むとどうなるか。

この場合のAPIレスポンスは動的(サーバーアプリケーションによる生成)であっても静的(プレビルトなJSON)であっても構わない。 そして、それは「生成タイミングが最適化されている」という話になる。

HTMLが生成されるのは、コンテンツが生成あるいは更新されたタイミングである。 そして、ハイブリッド化することでここに含まれていなければならないものは、本文要素として持っていなくてはならないものだけになる。 HTMLに含むものが、HTML生成時に確定できる情報だけであるとすれば、それらは必然的に含まれる。

一方分離されたコンテンツはJSONであり、これをプレビルトする前提において、生成タイミングはそれぞれのHTMLで生成されるタイミングとは切り離すことができる。 「最新記事一覧」であれば最後にHTMLを生成したタイミングで更新することができるし、サーバー上のスクリプトによって更新されるようにすればHTMLのビルドサイクルから完全に切り離すことも可能だ。

また、事前生成のみを善としているわけではない。 ライブなデータを必要とするのであれば、必要としている部分のみウェブサーバーアプリケーションにすれば良い。 ウェブサーバーアプリケーションが限定的な責務を持つことで、非常に小さく簡潔に書ける。

プレビルトの場合、コンテンツとなるJSONファイルを書く者を単一とすることで排他制御も不要となり、データが更新されるタイミングでレスポンスコンテンツも書き換えることが可能になる。

PureBuilder Simplyは既に「HTML生成時にHTML生成以外のフックになる」ための方法を多数提供しており、CSR要素の組み込みはPureBuilder Simplyによる事前生成で提供できるコンテンツの幅を広げることになる。

それに加えてPureBuilder Simply外のコンテンツをシームレスに統合できるようになる。

PureBuilder Simplyで生成されるコンテンツは、基本的にBuild以下に出力され、サーバーにpushすることで公開されるという性質を持っている。 PureBuilder Simplyだけの世界を想定するならばこれは分かりやすいが、フローを不自由にする制限になる場合がある。

CSRの組み込みはPureBuilder Simplyのグルー的側面を強化し、異なるコンテンツの提供元を受け入れやすくする。

この「生成タイミングと生成者が最適化される」ことにより、プレビルトを選択できるものの範囲が増え、SSGとSSRが二者択一ではなくおいしいとこどりが可能になる。

名を与えよ

このデザイン、アーキテクチャだが、「CSR」ではない。 SSGを基本としているため部分的にCSRを組み込んでおり、実体としてはSSG + CSR Hybridだ。

が、それはあまりにも説明的すぎてアーキテクチャの名前にはなっていない。 このアプローチはうまく説明するためのバズワードを持っていない。

例えばStatic APIでもあるが、Static APIであることを要件にはしていない。 むしろStaticであるかDynamicであるかは適材適所で決めろという感じだ。

しかし、私しか考えないようなことではないということが、これに相当するバズっていないワードに存在することから読み取れる。

そう、Jamstackだ。 Jamstackという言葉は色々とこねくり回された結果、もとの意味とは完全に異なった形で広まってしまっている。 だが、Mathias Biilmannが提唱したJamStackは、ビルドされたHTMLファイルを配信し、動的処理をサーバーから切り離し、JavaScriptは動的な処理(APIコール)だけを最小限に行う、というものであった。

だが残念ながら、本来の意味でJamstackという言葉を使っても正しく伝わらない。 それを生み出したNetlify自身がその言葉を避けるようになったことからもそれは明らかだ。

だがまだ道はある。 Chris Ferdinandiが提唱するThe Lean Webだ。 Vanilla JSを愛する彼とは気が合いそうだ。

というわけで、私はこれからはこの “The Lean Web” という言葉を推していこうと思う。


  1. ウェブページのいち部分をJavaScriptによってロードし、DOMを構築する手法↩︎