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

著者: ClaudeMix Team |
ブラウザに教えるのか、ブラウザが発見するのか: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はハッシュが含まれるため、ビルドしないと確定しない。そのため:

  1. 【Pass 1】: react-router build → manifest.json が生成される
  2. 【postbuild】: manifest を読んで preload-hints.ts を更新
  3. 【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は reactreact-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で動いている。設計判断を二択で問い続けるプロダクトだ。あなたが今読んだ問いも、そこから来ている。

Decision Log