Vueページは必ずWorkerバンドルに載る — 静的HTML化と「島マウント」でCloudflare Workersの上限から逃げる
朝いちで確かめたかったのは、自分の理解が合っているかどうかだった。
レッスンページを全部 .html に書き出す、という計画をずっと進めてもらっていた。ただ前提を飲み込めていない。ページは Cloudflare Pages に置いてあるのに、逼迫しているのは Workers のバンドルだと言う。Pages に置いたまま Vue ページのままでいる、というパターンもありえるじゃないか。ずっとそれでやってきたつもりだった。
実測させたら、答えは短かった。Vue ページのままだと必ず Worker バンドルに載る。
Nuxt の SSR は Worker の上で動く。ページの本文が .vue の中にあるかぎり、その文字列はサーバーコードの一部としてバンドルされる。外部 HTML に出してしまえば静的アセットになって、バンドルから消える。Pages か Workers かという置き場所の話ではなかった。
ここまで来て、当たり前の系が見えた。コンポーネントで組み立てて使い回しているコンテンツは、純粋な静的 HTML に変換するのがすごく難しい。逆に完全なテキストのページなら、.html への置き換えは難しくない。
そう確認したら、「テキストだけなら簡単」の側にも実証済みの落とし穴が2つある、と返ってきた。単純な置き換えでは済まないらしい。
課金で上限は上がる。上げないと決めた
念のため、公式で料金を確認させた。月 $5 で 3 MiB → 10 MiB。Pages 経由でも同じ枠かどうかは過去に別枠だった経緯があるので、そこも確認させた。同じだった。
3倍以上になる。払える額だ。それでも上げないことにした。
静的ファイルで持てばいいデータをサーバーコードの中に抱えている、という状態そのものが引っかかった。最初からそうしておくべきだったやつだ。インタラクティブなコンテンツだけは静的化が難しいのは分かる。ただ、全部が全部そういうコンテンツではない。
だから最初にやりたかったのは削減作業ではなく、切り分けだった。どれが完全に静的ファイルで持てるのか、どれがどうしても Vue のまま残るのか。そのデューデリジェンスを走らせた。
公開 JS に何が載っているか
DD と並行して、もう一つ点検させた。公開 JS に載ってはいけないデータが混ざっていないか。
2件出てきた。
一つはクイズの正解データ。フロントの JS にそのまま入っていて、認証なしで読める。これは外出しの判断に直結した。もともと誰でも読める場所にあるのだから、公開ディレクトリの JSON に移しても読める範囲は変わらない。むしろバンドルから抜けるぶんだけ得になる。外出し・公開ディレクトリで決めた。
もう一つは draft(非公開)の本文が API から返っている件。本番の未認証 curl で、3経路とも本文が返ることを確認させた。ローカルだけの話ではなかった。ビルド時に除去する仕組みを入れさせて、そのうえで「除去漏れを検知できるか」を逆テストで確かめさせた。通した数だけ数えて安心するのが怖い。
クイズ JSON の外出しで、3 MiB 上限に対する残り(margin)が 136.8 → 380.1 KiB になった。2.8倍。
デプロイの進捗が見えない
デプロイに移った。6〜7分の待ちのはずが、監視が空振りした。
原因は自分が仕込んだ側にあった。デプロイ出力を | tail -40 でパイプしていたので、進捗行がバッファに溜まって流れてこない。生きているのか死んでいるのか分からないまま待つことになる。
しかも1回目はビルド途中で中断していた。画像159枚がビルド用に一時退避されたままだったので、そこから復元させた。
2回目は追跡方法を変えさせた。パイプを外して出力をログファイルへ直接書き、そのファイルを追う。監視キーワードも「完了」を示す語だけでなく、エラーやサイズ超過まで拾うように広げた。今度は進捗が見えた。
margin 380.4 KiB でビルド完了。gzip 2.629 MiB / 3.0 MiB。draft 除去は本番ビルドでも動いて26件を落とし、フェイルセーフの再検査も通った。
本番で draft の本文がまだ 200 で返ってきて、手が止まった。pages.dev 側は 404 で塞がっている。原因はキャッシュで、しかもそのキャッシュを作ったのは自分の検証アクセスだった。
ClientOnly は SSR に痕跡を残さない
計画の本命はここから先だった。コンポーネントを使っているページを諦めるのか、という話になる。諦めたくはない。本文を静的 HTML にして、必要な箇所だけ後から Vue を差し込めば、両方取れるはずだ。計画で島マウント機構と呼んでいたのは、これのことだった。
実装させる前に、実測が1つ必要になった。静的 HTML のどこに島があるのか、どうやって特定するか。
<ClientOnly> は SSR に痕跡をまったく残さない。残るのは中身も目印もない空の <span></span> だけで、これでは HTML 側から「ここに島があった」と復元できない。そこで、変換のときにソース側から島の位置と props を採取して、HTML に目印を埋め込む方式にした。島を探すのではなく、置いてくる。
機構本体を実装させて、テスト11件。コンバータを書かせて dry-run したら、19ページで島131個を検出した。共有 CSS に無いセレクタが28種出たが、ページ間で定義が完全に一致していたので、そのまま共有 CSS へ寄せた。
1ページだけ変換してパイロットにした。.vue を消して HTML 経路に切り替え、変換前に採取しておいたベースラインとバイト比較する。差は78バイトだけ。<span></span> が13バイト×6個で、完全に説明がつく。検証スクリプトを補正して、本文はバイト一致になった。
スクリーンショットは MD5 まで同一だった。それでも自分の目で見た。仕訳例の島が描画されていて、クリックも通る。
残り18ページを変換させたら、19ページ中18ページ合格。落ちた1ページは、コンポーネントが <ClientOnly> の外にあって SSR されていた。島化すると本文が後退する。このページは対象から外した。
テスト3,109件パス。margin 380.4 → 584.1 KiB。DD が「このファミリ全体で242.4 KiB」と予測していたぶんの84%を、25ページ中18ページで取れたことになる。
世代トークン
Codex にレビューさせたら3件出てきた。どれも妥当だったので全部直させた。書き留めておきたいのは2件。
一つは競合状態。島の動的 import が走っている最中にページを離れると、遅れて解決した import が、もう存在しない場所へ書き込もうとする。世代トークンを持たせて、世代が変わっていたら結果を捨てる形にさせた。
もう一つはコンバータの穴で、変換できなかったページを成功扱いしてしまう。除外リストを明示させて塞いだ。
ピクセル比較が拾ったもの
設例100本の講座(132ページ)も同じ型で流した。
こちらは CSS がページ間で食い違っていた。24種のスタイルシートのうち、2種で102ページをカバーしていて、そのグループ内はバイト一致。この102ページだけを対象にして、残りは個別 CSS なので今回は見送った。
102/102 バイト一致。島のマウントも全ページ確認できた。それでもピクセル比較をかけたら、ページ全体が51〜61px 縮んでいた。焼き込んだページャとパンくずの、コンポーネント CSS が読み込まれなくなっていた。本文のバイトは一致しているのに、見た目が縮む。共有 CSS へ移設して、ハッシュも scrollHeight も一致した。
朝に言われた「落とし穴」は、たぶんこの手のことだったのだろう。テキストだけのページでも、見た目は本文のバイトだけでは決まらない。
margin 584.2 → 656.3 KiB。この日の合計で 136.8 → 656.3 KiB になった。
破棄まわりのテストがない
デプロイして、本番を確認させた。
図が SSR で出ているかは、図の中身の文言で確認させた。画像として見えるかではなく、その文字列が HTML に含まれているか。これなら curl で判定がつく。
もう一度 Codex にレビューさせたら、実質的な指摘は1件だけだった。破棄まわりのテストがない。既存のテストは全部が純粋関数(props のパースと島の収集)を対象にしていて、副作用側は未テストのままだった。つまり、破棄と世代トークンという、まさに事故った種類のロジックに自動の歯止めがない。実装は正しいが、次に誰かが触ったとき静かに壊れる。
テストを9件追加させた。書かせる過程でだいぶ転んでいて、render 関数で ref が解決されていない、attachTo を指定していないので要素が document に繋がらず isConnected チェックで弾かれる、といった順に潰していった。
大事なのはその次だった。世代トークンを一時的に外して、テストが本当に落ちるかを確かめさせた。落ちた。破棄側も同じように確かめた。歯止めが効くことを見てから実装を戻して、22件パス。
計画の番号どおりには進んでいない
途中で、進めていた計画書を開いて突き合わせさせた。
セッション番号どおりには進んでいなかった。予定していた順序を飛ばしたり、まとめたりしている。それでも計画の目標値は超えていた。番号を守ることが目的ではないので、これでいい。計画書と DD の両方に、予測と実測の照合を入れて記録させた。
コンテキストがパンパンになってきたので、引き継ぎドキュメントを作らせて別セッションへ渡した。渡した先はもう次の講座(教科書系・12章)に入っていて、こちらが様子を見たときには ch01 のパイロットが終わっていた。図121個すべてが <style scoped> を持っていて、焼き込むと data-v が落ちて色とフォントが効かなくなる。別の落とし穴を踏んで、直したところだった。
学び
- Vue ページの本文は Worker バンドルの一部。Pages に置いていても関係ない
- 静的 HTML にしても Vue が必要な箇所は残る。本文とインタラクションを分けて後から差し込めば、両方取れる
<ClientOnly>の SSR 出力は空の<span>だけ。HTML 側から島を探すのではなく、変換時に目印を埋め込む- バイト一致は見た目の一致を保証しない。ページャの CSS が落ちて51〜61px 縮んだのは、ピクセル比較でしか見つからなかった
- テストを足したら、一度壊して落ちることを確かめる。通ったことしか見ていないテストは歯止めになっていない
- デプロイ出力をパイプに通すとバッファに溜まる。ログファイルへ直接書いて追う
明日やること
- 教科書系の講座の残り11章を、ch01 と同じ型で変換する
- 設例100本の講座の残り29ページは個別 CSS なので、見た目が変わる判断が要る。触る前にどこまで揃えるかを決める