ブラウザに教えるのか、ブラウザが発見するのか:modulepreload が問い直した依存解決の責務

はじめに
JSを書くとき、importは書く。
でも「そのimportをブラウザがいつ知るか」は考えない。
フレームワークが最適化してくれると思っていた。
バンドラーが解決してくれると思っていた。
ある日、Lighthouseのウォーターフォールチャートを見て、
JSが直列に並んでいるのを見た。
ブラウザはずっと待っていた。
「次に何が必要か」を知るために。
この記事をお勧めしない人
- 現在のLighthouseスコアに満足している人
- クライアントJSがほとんどないプロジェクトを作っている人
- フレームワークの最適化を信頼することを優先している人
もし一つでも当てはまらないなら、読み進める価値があるかもしれません。
【思想衝突】
- Framework Trust vs Explicit Control
- Infrastructure First vs Just-in-Time
① 問題提起:JSモジュールの「逐次発見」が払っているコスト
モジュールバンドラーはコードを分割する。コード分割はパフォーマンスを改善する。そこまでは正しい。
問題は、【ブラウザがそのチャンクの存在を知るタイミング】にある。
entry.js を実行する
→ 「react が必要」と発見 → fetch
→ react を実行する
→ 「react-router が必要」と発見 → fetch
→ react-router を実行する
→ ルートコンポーネントが必要」と発見 → fetchこれがウォーターフォールだ。各fetchは【前のモジュールが実行されて初めて開始する】。直列の待機が積み重なる。
- 【逐次発見のコスト】:次に何が必要かはJSを実行して初めて分かる。実行前にfetchできない。
- 【ネットワーク往復の累積】:1往復ごとに光の速さの制約がかかる。これは帯域ではなくレイテンシの問題で、ネットワークを速くしても根本的には解決しない。
- 【Hydrationの遅延】:JSが揃うまでReactは動けない。ウォーターフォールが長いほど、インタラクティブになるのが遅れる。
これは「設計ミス」ではない。コード分割という正当な最適化の副産物だ。
② なぜ皆そのままにしているのか
バンドラーがmodulepreloadを自動注入する、と信じているからだ。
Viteは確かにいくつかのmodulepreloadを注入する。React Routerのようなフレームワークも、SSR時に必要なチャンクをpreloadするロジックを持っている。
だから「自分では何もしなくていい」と感じる。
| 信じていたこと | 実際のこと |
|---|---|
| バンドラーが全チャンクをpreloadしてくれる | エントリーポイント付近の直接依存のみ対象になりがち |
| フレームワークが最適化してくれる | ルート固有の重いチャンクはナビゲーション時まで遅延することがある |
| コード分割すれば速くなる | 発見コストが残ったまま分割しても、ウォーターフォールは消えない |
誰かが怠慢だったのではない。「フレームワークを信頼する」という合理的な判断の結果、制御が見えなくなっただけだ。
③ modulepreload は何を優先したのか
<link rel="modulepreload"> は技術的な改善ではない。【依存解決の責務をブラウザに伝える宣言】だ。
<link rel="modulepreload" href="/assets/vendor-react-abc123.js">
<link rel="modulepreload" href="/assets/vendor-react-router-def456.js">
<link rel="modulepreload" href="/assets/route-blog-xyz789.js">HTMLが届いた瞬間、ブラウザはこれを読む。JSを一行も実行する前に、全チャンクの並列fetchを開始する。
「次に何が必要か」を【ブラウザが実行しながら発見する】のではなく、【HTMLで事前に宣言する】。
優先した価値
- 【レンダリングの透明性】:何が必要かをHTMLという最初の文書で宣言する。JSの実行に依存しない。
- 【並列化の確実性】:ブラウザのプリロードスキャナーはHTML解析と並列に動く。最も早いタイミングでfetchが始まる。
- 【フレームワーク非依存】:
<link rel="modulepreload">はW3C仕様。バンドラーが変わっても動く。
意図的に後退させた価値
- 【フレームワークへの委譲】:依存グラフの管理を自分で行う必要が生まれる。バンドラーが変われば再計算が必要になる。
- 【ビルドの単純さ】:chunk URLにはハッシュが含まれる。ビルドごとにURLが変わるため、手動で書くことができない。何らかの自動生成が必要になる。
④ 実装でどう解決しているか
発見コストをビルド時に前払いする
ブラウザがJSを実行しながら依存を発見するコストを、【ビルド時に前払い】する。
Viteはビルド後に build/client/.vite/manifest.json を出力する。このファイルには全チャンクとその依存関係が記録されている。
{
"_vendor-react-abc123.js": {
"file": "assets/_vendor-react-abc123.js",
"isEntry": false
},
"app/root.tsx": {
"file": "assets/root-xyz789.js",
"isEntry": true,
"imports": ["_vendor-react-abc123.js", "_vendor-react-router-def456.js"]
}
}このmanifestを読んでvendor chunkのURLを抽出し、app/generated/preload-hints.ts に書き出す。
// scripts/postbuild/generate-preload-hints.js
const manifest = JSON.parse(readFileSync('build/client/.vite/manifest.json', 'utf-8'));
const vendorUrls = Object.values(manifest)
.filter(entry => entry.file && !entry.isEntry)
.map(entry => `/${entry.file}`);生成されたURLをRemixの links() 関数で宣言する。
// app/root.tsx
import { preloadHints } from '~/generated/preload-hints';
export const links: LinksFunction = () => [
// 既存のCSS・フォントpreload...
...preloadHints.map(href => ({ rel: 'modulepreload' as const, href })),
];HTMLが届いた瞬間、ブラウザはこれを読んで全チャンクの並列fetchを開始する。JSの実行を待たない。
ビルドは2パスになる
chunk URLはハッシュが含まれるため、ビルドしないと確定しない。そのため:
- 【Pass 1】:
react-router build→ manifest.json が生成される - 【postbuild】: manifest を読んで
preload-hints.tsを更新 - 【Pass 2】:
react-router build→ 更新された hints を使って最終ビルド
vendor chunk の内容はPass 1とPass 2で変わらないため、ハッシュは同一になる。Pass 2では正しいURLがHTMLに入る。
⑤ 何を捨てたか
- 【ビルドの単純さ】:2パスビルドになる。ビルド時間が実質2倍になる。CIのコストが上がる。
- 【フレームワークへの信頼】:React Routerがすでに注入しているmodulepreloadと、自分が追加するものの重複管理が必要になる。
- 【manifest依存】:Viteのmanifest形式が変わると、postbuildスクリプトの更新が必要になる。バンドラーを変えると仕組みごと作り直しになる。
【これを許容できるかどうか】が、採用判断の核心になる。
そして正直に言うと、【クライアントJSが軽い状態では許容する理由がない】。
⑥ ClaudeMix への適用
ClaudeMixでは現時点で【実装しないことを選んだ】。
理由は単純だ。ClaudeMixのクライアントバンドルが軽いから。
marked(MarkdownパーサーI)と shiki(シンタックスハイライター)は .server.ts ファイルに閉じている。クライアントには送られない。vendor chunkは react・react-router・フォームバリデーションだけで、React Routerフレームワークがすでにそれらをpreloadしている。
ウォーターフォールチャートを確認したが、問題になるレベルの直列待機は観測されなかった。
【ただし、以下のいずれかが起きたら実装する】:
- LPにアニメーションライブラリ(Framer Motion等)を導入したとき
- ブログにコードエディターやインタラクティブデモを追加したとき
- LighthouseのWaterfallで意味のある直列パターンを観測したとき
「速くしたい」ではなく「重くなったときの備え」として、設計だけ理解しておく判断にした。
⑦ この設計は誰向けか
向いている
- リッチなUIを作りたい:Framer Motion・Three.js・D1グラフ等の重いクライアントJSを入れるプロジェクト
- Waterfallチャートで直列パターンを観測した:Lighthouseで問題を確認してから対処したいチーム
- バンドラーの自動最適化を信頼しない:フレームワークの内部動作まで制御したいエンジニア
向いていない
- クライアントJSが軽いプロジェクト:サーバー中心のSSRアプリでは、フレームワークの自動注入で十分なことが多い
- ビルド時間を増やしたくない:2パスビルドのコストを払えない(CIが遅い・ビルド料金が問題)
- フレームワークに任せたい:「React Routerが最適化してくれる」という判断も合理的
⑧ 思想の衝突
フレームワークは「任せれば速くなる」という約束をする。
その約束は概ね正しい。React Routerはmodulepreloadを内部で注入する。Viteは依存グラフを解析して最適化する。
modulepreloadを明示的に宣言するということは、その約束から一歩引くことだ。
「フレームワークが何をしているか知った上で、自分でも宣言する」。
それは余計な作業なのか。それとも責務の明確化なのか。
【「フレームワークに任せてDXを最大化したい」】 という価値観では、明示的なmodulepreloadは不要なノイズに見える。
【「何がいつロードされるかを自分で把握したい」】 という価値観では、明示的な宣言がシステムの透明性を担保する。
どちらが正しいかではなく、どちらを先に置くかだ。
【実装しないという判断も設計判断だ】。「今は軽い。重くなったら入れる」という判断は、Just-in-Timeの価値観と一致している。Infrastructure-Firstの価値観とは相容れないが、それは「間違い」ではない。
設計とは、何を便利にするかではない。
どの価値観を、どの順番で優先するかだ。
⑨ あなたはどちらを選ぶか
依存解決の責務は、ブラウザのランタイム発見に委ねるか。HTMLで事前に宣言するか。
フレームワークを信頼して委譲するか。透明性のために自分で制御するか。
インフラを先に整えるか。必要になってから入れるか。
この記事はClaudeMixで動いている。設計判断を二択で問い続けるプロダクトだ。あなたが今読んだ問いも、そこから来ている。
