[{"data":1,"prerenderedAt":339},["ShallowReactive",2],{"content-/worker-bundle-reduction-content-islands":3,"all-pages-for-dir":316,"related-/worker-bundle-reduction-content-islands":317,"og-image-/worker-bundle-reduction-content-islands":338},{"id":4,"title":5,"body":6,"category":297,"concepts":298,"description":299,"extension":300,"meta":301,"navigation":274,"ogImage":298,"path":302,"project_name":303,"published":304,"publishedAt":305,"seo":306,"stem":307,"tags":308,"todo":298,"unpublished":304,"updatedAt":298,"__hash__":315},"pages/2026-07/2026-07-29/worker-bundle-reduction-content-islands.md","Vueページは必ずWorkerバンドルに載る — 静的HTML化と「島マウント」でCloudflare Workersの上限から逃げる",{"type":7,"value":8,"toc":283},"minimark",[9,13,21,28,35,41,44,49,52,55,58,61,65,68,71,74,77,80,83,86,93,96,99,102,105,109,112,115,125,128,137,140,146,149,152,155,158,161,164,167,170,173,176,179,182,185,188,191,206,209,212,215,218,229,232,260,263],[10,11,12],"p",{},"朝いちで確かめたかったのは、自分の理解が合っているかどうかだった。",[10,14,15,16,20],{},"レッスンページを全部 ",[17,18,19],"code",{},".html"," に書き出す、という計画をずっと進めてもらっていた。ただ前提を飲み込めていない。ページは Cloudflare Pages に置いてあるのに、逼迫しているのは Workers のバンドルだと言う。Pages に置いたまま Vue ページのままでいる、というパターンもありえるじゃないか。ずっとそれでやってきたつもりだった。",[10,22,23,24],{},"実測させたら、答えは短かった。",[25,26,27],"strong",{},"Vue ページのままだと必ず Worker バンドルに載る。",[10,29,30,31,34],{},"Nuxt の SSR は Worker の上で動く。ページの本文が ",[17,32,33],{},".vue"," の中にあるかぎり、その文字列はサーバーコードの一部としてバンドルされる。外部 HTML に出してしまえば静的アセットになって、バンドルから消える。Pages か Workers かという置き場所の話ではなかった。",[10,36,37,38,40],{},"ここまで来て、当たり前の系が見えた。コンポーネントで組み立てて使い回しているコンテンツは、純粋な静的 HTML に変換するのがすごく難しい。逆に完全なテキストのページなら、",[17,39,19],{}," への置き換えは難しくない。",[10,42,43],{},"そう確認したら、「テキストだけなら簡単」の側にも実証済みの落とし穴が2つある、と返ってきた。単純な置き換えでは済まないらしい。",[45,46,48],"h2",{"id":47},"課金で上限は上がる上げないと決めた","課金で上限は上がる。上げないと決めた",[10,50,51],{},"念のため、公式で料金を確認させた。月 $5 で 3 MiB → 10 MiB。Pages 経由でも同じ枠かどうかは過去に別枠だった経緯があるので、そこも確認させた。同じだった。",[10,53,54],{},"3倍以上になる。払える額だ。それでも上げないことにした。",[10,56,57],{},"静的ファイルで持てばいいデータをサーバーコードの中に抱えている、という状態そのものが引っかかった。最初からそうしておくべきだったやつだ。インタラクティブなコンテンツだけは静的化が難しいのは分かる。ただ、全部が全部そういうコンテンツではない。",[10,59,60],{},"だから最初にやりたかったのは削減作業ではなく、切り分けだった。どれが完全に静的ファイルで持てるのか、どれがどうしても Vue のまま残るのか。そのデューデリジェンスを走らせた。",[45,62,64],{"id":63},"公開-js-に何が載っているか","公開 JS に何が載っているか",[10,66,67],{},"DD と並行して、もう一つ点検させた。公開 JS に載ってはいけないデータが混ざっていないか。",[10,69,70],{},"2件出てきた。",[10,72,73],{},"一つはクイズの正解データ。フロントの JS にそのまま入っていて、認証なしで読める。これは外出しの判断に直結した。もともと誰でも読める場所にあるのだから、公開ディレクトリの JSON に移しても読める範囲は変わらない。むしろバンドルから抜けるぶんだけ得になる。外出し・公開ディレクトリで決めた。",[10,75,76],{},"もう一つは draft（非公開）の本文が API から返っている件。本番の未認証 curl で、3経路とも本文が返ることを確認させた。ローカルだけの話ではなかった。ビルド時に除去する仕組みを入れさせて、そのうえで「除去漏れを検知できるか」を逆テストで確かめさせた。通した数だけ数えて安心するのが怖い。",[10,78,79],{},"クイズ JSON の外出しで、3 MiB 上限に対する残り（margin）が 136.8 → 380.1 KiB になった。2.8倍。",[45,81,82],{"id":82},"デプロイの進捗が見えない",[10,84,85],{},"デプロイに移った。6〜7分の待ちのはずが、監視が空振りした。",[10,87,88,89,92],{},"原因は自分が仕込んだ側にあった。デプロイ出力を ",[17,90,91],{},"| tail -40"," でパイプしていたので、進捗行がバッファに溜まって流れてこない。生きているのか死んでいるのか分からないまま待つことになる。",[10,94,95],{},"しかも1回目はビルド途中で中断していた。画像159枚がビルド用に一時退避されたままだったので、そこから復元させた。",[10,97,98],{},"2回目は追跡方法を変えさせた。パイプを外して出力をログファイルへ直接書き、そのファイルを追う。監視キーワードも「完了」を示す語だけでなく、エラーやサイズ超過まで拾うように広げた。今度は進捗が見えた。",[10,100,101],{},"margin 380.4 KiB でビルド完了。gzip 2.629 MiB / 3.0 MiB。draft 除去は本番ビルドでも動いて26件を落とし、フェイルセーフの再検査も通った。",[10,103,104],{},"本番で draft の本文がまだ 200 で返ってきて、手が止まった。pages.dev 側は 404 で塞がっている。原因はキャッシュで、しかもそのキャッシュを作ったのは自分の検証アクセスだった。",[45,106,108],{"id":107},"clientonly-は-ssr-に痕跡を残さない","ClientOnly は SSR に痕跡を残さない",[10,110,111],{},"計画の本命はここから先だった。コンポーネントを使っているページを諦めるのか、という話になる。諦めたくはない。本文を静的 HTML にして、必要な箇所だけ後から Vue を差し込めば、両方取れるはずだ。計画で島マウント機構と呼んでいたのは、これのことだった。",[10,113,114],{},"実装させる前に、実測が1つ必要になった。静的 HTML のどこに島があるのか、どうやって特定するか。",[10,116,117,120,121,124],{},[17,118,119],{},"\u003CClientOnly>"," は SSR に痕跡をまったく残さない。残るのは中身も目印もない空の ",[17,122,123],{},"\u003Cspan>\u003C/span>"," だけで、これでは HTML 側から「ここに島があった」と復元できない。そこで、変換のときにソース側から島の位置と props を採取して、HTML に目印を埋め込む方式にした。島を探すのではなく、置いてくる。",[10,126,127],{},"機構本体を実装させて、テスト11件。コンバータを書かせて dry-run したら、19ページで島131個を検出した。共有 CSS に無いセレクタが28種出たが、ページ間で定義が完全に一致していたので、そのまま共有 CSS へ寄せた。",[10,129,130,131,133,134,136],{},"1ページだけ変換してパイロットにした。",[17,132,33],{}," を消して HTML 経路に切り替え、変換前に採取しておいたベースラインとバイト比較する。差は78バイトだけ。",[17,135,123],{}," が13バイト×6個で、完全に説明がつく。検証スクリプトを補正して、本文はバイト一致になった。",[10,138,139],{},"スクリーンショットは MD5 まで同一だった。それでも自分の目で見た。仕訳例の島が描画されていて、クリックも通る。",[10,141,142,143,145],{},"残り18ページを変換させたら、19ページ中18ページ合格。落ちた1ページは、コンポーネントが ",[17,144,119],{}," の外にあって SSR されていた。島化すると本文が後退する。このページは対象から外した。",[10,147,148],{},"テスト3,109件パス。margin 380.4 → 584.1 KiB。DD が「このファミリ全体で242.4 KiB」と予測していたぶんの84%を、25ページ中18ページで取れたことになる。",[45,150,151],{"id":151},"世代トークン",[10,153,154],{},"Codex にレビューさせたら3件出てきた。どれも妥当だったので全部直させた。書き留めておきたいのは2件。",[10,156,157],{},"一つは競合状態。島の動的 import が走っている最中にページを離れると、遅れて解決した import が、もう存在しない場所へ書き込もうとする。世代トークンを持たせて、世代が変わっていたら結果を捨てる形にさせた。",[10,159,160],{},"もう一つはコンバータの穴で、変換できなかったページを成功扱いしてしまう。除外リストを明示させて塞いだ。",[45,162,163],{"id":163},"ピクセル比較が拾ったもの",[10,165,166],{},"設例100本の講座（132ページ）も同じ型で流した。",[10,168,169],{},"こちらは CSS がページ間で食い違っていた。24種のスタイルシートのうち、2種で102ページをカバーしていて、そのグループ内はバイト一致。この102ページだけを対象にして、残りは個別 CSS なので今回は見送った。",[10,171,172],{},"102/102 バイト一致。島のマウントも全ページ確認できた。それでもピクセル比較をかけたら、ページ全体が51〜61px 縮んでいた。焼き込んだページャとパンくずの、コンポーネント CSS が読み込まれなくなっていた。本文のバイトは一致しているのに、見た目が縮む。共有 CSS へ移設して、ハッシュも scrollHeight も一致した。",[10,174,175],{},"朝に言われた「落とし穴」は、たぶんこの手のことだったのだろう。テキストだけのページでも、見た目は本文のバイトだけでは決まらない。",[10,177,178],{},"margin 584.2 → 656.3 KiB。この日の合計で 136.8 → 656.3 KiB になった。",[45,180,181],{"id":181},"破棄まわりのテストがない",[10,183,184],{},"デプロイして、本番を確認させた。",[10,186,187],{},"図が SSR で出ているかは、図の中身の文言で確認させた。画像として見えるかではなく、その文字列が HTML に含まれているか。これなら curl で判定がつく。",[10,189,190],{},"もう一度 Codex にレビューさせたら、実質的な指摘は1件だけだった。破棄まわりのテストがない。既存のテストは全部が純粋関数（props のパースと島の収集）を対象にしていて、副作用側は未テストのままだった。つまり、破棄と世代トークンという、まさに事故った種類のロジックに自動の歯止めがない。実装は正しいが、次に誰かが触ったとき静かに壊れる。",[10,192,193,194,197,198,201,202,205],{},"テストを9件追加させた。書かせる過程でだいぶ転んでいて、render 関数で ",[17,195,196],{},"ref"," が解決されていない、",[17,199,200],{},"attachTo"," を指定していないので要素が document に繋がらず ",[17,203,204],{},"isConnected"," チェックで弾かれる、といった順に潰していった。",[10,207,208],{},"大事なのはその次だった。世代トークンを一時的に外して、テストが本当に落ちるかを確かめさせた。落ちた。破棄側も同じように確かめた。歯止めが効くことを見てから実装を戻して、22件パス。",[45,210,211],{"id":211},"計画の番号どおりには進んでいない",[10,213,214],{},"途中で、進めていた計画書を開いて突き合わせさせた。",[10,216,217],{},"セッション番号どおりには進んでいなかった。予定していた順序を飛ばしたり、まとめたりしている。それでも計画の目標値は超えていた。番号を守ることが目的ではないので、これでいい。計画書と DD の両方に、予測と実測の照合を入れて記録させた。",[10,219,220,221,224,225,228],{},"コンテキストがパンパンになってきたので、引き継ぎドキュメントを作らせて別セッションへ渡した。渡した先はもう次の講座（教科書系・12章）に入っていて、こちらが様子を見たときには ch01 のパイロットが終わっていた。図121個すべてが ",[17,222,223],{},"\u003Cstyle scoped>"," を持っていて、焼き込むと ",[17,226,227],{},"data-v"," が落ちて色とフォントが効かなくなる。別の落とし穴を踏んで、直したところだった。",[45,230,231],{"id":231},"学び",[233,234,235,239,242,251,254,257],"ul",{},[236,237,238],"li",{},"Vue ページの本文は Worker バンドルの一部。Pages に置いていても関係ない",[236,240,241],{},"静的 HTML にしても Vue が必要な箇所は残る。本文とインタラクションを分けて後から差し込めば、両方取れる",[236,243,244,246,247,250],{},[17,245,119],{}," の SSR 出力は空の ",[17,248,249],{},"\u003Cspan>"," だけ。HTML 側から島を探すのではなく、変換時に目印を埋め込む",[236,252,253],{},"バイト一致は見た目の一致を保証しない。ページャの CSS が落ちて51〜61px 縮んだのは、ピクセル比較でしか見つからなかった",[236,255,256],{},"テストを足したら、一度壊して落ちることを確かめる。通ったことしか見ていないテストは歯止めになっていない",[236,258,259],{},"デプロイ出力をパイプに通すとバッファに溜まる。ログファイルへ直接書いて追う",[45,261,262],{"id":262},"明日やること",[233,264,267,277],{"className":265},[266],"contains-task-list",[236,268,271,276],{"className":269},[270],"task-list-item",[272,273],"input",{"disabled":274,"type":275},true,"checkbox"," 教科書系の講座の残り11章を、ch01 と同じ型で変換する",[236,278,280,282],{"className":279},[270],[272,281],{"disabled":274,"type":275}," 設例100本の講座の残り29ページは個別 CSS なので、見た目が変わる判断が要る。触る前にどこまで揃えるかを決める",{"title":284,"searchDepth":285,"depth":285,"links":286},"",2,[287,288,289,290,291,292,293,294,295,296],{"id":47,"depth":285,"text":48},{"id":63,"depth":285,"text":64},{"id":82,"depth":285,"text":82},{"id":107,"depth":285,"text":108},{"id":151,"depth":285,"text":151},{"id":163,"depth":285,"text":163},{"id":181,"depth":285,"text":181},{"id":211,"depth":285,"text":211},{"id":231,"depth":285,"text":231},{"id":262,"depth":285,"text":262},"dev",null,"Cloudflare Workersのバンドル上限に当たったので、レッスンページを外部HTMLへ書き出した1日の記録。月$5で3→10 MiBに上げられるが上げず、コンテンツの切り分けから始めた。ClientOnlyがSSRに痕跡を残さない問題、世代トークン、ピクセル比較でしか見つからないCSS欠落。","md",{},"/worker-bundle-reduction-content-islands","eurekapu-nuxt4",false,"2026-07-29T00:00:00.000Z",{"title":5,"description":299},"2026-07/2026-07-29/worker-bundle-reduction-content-islands",[309,310,311,312,313,314],"Cloudflare Workers","Nuxt","バンドルサイズ","SSR","静的HTML","Codex","_usxN_nNDH8qXjuHkUPBuqaWZQMcZs-AIJ9EzFsrAJg",[],[318,322,326,330,334],{"title":319,"path":320,"publishedAt":321},"Cloudflare Pages の 1MB Worker bundle 制限に当たって構成図A〜Dを書きながら設計判断を整理した","/cloudflare-pages-workers-bundle-architecture","2026-06-16T00:00:00.000Z",{"title":323,"path":324,"publishedAt":325},"Cloudflare Pages SSG の OOM を Excel 教材データ分離で根本対応した話 — Codex 再帰レビューと SSR fetch 404 の突破","/excel-data-separation-cloudflare-ssg-oom","2026-05-10T00:00:00.000Z",{"title":327,"path":328,"publishedAt":329},"語源英単語4000語をどう一覧化するか — Codex実装の停滞からナビゲーション統合まで","/gogen-eitango-word-list-navigation","2026-07-21T00:00:00.000Z",{"title":331,"path":332,"publishedAt":333},"Cloudflare Pages はもう Workers の中に住んでいる ― Static Assets 統合後の地図","/cloudflare-pages-to-workers-static-assets","2026-06-29T00:00:00.000Z",{"title":335,"path":336,"publishedAt":337},"memory-makersに日本の機械受注「電子計算機等」統計を追加した話","/japan-computer-orders-statistics-added","2026-06-13T00:00:00.000Z","https://log.eurekapu.com/og/blog/worker-bundle-reduction-content-islands.png?v=2026-07-29T00%3A00%3A00.000Z&title=Vue%E3%83%9A%E3%83%BC%E3%82%B8%E3%81%AF%E5%BF%85%E3%81%9AWorker%E3%83%90%E3%83%B3%E3%83%89%E3%83%AB%E3%81%AB%E8%BC%89%E3%82%8B%20%E2%80%94%20%E9%9D%99%E7%9A%84HTML%E5%8C%96%E3%81%A8%E3%80%8C%E5%B3%B6%E3%83%9E%E3%82%A6%E3%83%B3%E3%83%88%E3%80%8D%E3%81%A7Cloudflare%20Workers%E3%81%AE%E4%B8%8A%E9%99%90%E3%81%8B%E3%82%89%E9%80%83%E3%81%92%E3%82%8B&author=Kei%20Komatsu&sig=799c6fb545b8408b",1785379963341]