zfb

Type to search...

to open search from anywhere

インクリメンタルリビルド

作成2026年6月1日Takeshi Takatsudo

zfb の依存グラフが、各リビルドを実際にファイル変更の影響を受けるページだけに限定する仕組み。

1 つのファイルを編集すると、それに依存するページだけがリビルドされます。このページでは、その保証がどう機能するか、グラフが何を追跡するか、効果が本物なのはどこか、そして現状のコストがどこにあるかを説明します。

約束

zfb dev 中に content/blog/post-3.md を保存したとき、zfb はサイトのすべてのページをリビルドするわけではありません。依存グラフにそのファイルをインポートしたページを問い合わせ、それらだけを再レンダリングします。コンテンツ中心のブログでは通常、1〜2 ページです。

具体例: blog コレクションを消費するブログインデックス(pages/blog/index.tsx)と投稿ごとのページ(pages/blog/[slug].tsx)を持つ 500 ページのサイト。content/blog/post-3.md を保存すると、pages/blog/index.tsx(全投稿を一覧)とその slug をレンダリングする単一の投稿ページがダーティになります。500 ページではなく 2 ページです。

グラフはこれを厳密にします。ヒューリスティックでもキャッシュ無効化のタイムアウトでもありません。各ページは、記録された依存関係が変わった場合に限り、ちょうどそのときだけリビルドされます。

zfb-graph が追跡するもの

crates/zfb-graph は 2 層の依存情報を保持します。

ソースレベルの依存(ページ単位):

各ページには PageId(ソースの .tsx パス)が割り当てられます。グラフは、そのページが依存するすべてのファイルを DepKind でタグ付けして記録します。

KindExamples
ModuleTSX/TS のレイアウト、コンポーネント、lib ファイル
Contentコンテンツコレクション経由の Markdown/MDX エントリ
StyleCSS ソース、CSS モジュール
DataJSON/TOML/YAML データモジュール
Assetpublic/ 配下の静的ファイル

逆引きインデックスは、すべての依存パスを、それを消費するページの集合へとマッピングし返します。これが dirty_pages() が問い合わせるインデックスです。

アセットレベルの依存(ページ単位):

ソースグラフと並んで、各ページは AssetDeps レコードも保持します。

  • islands — ページがハイドレートするすべての "use client" 島の安定したコンポーネント識別子。
  • css_modules — ページがインポートする CSS-Modules のソースパス。

この第 2 の層は別の問いに答えます。「どのページが再レンダリングされるか?」ではなく「どのアセットバンドルが再出力されるか?」です。"use client" コンポーネントへの変更は、JS を配信するすべてのページを再レンダリングすることなく、どのページスコープの islands バンドルを無効化すべきかをグラフに伝えます。

グラフは成功した各レンダリングのあとに両方の層を更新するため、次の問い合わせは常に最新の状態を反映します。

DirtySet

ファイルが変更されると、ビルドオーケストレーターは DependencyGraph::dirty_pages(path)(または変更が連続するときのバッチ版 dirty_pages_batch)を呼び出します。戻り値の型は DirtySet です。

pub enum DirtySet {
    /// Rebuild every page. Triggered by global files like zfb.config.ts.
    All,
    /// Rebuild exactly this set of pages. May be empty.
    Specific(BTreeSet<PageId>),
}

DirtySet::Specific が一般的なケースです。グラフは O(consumers) の時間で逆引きインデックスのルックアップを行い、変更されたパスをインポートしたページだけを返します。

DirtySet::All は最終手段です。DependencyGraph::mark_global() 経由で登録されたファイルは、記録されたエッジに関係なく常に All を返します。zfb.config.ts はデフォルトでグローバル登録されています。他の候補にはトップレベルの _app.tsx_document.tsx など、その変更が意味的にすべてのページに影響するものが含まれます。

未知のパス(グラフが依存関係として一度も見たことのないファイル)は空の DirtySet::Specific を返します。リビルドするものはなく、何も起こりません。

dev パイプラインがダーティセットで行うこと

オーケストレーター(crates/zfb-build)は DirtySet を受け取り、それを dev パイプライン(DevAssetPipeline)に流し込みます。

  1. ダーティなページだけを再レンダリングする。 パイプラインは DirtySet::Specific 内のページに対してレンダラを呼び出します。集合に含まれないページは手つかずのままです。
  2. HTML がバイト単位で同一なら書き込みをスキップする。 レンダリング後、各ページの新しい HTML が直前に判明しているバイト列と比較されます。バイトが一致すれば、ファイルは書き込まれず、リロードシグナルにもページは含まれません。意味的な HTML 変更を生まない純粋なリファクタリングは、ブラウザのリロードを生みません。
  3. 消費するページが変わったときだけ islands を再バンドルする。 islands サブパイプラインは、プランの rerun_islands フラグが立っているときだけ実行されます。これをオーケストレーターが立てるのは、変更されたファイルが islands ルート(例: components/)の内部にあるときだけです。コンテンツのみの変更は islands の再バンドルをトリガーしません。
  4. ファイル名を安定に保つ。 dev モードの出力ファイルは安定した名前を使います(コンテンツハッシュなし)。ブラウザのキャッシュコントラクトはウォッチャーのティック間で変わりません。コンテンツハッシュ化はプロダクションパイプラインの仕事です。

総合的な効果は次のとおりです。2 ページに触れるコンテンツ編集は、2 回の HTML 書き込み、1 つのライブリロードイベントを生み、島のバンドリングは行いません。ヘッダーコンポーネントの編集は、ヘッダーをインポートするすべてのページに書き込みを生みますが、それでもそれらのページだけです。SSE イベントの種類(PageCssIslands)の全体像と、ブラウザがそれぞれにどう反応するかについては Dev mode lifecycle を参照してください。

オーケストレーターの設計をより深く知るには Build engine を参照してください。

Astro の dev モデルとの比較

Astro は内部で Vite を使います。Vite の hot module replacement(HMR)はモジュールを無効化し、モジュールのインポートグラフを辿って影響を受ける ESM の境界を見つけ、WebSocket 経由でブラウザに更新をプッシュします。集中管理された Content Layer はパース済みのコンテンツを SQLite ストアにキャッシュし、コレクションの変更時にリロードします。

zfb のモデルは粒度のレベルが異なります。

  • Astro の無効化の単位は ES モジュールです。 変更はモジュールを無効化し、バンドラがモジュールグラフに基づいてどのチャンクを再出力するかを決めます。
  • zfb の無効化の単位はページです。 依存グラフはモジュールではなくレンダリング出力をキーとするため、共有コンポーネントの変更は、インポートのクロージャ全体ではなく、ちょうど HTML を生成するページだけをダーティにします。

コンテンツ中心のサイトではこの違いが効きます。Astro で 1 つの MDX ファイルを編集すると、ページが再レンダリングされる前に Content Layer のリロードが起き、すべてのコレクションエントリが再パースされることがあります。zfb は変更をグラフ経由でルーティングし、そのエントリを消費する 2 ページを見つけ、他のエントリのスナップショットに触れることなくそれらだけをレンダリングします。

より広いアーキテクチャの全体像については Architecture overview を参照してください。

正直な現状のボトルネック

依存グラフはボトルネックではありません。完璧なダーティセット(リビルドすべきページが 1 つ)であっても、dev モードのリビルドごとに 2 つのコストが支払われます。

1. リビルドごとのエンジン起動。

zfb は、組み込みの V8 ホストにワーカーバンドルをロードしてページをレンダリングします。各リビルドはレンダラを再ロードします。V8 アイソレートが起動し、ワーカーバンドルを評価し、ダーティなページがレンダリングされるとテアダウンします。このオーバーヘッドは、いくつのページがダーティかに関係なく一定です。グラフは 500 ページのレンダリングからは救ってくれますが、どのページのレンダリングよりも前に起きるアイソレートの起動コストからは救えません。

これはグラフの問題ではありません。これは別の最適化軸です。ティック間でアイソレートを温かく保つ、あるいは永続的なレンダラホストへ切り替えるというもので、ロードマップ上にありますが今日は未実装です。

2. コンテンツ変更時のワーカーバンドルのリビルド。

ContentSnapshot(すべてのコンテンツコレクションエントリを JSON にシリアライズしたもの)は、V8 ホストがロードするワーカーバンドルに直接埋め込まれます。今日、コンテンツの変更は、たとえ 1 エントリだけが変わったとしても、フルバンドルのリビルドをトリガーします。バンドルのリビルド時間は O(snapshot size) であり、O(changed entries) ではありません。

zfb が対象とするプロジェクト規模(数百の MDX ファイル、典型的なドキュメントサイト)では、これは余裕で収まります。非常に大きなコンテンツセットはリビルド時間を線形に押し上げます。予定されている緩和策(変更されたエントリだけを再シリアライズするスナップショットパッチ、各バンドルが 1 コレクションをカバーするコレクション単位のシャーディング)は今後のロードマップとして追跡されていますが、今日は未実装です。

ビルドのスナップショットフットプリントを測定するには ZFB_DEBUG_SNAPSHOT=1 を設定します。

ZFB_DEBUG_SNAPSHOT=1 pnpm exec zfb build

これは stderr に次のような行を出力します。

content snapshot: 187 entries / 412 KB

完全な測定ガイドと予定されているシャーディング作業については README のスナップショットセクション を参照してください。

スケーリングのスイートスポット

下の表は、現状のアーキテクチャがどこで快適でどこで苦しいかのおおよその感覚を示します。数値はオーダーレベルの見積もりです。実際の時間はハードウェア、コンテンツエントリのサイズ、コンポーネントの深さによって変わります。

Site sizeCold-start bootGraph lookupPages re-renderedOverall feel
~100 ページ、~100 コンテンツエントリ高速即時1〜3保存からリロードまで即座
~1,000 ページ、~1,000 エントリ高速即時1〜5軽快。スナップショットサイズはまだ小さい
~10,000 ページ、~5,000 エントリ高速即時1〜10体感あり。スナップショットのリビルドがコスト
~100,000 ページ以上高速即時1〜多数線形に苦しい。分割ビルドやコレクション単位のシャーディングを検討

グラフのルックアップとページレンダリングの列は、サイトサイズによってあまり変わりません。それが勝ち筋です。スナップショットのリビルドの列はコンテンツセットのサイズとともに増大し、現状の上限となっています。エンジン起動は一定のオーバーヘッドであり、ダーティセットが小さいとき(レンダリングするページは 1 つでも、同じ起動コストを払う)に最も効いてきます。

~10k の線を超えるサイトについては、今日のアドバイスは次のとおりです。ZFB_DEBUG_SNAPSHOT でスナップショットサイズを監視し、コンテンツエントリを短く保ち、ロードマップが提供したときにはコレクション単位のシャーディングを計画してください。

Revision History