dev モードのライフサイクル
.tsx ファイルを保存してからブラウザで変更が見えるまでに何が起こるか — ウォッチャー、リバンドル、SSR リフレッシュ、そして 3 つの SSE イベント種別。
このページで扱う内容
zfb dev の「保存からピクセルまで」のループ。ウォッチャーがどのように変更を検出するか、 オーケストレーターが何をリビルドするかをどう決めるか、そして 3 つの SSE イベント種別が どのように、不要なフルページリロードなしにブラウザを反応させるか。ビルドステップ全体の 順序については Build pipeline を、依存グラフがどのように リビルドを影響を受けるページに限定するかについてはIncremental rebuild を参照してください。
prerender = false の SSR ルートについて: 各 EDIT ティックは新しい V8 ホストを再バンドルし、 それを共有レンダラの mutex にスワップします(下記のステップ 1 を参照)。そのため SSR リクエストは、 ティックが完了した直後から更新後のコードを配信します — 再起動は不要です。1 つだけ残る制約は、 SSR のみの編集は SSG の HTML 書き込みを生まないため、Page SSE イベントが発行されず、開いている ブラウザタブが自動リフレッシュされない点です。更新後の SSR 出力を見るには、タブを手動でリロード してください。dev 側の SSR 経路の背景についてはSSR and Cloudflare Bindingsを参照してください。
.tsx ファイルを保存する。それから?
保存した瞬間、オペレーティングシステムがそのファイルのパスに対してファイルシステムイベントを
発火します。crates/zfb-watcher は関連するすべてのディレクトリ — pages/・components/・
content/・layouts/・styles/・data/、そして 2 つの設定ファイル
zfb.config.json と zfb.config.ts、加えて設定された各コレクションの path がそれらの
デフォルトルートの外にある場合はそれも — を notify
クレート経由で監視しています。(正規のリストは crates/ の
DEFAULT_WATCH_ROOTS と derive_watch_roots を参照してください。)
なお、public/ は監視ルートに 含まれません。静的アセットはリクエストごとにディスクから
ライブで配信されるため(静的アセット — dev / prod の一致
を参照)、public/ 配下のファイルを編集してもウォッチャーイベントは発生せず、ライブリロードも
起こりません。dev サーバーは次のリクエストで新しいバイトをそのまま配信するだけです。public/
を監視対象から外していることは、アセットツリーがどれだけ大きくてもブート(起動)を高速に保つ
ことにもつながります(後述の ブートは bind ファースト を参照)。
エディタの保存が単一のきれいなイベントであることはまれです。vim はスワップファイルを元の
ファイルにリネームし、VS Code はメタデータ→データのイベントを立て続けに複数発行し、
git checkout は一度に数百を発火します。ウォッチャーのデバウンサーは、50ms の静寂ウィンドウ
内のすべてを単一の Change { path, kind } 値に統合します。kind フィールドは
ChangeKind::Created・ChangeKind::Modified・ChangeKind::Removed のいずれかです。
OS が分類できないイベント種別はすべて Modified に折りたたまれ、実際の変更が静かに
取りこぼされることが決してないようにします。
バーストが収まりデバウンスウィンドウが閉じると、ビルドオーケストレーター(crates/zfb-build)が
変更を受け取ります。変更されたパスを携えて crates/zfb-graph を呼び出し、DirtySet を
受け取ります。All(zfb.config.ts のようなグローバルファイル)か、変更されたファイルを
実際にインポートしているページの Specific(set_of_page_ids) のどちらかです。ダーティセットの
外のページは触られません。(依存追跡の完全なストーリーは
Incremental rebuild にあります。)
オーケストレーターはダーティセットから RebuildPlan を組み立てます。変更されたパスが
アイランドルート(例: components/)の中にあれば、プランの rerun_islands フラグが
立てられます。CSS ソースが変更されていれば rerun_css が立てられます。その後、
DevAssetPipeline::apply() メソッドが次の順序で実行されます。
遅延 dev レンダリング(デフォルト)
デフォルトでは、下記の「ページの再レンダリング」ステップは遅延(lazy)です。 ティックは編集されたコンテンツエントリ自身のルートだけを即時に再レンダリングし、 影響を受けた他のすべてのルートにはstale(陳腐化)マークを付けます。stale な ルートは最初のリクエスト(GET または HEAD)の時点でライブの V8 ホストを通して 再レンダリングされます — リクエストは新しいバイトが書き込まれるまでブロックされる ため、レスポンスが stale になることはありません。zfb dev ブート時の初回レンダリング は常に即時(eager)のままなので、すべてのルートは最初からディスク上に存在します。
これは 2 つの環境変数で制御できます。
ZFB_DEV_EAGER=1— エスケープハッチ: 完全な即時レンダリングを復元します (影響を受けたすべてのルートを毎ティック再レンダリングする、lazy 化以前の挙動)。ZFB_LAZY_DEV_RENDER=0|1— 精密なオーバーライド(1/trueで lazy を強制、0/falseで eager を強制)。両方が設定された場合はZFB_DEV_EAGERより優先 されます。
どちらもブート時に一度だけ読み取られます。変更には dev サーバーの再起動が必要です。zfb dev --help にも記載されています。
ページの再レンダリング。 レンダラが呼び出される前に、EDIT ティック(すなわち
ChangeKind::Modified— VS Code とほとんどのエディタが発行するもの)ではBuildContext::reload_rendererが発火します。これはディスクから取得した新しいコンテンツ スナップショットで再バンドルし、リビルドしたバンドルに対して新しい組み込み V8 ホストを起動し、 それを共有レンダラの mutex にスワップします(start-before-swap — 古いホストはスワップが 成功した後にのみシャットダウンされるため、バンドルエラーが起きても前のレンダラが配信を続けます)。 レンダリングコールバックと SSR アダプタはどちらも同じ mutex へのArcを保持するため、この ティック内の以降のすべてのリクエストは新しいホストを通してレンダリングされます。その後、 オーケストレーターはダーティセット内の各ページについてレンダラを呼び出します。レンダリングされた 各RenderedPage.htmlは、直近の既知の出力とバイト単位で比較されます。バイトが同一なら (セマンティックな HTML の変化を生まない純粋なリファクタリング)、そのページについてはファイルが 書き込まれず、リロードシグナルも送られません。(ブートの初回レンダリングと watch-ADD の発見は すでに新しいバンドルを持っているため、これらの経路ではリロードステップがスキップされます — 各 ティックは多くても一度しか再バンドルしません。)CSS パイプライン。
rerun_cssが true のとき、Tailwind v4 + PostCSS が実行されます。 CSS 出力が前のティックとバイト単位で同一なら、Cssイベントは発行されません。アイランドの再バンドル。
rerun_islandsが true のとき、esbuild の Go バイナリの サブプロセスが呼び出されます。すべての"use client"コンポーネントをバンドルし、単一の 結合モジュールを 安定したファイル名 —dist/(assets/ islands. js crates/のzfb- types/ src/ asset_ urls. rs STABLE_ISLANDS_FILENAME定数で、再エクスポートされてアイランドバンドラに消費されます) — に書き込みます。ファイル名にコンテンツハッシュはありません。なぜ dev ではファイル名が安定したままなのか を参照してください。ビルド結果のブロードキャスト。 パイプラインは
BuildOutcome構造体を返します。crates/のzfb- server/ src/ livereload. rs outcome_to_events()がその結果を検査し、/の SSE チャネル経由でブロードキャストされる_ _ zfb/ reload ReloadEvent値にマッピング します。あなたのサイトを開いているすべてのブラウザタブはそのチャネルを購読しており、即座に 反応します。
3 つの SSE イベント種別
| 結果のトリガー | イベント | ブラウザの振る舞い |
|---|---|---|
pages_written が非空 または pages_stale が非空 | Page | 完全な location.reload() |
css_changed | Css | すべての <link rel="stylesheet"> をホットスワップ — ドキュメントをリロードせずにブラウザキャッシュを破棄するため ?v=<timestamp> を付加 |
islands_bundle.is_some() | Islands { component, bundle_url } | 新しいバンドル URL の動的 import()(キャッシュ破棄の ?v=<timestamp> 付き)。新しくインポートされたモジュールがハイドレーションを実行し、デフォルトでは現在のページ上の すべての [data-zfb-island] 要素を再マウント — ドキュメントのリロードなし |
複数のイベントが同じティックで発火すると、サーバーは該当するイベントをすべて発行し、
接続中のすべてのタブが同じ SSE チャネルを購読しており、それらすべてを受け取ります。
ブラウザはイベントを到着順に処理します。Page イベントを受け取ると、各タブは
location.reload() を呼び出し、現在のドキュメントを破棄します — これにより、同じティックで
発行された Css や Islands イベントは、そのタブにとって無意味になります。「このタブだけが
フルリロードを受け取る」というパターンはここには存在しません。インプレースの Css と
Islands のスワップは、そのティックでアクティブなタブだけでなく、購読しているすべてのタブに
とって無意味です。
Islands について 1 つの詳細: 今日の dev モードでは、BuildContext::run_islands は
ビルド側のペイロードが現状アイランドごとの名前を提供しないため、outcome_to_events() に
components: Vec::new() を報告します。そのためサーバーは component: "" と安定した
バンドル URL(/)を持つ 単一の Islands イベントを発行します。
/ のクライアントスクリプトは bundleUrl だけを読み、新鮮な
タイムスタンプ(?v=<timestamp>)を付加し、その結果を動的インポートします。インポートされた
バンドルのトップレベルのハイドレーションコードが実行され、ページ上のすべての
[data-zfb-island] を巡回します。つまり、単一のアイランドティックは、デフォルトでは単一の
コンポーネントではなくページ全体を再ハイドレートします。
ターゲットを絞った再ハイドレーションはオプトインです。 クライアントがユーザー提供の
window.__zfbIslandsReload(component, swapUrl) 関数を検出すると、プレーンな動的インポートを
行う代わりに、そのフックにインポートを委譲します。アイランドのホットスワップを通してスクロール
位置やコンポーネントの状態を保持したいアプリケーションは、このフックをインストールし、どの
コンポーネントを再マウントするかを自分自身で決めます。フックがなければ、デフォルトのページ全体の
再ハイドレーションが実行されます。
なぜ dev ではファイル名が安定したままなのか
本番ビルドはコンテンツハッシュ付きのアセット URL(/)を使います。
これにより、デプロイされた CDN レスポンスを無期限にキャッシュでき、新しいデプロイで変更された
アセットは新鮮な URL を得ます。ハッシュはファイルの内容によって決まり、バンドルが変わるたびに
変化します。
dev モードは意図的にコンテンツハッシュをスキップします。出力は dist/ に
配置されます — 毎ティック同じ URL です。これが、SSE 駆動のホットスワップを機能させる
URL コントラクトの保証です。ブラウザが Islands イベントを受け取ったとき、新しいバンドルが
常に同じベース URL で到達可能だと分かっています。リビルドのたびに URL を変えると、ブラウザに
スワップ元のキャッシュ参照がなくなるため、フルページリロードが強制されてしまいます。
コンテンツハッシュは本番パイプラインの責務です。dev では安定した名前が設計上正しいのであって、 見落としではありません。
実際にはどういう意味になるのか
典型的な 3 つの編集シナリオと、どのイベントが発火するか。
.tsx アイランドコンポーネントの本体のみを編集する。 ウォッチャーがコンポーネントファイルで
発火します。依存グラフはそれを消費するページをダーティとしてマークし、オーケストレーターは
まず影響を受けるページを再レンダリングし、その後アイランドを再バンドルします。レンダリングされた
HTML が変わっていれば、Page イベントが発火し、ブラウザがリロードします。HTML がバイト単位で
同一なら(例: サーバーレンダリングに決して到達しないクライアント側の状態ロジックだけを変えた
場合)、Islands イベントだけが発火します — 新しいバンドルが動的インポートされ、その
ハイドレーションが実行され、ページ上のすべてのアイランドを再マウントします。そのスワップを通して
スクロール位置やコンポーネントごとの状態を保持するには、window.__zfbIslandsReload フックを
インストールしてください(3 つの SSE イベント種別を参照)。
アイランドを消費するページファイルを編集する。 ページとアイランドの両方がダーティです。
オーケストレーターはページを再レンダリングし、HTML はほぼ確実に変わり、Page イベントが
発火します。ブラウザは最新のサーバーレンダリング済み HTML で新鮮なフルページロードを得ます。
そのタブの同時並行の Islands イベントは、リロードによって無意味になります。
CSS ファイルのみを編集する。 ページはダーティにならず、アイランドの再バンドルもありません。
CSS パイプラインが実行され、出力が変わっていれば Css イベントが発火します。ブラウザは
スタイルシートをインプレースでスワップします — ドキュメントのリロードはなく、スクロール位置と
クライアント側の状態はすべて保持されます。
ブートは bind ファースト
ここまでは定常状態の編集ループを説明してきました。ブート自体は、サーバーを可能な限り早く到達
可能にするよう順序づけられています。zfb dev は、プロジェクト全体に関わる処理を行う 前に
TCP リスナーを bind します。マニフェストダイジェストの走査、永続化された依存グラフのロード、
グラフのシード、そして初回レンダリングは、すべて bind が成功した あと にのみ spawn される
バックグラウンドタスクで実行されます。(crates/ の
TcpListener::bind 周辺の bind ファースト化された構造を参照してください。)
ここで取り除かれるコストは、compute_manifest_digest が監視ツリーに対して行う
WalkDir + metadata() の走査です。これを bind の前に実行していたため、ポートの到達可能性が
監視ツリーのサイズ に比例してしまっていました。大きな静的アセットディレクトリやシンボリック
リンクされたツリーを持つプロジェクトでは、サーバーが最初の接続を受け付けるまでに体感できるほどの
待ちが生じることがありました。先に bind し、その走査をバックグラウンドタスクで行うことで、
そのツリーがどれだけ大きくても、サーバーは定数時間で接続を受け付け始めます。
この改善が指すもの — そして指さないもの
このブートの改善は、静的アセット/監視ツリーのサイズ からの独立です。bind 前の走査を 取り除いたのであって、実際のビルドが安くなったわけではありません。初回のページレンダリング、 CSS バンドリング、アイランドバンドルは、依然としてプロジェクト内のページ数・アイランド数・ ソースファイル数に比例します。その処理は依然として行われ、ただリスナーが立ち上がったあとの バックグラウンドタスクで行われるだけです。ブートのレンダリングがまだ到達していないルートへ リクエストが届いた場合、そのリクエストはそのルートがレンダリングされるまでブロックされます。 得られるのは、初回レンダリングがタダになることではなく、listen ソケットが即座に立ち上がることです。
この変更は、public/ が監視ルートでなくなったこと(上述)と対になります。両者が合わさることで、
大きな public/ ツリーは起動を遅くすることもウォッチャーに流れ込むこともなくなります。
関連
Build pipeline — CLI から
dist/までの完全なパイプラインと、dev モードがその上にどう乗るかIncremental rebuild — リビルドを影響を受けるページに限定する依存グラフと
DirtySetIslands —
"use client"がどのようにコンポーネントをアイランドバンドルにオプトインさせるかSSR and Cloudflare Bindings — dev 側の SSR パリティ:
prerender = falseルートが同じ V8 レンダラを通してどう配信されるか