Read OSS

リアクティビティエンジン:Sources・Deriveds・Effects とバッチスケジューラ

上級

前提知識

  • 第1回:アーキテクチャとコードベースマップ
  • 第2回:コンパイラパイプライン(コンパイル済み出力フォーマットの理解)
  • リアクティブプログラミングの基礎(シグナル、依存関係トラッキング)
  • ビット演算の基礎(フラグ、マスク、&・|・^)
  • ES Proxy ハンドラトラップ

リアクティビティエンジン:Sources・Deriveds・Effects とバッチスケジューラ

Svelte 5 のリアクティビティエンジンは、プルベース型のシグナルシステムです。ソースの値が変化しても、即座に変更が伝播するわけではありません。依存している reaction を「更新が必要かもしれない」状態としてマークしておき、スケジューラが処理するタイミングで遅延評価します。この設計により、derived values は不要な再計算をスキップでき、更新のバッチ処理も効率的に行えます。まずはデータ構造から見ていきましょう。

シグナルグラフ:Sources・Deriveds・Effects

リアクティブなプリミティブは 3 種類あり、いずれも共通の形を持つ素の JavaScript オブジェクトとして定義されています。

Sources はリアクティブグラフの末端であり、入口となるノードです。$.state() で生成され、値を保持するとともに、依存している reaction を追跡します。sources.js から抜粋:

export function source(v, stack) {
    var signal = {
        f: 0,          // flags
        v,             // value
        reactions: null, // dependents
        equals,        // equality function
        rv: 0,         // read version
        wv: 0          // write version
    };
    return signal;
}

Deriveds はグラフの中で読み取りと書き込みの両方の役割を担う、遅延評価型の計算ノードです。$.derived() で生成され、source と同じフィールドに加えて、fn(計算関数)、deps(上流の依存関係)、そして親子ポインタを持ちます。deriveds.js から抜粋:

export function derived(fn) {
    var flags = DERIVED | DIRTY;
    // ...creates an object with fn, deps, parent, first, last, etc.
}

Effects はツリー構造を形成する副作用関数です。effects.jscreate_effect() で生成され、DOM ノードへの参照とツリーポインタを含みます:

var effect = {
    ctx: component_context,
    deps: null,
    nodes: null,       // { start, end } DOM anchors
    f: type | DIRTY | CONNECTED,
    first: null,       // first child effect
    fn,                // the effect function
    last: null,        // last child effect
    next: null,        // next sibling effect
    parent,            // parent effect
    prev: null,        // previous sibling effect
    teardown: null,    // cleanup function
    wv: 0,
    ac: null           // AbortController
};
graph TD
    subgraph Sources
        S1["source { v: 0, reactions: [...] }"]
        S2["source { v: 'hello', reactions: [...] }"]
    end
    subgraph Deriveds
        D1["derived { fn, deps: [S1, S2], reactions: [...] }"]
    end
    subgraph Effects
        E1["root_effect { first → E2 }"]
        E2["render_effect { deps: [D1], parent → E1 }"]
    end
    S1 -->|"reactions"| D1
    S2 -->|"reactions"| D1
    D1 -->|"reactions"| E2
    E1 -->|"first"| E2

ビットフラグシステム

すべてのリアクティブノードは .f(flags)という整数フィールドを持ちます。状態のチェックにはビット演算を使用します。プロパティの参照や文字列比較よりも大幅に高速だからです。フラグの定義は constants.js にあります:

フラグ ビット 意味
DERIVED 1 << 1 derived な計算ノード
EFFECT 1 << 2 ユーザー定義の effect
RENDER_EFFECT 1 << 3 render effect(同期処理)
BLOCK_EFFECT 1 << 4 再実行をまたいで子を保持する effect
BRANCH_EFFECT 1 << 5 条件分岐を表す effect
ROOT_EFFECT 1 << 6 トップレベルの effect(マウントポイント)
BOUNDARY_EFFECT 1 << 7 エラー/サスペンス境界
CONNECTED 1 << 9 エフェクトツリーに接続済み
CLEAN 1 << 10 値は最新の状態
DIRTY 1 << 11 再計算が必要
MAYBE_DIRTY 1 << 12 上流が変化したかもしれない
INERT 1 << 13 一時停止中(オフスクリーン、退場トランジション中など)
DESTROYED 1 << 14 永久に削除済み

フラグの確認は 1 回のビット AND で完結します:(reaction.f & DIRTY) !== 0。セットは OR:reaction.f |= DIRTY。クリアは AND-NOT:reaction.f &= ~DIRTY。これらの演算は CPU の単一命令にコンパイルされます。

ヒント: リアクティビティエンジンをデバッグするとき、.f の値をフラグ定数と照合することで状態を読み解けます。たとえば f = 2054 を 2 進数に変換すると 100000000110 であり、DIRTY | CONNECTED | DERIVED を意味します。

依存関係トラッキング:get() と reaction コンテキスト

get() 関数 は依存関係トラッキングの核心です。source、derived、proxy のプロパティを問わず、リアクティブな値が読み取られるたびに get() が呼ばれます。active_reaction(現在実行中の effect または derived)が存在する場合、依存関係として登録します:

export function get(signal) {
    if (active_reaction !== null && !untracking) {
        // ...register dependency
        if (signal.rv < read_version) {
            signal.rv = read_version;
            if (new_deps === null && deps !== null && deps[skipped_deps] === signal) {
                skipped_deps++;
            } else if (new_deps === null) {
                new_deps = [signal];
            } else {
                new_deps.push(signal);
            }
        }
    }
}

rv(read version)と wv(write version)フィールドは、依存関係の重複登録を防ぐための最適化です。reaction が実行されるたびにグローバルな read_version がインクリメントされます。シグナルの rv が現在の read_version と一致していれば、その実行サイクルで既に登録済みであることを意味し、スキップできます。

skipped_deps の最適化は特に巧みです。effect が前回と同じ順序で同じ依存関係を読み取る場合、新しい配列を確保する代わりに skipped_deps をインクリメントするだけで済みます。依存関係の順序が変わったときに限り、new_deps の構築を始めます。つまりほとんどの再実行ではガベージが一切発生しません。

sequenceDiagram
    participant Effect as Active Effect
    participant Get as get()
    participant Source as Source Signal

    Effect->>Get: read source.value
    Get->>Get: active_reaction !== null?
    Get->>Get: signal.rv < read_version?
    Get->>Get: deps[skipped_deps] === signal?
    alt Same order as last run
        Get->>Get: skipped_deps++
    else New dependency
        Get->>Get: new_deps.push(signal)
    end
    Get-->>Effect: return signal.v

runtime.js#L73-L95 にあるモジュールレベルの変数 active_reactionactive_effectuntrackingcurrent_sources が実行コンテキストを構成します。第 2 回で紹介したコンパイラのモジュールレベル状態パターンと同じ設計で、ホットループでのパフォーマンスを最大化するために採用されています。

reaction の実行:update_reaction()

update_reaction() 関数 はコア実行ループです。依存関係トラッキングのコンテキストを丁寧に管理しながら、reaction の関数を実行します:

export function update_reaction(reaction) {
    // Save all context variables
    var previous_deps = new_deps;
    var previous_reaction = active_reaction;
    // ... 6 more saves

    // Set up new context
    new_deps = null;
    skipped_deps = 0;
    active_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null;
    set_component_context(reaction.ctx);

    try {
        var result = reaction.fn();
        // Reconcile dependency arrays...
    } finally {
        // Restore all context variables
    }
}

保存と復元のパターンにより、effect の中に effect があったり、effect の実行中に derived が評価されたりするようなネストが発生しても、トラッキングコンテキストが壊れません。関数の実行後は依存関係リストを調整します。不要になったシグナルから reaction を取り除き、新たに発見した依存先に reaction を追加します。

flowchart TD
    Start["update_reaction(reaction)"] --> Save["Save 8 context variables"]
    Save --> Setup["Set active_reaction,<br/>reset new_deps, skipped_deps"]
    Setup --> Run["Execute reaction.fn()"]
    Run --> Reconcile{"new_deps !== null?"}
    Reconcile -->|yes| Update["Update deps array,<br/>register reactions on new deps"]
    Reconcile -->|no| Trim["Trim deps to skipped_deps length"]
    Update --> Restore["Restore 8 context variables"]
    Trim --> Restore

エフェクトツリーの構造

Effect はフラットなリストではなく、親子の連結リストによるツリーを形成します。first/last ポインタで effect から子への接続を、prev/next ポインタで兄弟チェーンを、parent ポインタで上位への接続を表します。

このツリー構造はコンポーネントツリーに対応しています。コンポーネントのルート effect は各テンプレート領域の render effect を内包し、制御フローブロック({#if}{#each})は子として branch effect を生成します。effect の種類によってツリーの振る舞いが決まります:

  • ROOT_EFFECTmount() が生成し、コンポーネント全体を管理する
  • RENDER_EFFECT — DOM 更新のための同期 effect。ユーザー effect より先に実行される
  • EFFECT — ユーザーが書く effect($effect)。DOM 更新後に実行される
  • BLOCK_EFFECT — 制御フローをラップし、再実行をまたいで子を保持する
  • BRANCH_EFFECT — ブロック内の条件分岐を表す({#if} の一方のアーム)
graph TD
    Root["ROOT_EFFECT<br/>(component mount)"]
    Root --> RE1["RENDER_EFFECT<br/>(template setup)"]
    Root --> Block["BLOCK_EFFECT<br/>({#if condition})"]
    Block --> Branch1["BRANCH_EFFECT<br/>(true branch)"]
    Branch1 --> RE2["RENDER_EFFECT<br/>(update text)"]
    Block --> Branch2["BRANCH_EFFECT<br/>(false branch)"]
    Root --> UE["EFFECT<br/>($effect user code)"]

ブロックの条件が変化すると、古い BRANCH_EFFECT は一時停止され(アウトロトランジションを実行できるよう)、新しいブランチが生成されます。ブロック effect 自体は破棄されず、アクティブなブランチを切り替えるだけです。

ダーティチェック:CLEAN/MAYBE_DIRTY/DIRTY のステートマシン

CLEANMAYBE_DIRTYDIRTY の 3 つのフラグは、遅延評価を実現するためのステートマシンを構成します。is_dirty() 関数 が、reaction の再実行が必要かどうかを判定します:

  • DIRTY — 直接の依存関係が変化した。再実行が確定している。
  • MAYBE_DIRTY — 上流の derived が変化したかもしれない。依存関係を先に確認する。
  • CLEAN — 何も変化していない。スキップする。

source に書き込みが行われると、直接の依存先が effect であれば DIRTY に、derived であれば MAYBE_DIRTY を上流へ伝播させます。これが「プッシュ」フェーズです。「プル」フェーズは is_dirty() 内で起こります:

export function is_dirty(reaction) {
    if ((flags & DIRTY) !== 0) return true;

    if ((flags & MAYBE_DIRTY) !== 0) {
        var dependencies = reaction.deps;
        for (var i = 0; i < dependencies.length; i++) {
            var dependency = dependencies[i];
            if (is_dirty(dependency)) {
                update_derived(dependency);
            }
            if (dependency.wv > reaction.wv) {
                return true;
            }
        }
        set_signal_status(reaction, CLEAN);
    }
    return false;
}

MAYBE_DIRTY な reaction に対しては、各依存関係を再帰的にチェックします。依存先の derived 自身がダーティであれば再評価し、その後 write version(wv)を比較します。どの依存関係も reaction の wv より新しい wv を持たなければ、その reaction はクリーンと見なされ、何もしなくて済みます。

これがパフォーマンス上の重要なポイントです。別の derived に依存する derived は、上流の値が実際に変化しない限り再計算されません。MAYBE_DIRTY 状態により、誰かが実際に値を必要とするまで評価を先送りできるのです。

バッチスケジューラ

Batch クラス は、リアクティブな更新の処理を統括します。source への書き込みが発生すると、Batch.ensure() を呼び出して現在のバッチを取得または生成し、影響を受ける effect をスケジュールします。

バッチはシグナルの変化を収集し、処理をマイクロタスクに先送りします。これにより、複数の同期的な書き込みが一度の更新としてまとめられます:

count = 1;  // schedules batch
count = 2;  // same batch
count = 3;  // same batch
// microtask fires: process all changes once

バッチはいくつかのデータ構造を管理します:

  • current — このバッチにおける現在のシグナル値を保持する Map<Value, [any, boolean]>
  • previous — バッチ開始前の値を保持する Map<Value, any>
  • #roots — フラッシュが必要なルート effect
  • #commit_callbacks — コミット時まで遅延される DOM 操作

処理はエフェクトツリーをトップダウンで走査し、ダーティな effect を収集します。render effect(同期的な DOM 更新)が先に実行され、次にユーザー effect が実行されます。この順序により、ユーザーの $effect コードが実行される前に DOM が一貫した状態になることが保証されます。

バッチシステムはフォークもサポートしています。並行レンダリングのための並列バッチで、それぞれ独自のシグナル値セットを持ち、独立してコミットまたは破棄できます。これにより、現在の UI を表示したまま新しいブランチをバックグラウンドでレンダリングできる、実験的な非同期レンダリングモードが実現されています。

ヒント: flushSync() を使うと、保留中のバッチを即座に処理できます。テストで役立つほか、DOM を読み取る前に最新の状態に更新したい場合にも便利です。

ES Proxy によるディープリアクティビティ

let obj = $state({ x: 1 }) と書くと、コンパイラは $.proxy(...) を生成してオブジェクトを ES Proxy でラップします。proxy() 関数 はプレーンオブジェクトと配列だけをラップし、クラスインスタンス、DOM 要素、その他の特殊なオブジェクトはそのまま通過させます:

export function proxy(value) {
    if (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {
        return value;
    }
    const prototype = get_prototype_of(value);
    if (prototype !== object_prototype && prototype !== array_prototype) {
        return value;
    }
    // Create proxy with lazy source creation...
}

proxy はソースシグナルを遅延生成します。プロパティごとに 1 つ作られ、最初にアクセスされた時点で確保されます。obj.x を読み取ると、proxy の get トラップが対応するソースシグナルの get() を呼び出し、依存関係を登録します。obj.x = 2 と書き込むと、set トラップがそのソースの set() を呼び出し、リアクティビティを起動します。

この遅延アプローチにより、100 個のプロパティを持つオブジェクトでも最初から 100 個のソースを生成する必要はありません。リアクティブなコンテキストで実際に読み取られたプロパティだけがトラッキング対象になります。プロパティの追加・削除や配列の変更といった構造的な変化には version ソースのインクリメントで対応し、イテレーションや Object.keys() のトラッキングを実現しています。

flowchart TD
    Proxy["ES Proxy for { x: 1, y: 2 }"]
    Proxy -->|"get trap: obj.x"| GetX["sources.get('x') ?? create source"]
    GetX --> Signal["get(source) → registers dependency"]
    Proxy -->|"set trap: obj.x = 3"| SetX["set(source, 3) → triggers reactivity"]
    Proxy -->|"ownKeys trap"| Version["get(version) → tracks structural changes"]

次回予告

リアクティビティエンジンは「何を更新するか」を担っています。では、Svelte は実際にどうやって DOM を生成・更新するのでしょうか。次回は cloneNode() を使った高速な DOM 生成を行うテンプレートインスタンス化システムを解説します。制御フローブロック({#if}{#each})の実装やハイドレーションプロトコルについても詳しく見ていきます。