gray deep well pump surrounded by flowers during daytime

タイトルは英語のままなのは、日本語にしようとしても結局全部カタカナになるので放置。さてre-frameは俺的にClojureScriptでウェブのフロントエンド作るにあたって標準装備になっているが、ものによっては完全動的に生成されるReactのアプリよりも、検索エンジンなどでも絶対拾える静的HTMLを返した方がいいという場合もある。今回は流行り言葉をいっぱい使えるように、Denoを活かしてClojureScriptアプリのサーバーサイドレンダリングを実現しようと思います。動くコードはGithubにて

まずSSRとはなんぞやから。ガチャゲームならずウェブ開発の話なのでもちろん虹のことではなく、普段ブラウザなどフロントエンドで行われる処理を予めサーバー側で行って、その結果を静的HTMLとして返す。re-frameの場合はブラウザだと reagent.dom/render を使ってReactのコンポーネントを生成するが、それをサーバー側で ReactDOMServer.renderToString で代替し、ブラウザ側で react-dom/hydrate でそれを「蘇らせる」。ただちょっとした罠はいくつでもあるので紹介しよう。

documentがない

Denoはwindowオブジェクトがあるのに、documentはない。なので、documentの存在に依存するコードはそのdocumentが存在するのかをClojureScriptの exists? で確認する必要がある。例えばHTMLのタイトルを設定する処理(re-frameなら reg-fx のエフェクト)があると、set! する前に確認する必要がある。

(rf/reg-fx
 :title
 (fn [title]
   (when (exists? js/document)
     (set! js/document.title (str title " | すてきな私のアプリ")))))

また、shadow-cljsを使っている場合、SSRに渡すコードは開発用の魔法いっぱい詰まったビルドではだめだ。shadow-cljsがいろんな強力な開発サポート機能を提供するし、開発ビルドはほとんどの効率化は図られてないのでGoogle Closureなど依存ライブラリのdocument参照が引っかかってしまう。そのため、開発中でもSSRする必要があるコードは生のshadow-cljs watchではなく、compileしないと機能しない。shadow-cljsはそのためのdevtools配下のツールはあるので、自前でいちいちcompileを呼ぶ疑似watchを作ることは可能。

locationがない

flat ray photography of book, pencil, camera, and with lens

Denoはlocationもないのでlocation(History API)を使ったルーターも残念ながら機能しない。例えばreititを使って :use-fragment false でルーターを作ると、そのHistory APIを使うのでSSRでは意図した通りには動かない。普段はreititがブラウザからそのHistory APIを使って、今のパスとかを取得して適切なルートに遷移してくれるが、それがない以上代替する必要がある。

reititのフロントエンドのルーターは一般的に reitit.frontend.easy/start! で起動するが、そいつに渡す遷移時のコールバックを手動で真似る。re-frameなら遷移を示すイベントを dispatch する。そしてサーバー側でそれを行うなら、ルーターは動かないので手動で reitit.core/match-by-path で該当ルートの存在確認してから遷移イベントを dispatch する。

「完了」を意識しないといけない

上記のように起動時やページ遷移時に動く処理を手動で呼ぶ必要があるサーバー側レンダリングだが、一番ややこしいのはやっぱり「完了」がないという点だ。一般的にサーバー側でリクエストが飛んできたときにその処理がレスポンスを返して終わり。一方でブラウザ側でフロントエンドのアプリがページが読み込まれたタイミングで起動し、動きっぱなし。問題は、そのフロントエンドのアプリをサーバー側でレンダーしたい場合、その「動きっぱなし」だといつの時点でレンダーすればいいのかわからない。

HTML自体でいうとonLoadとかイベントはあるが、Reactのアプリだとそれもちょっと違う。ブラウザでいうとページに遷移して、サーバーのAPIから諸々取得して、ステート(状態)を構築しそれをもとにDOMを生成してレンダー。ブラウザだとこれが流動的に行われて、反応できる(Reactイブな)状態にある。サーバー側だと、状態が整ってレンダーしたい時点で処理が終了させ renderToString する必要がある。

この完了問題に対応するための工夫や方法はいろいろあると思う。俺が考えついたのは、

つまりステート構築に必要なものが揃うまで待って、コールバックを使って処理の完了を連絡する。今回の例コードだとHTTPリクエストを一つ待っているだけだけど、別にどんな処理をいくらでも待ってられる(その分もちろんDenoのレスポンスが遅くなる)。

このやり方だと例えばリクエストのパスがルートとして存在するもの(つまり前持って「ない」とは言い切れない場合)でも、これだとページ表示に必要なAPIリクエストが済むので確実に200か404かも判断がつく。

今回の例コードではDeno側はClojureScriptじゃなく生のTypeScriptを使っている。ClojureScriptももちろんDenoで機能するので理想は全部ClojureScriptにしたいが、それはまた別の日の話。