シグナルと細粒度リアクティビティ
画面更新のたびにコンポーネントごと再計算する仕組みをやめると、差分検出の手間そのものが要らなくなる。依存関係を自動追跡するシグナルの内部動作を原理から解説します。
- 1.シグナルは値と購読者集合を持つノードで、読み取り時に「今計算中の消費者」を自動登録し、書き込み時にその消費者だけを再実行する。
- 2.Reactは仮想DOM差分でコンポーネント単位に再レンダーするプル型/バッチ型、SolidJSやPreact Signalsは依存グラフを辿ってDOMノード単位だけ更新するプッシュ型。
- 3.更新はグラフのトポロジカル順に伝播し、ダイヤモンド依存でも中間ノードの再計算を1回に抑える仕組み(グリッチ回避)が正確性の鍵。
何を解決する仕組みか
Reactのようなフレームワークは、状態が変わると コンポーネント関数を丸ごと再実行 し、その結果の仮想DOMを前回のものと比較(差分検出)して、変わった部分だけ実DOMに反映します。この方式は「状態→UI」を純粋関数として扱える利点がありますが、状態が1つの文字列を書き換えただけでも、そのコンポーネント配下の仮想DOMツリーを丸ごと作り直し、比較する処理が挟まります。
シグナル(Signal)ベースの細粒度リアクティビティは、この差分検出そのものを不要にする設計です。「どの値がどのDOM更新に使われているか」を実行時に自動で記録しておき、値が変わったら その値を直接使っている箇所だけ をピンポイントで更新します。コンポーネント関数は基本的に最初の1回しか実行されません。仮想DOMもツリー比較も存在しない。これがSolidJSやPreact Signals、Vueの内部(refとreactive)が採用するモデルです。仮想DOM側の差分アルゴリズムの詳細は SPA/SSR/SSGのレンダリング戦略 を、コンポーネント外の描画パイプラインは ブラウザのレンダリング を参照してください。
シグナルの内部構造:値・購読者・自動登録
シグナルは概念上、次の3つを持つオブジェクトです。
| 構成要素 | 役割 |
|---|---|
| 現在値 | 保持している最新の値 |
| 購読者集合(subscribers) | この値を読み取った「計算」への参照の集合 |
| read/write手続き | 読み取り時は登録、書き込み時は購読者へ通知 |
鍵になるのは、購読登録が 手動ではなく実行時の自動観測 で行われる点です。ランタイムはグローバルに「今どの計算(エフェクトやコンポーネント)が実行中か」を1つのスタック変数で追跡しています。この状態を仮に「現在実行中コンテキスト」と呼びます。
let currentComputation = null; // 実行中のエフェクト/派生値を指す
function createSignal(initial) {
let value = initial;
const subscribers = new Set();
function read() {
if (currentComputation) {
subscribers.add(currentComputation); // 自動登録
}
return value;
}
function write(next) {
if (next === value) return; // 値が変わらなければ何もしない
value = next;
for (const sub of [...subscribers]) sub.execute();
}
return [read, write];
}
readが呼ばれた瞬間、たまたま実行中だった計算(currentComputation)が「この値に依存している」と自己申告する形で購読者集合に加わります。開発者が依存配列を明示的に書く必要はありません。これがReactのuseEffect(fn, [a, b])のような 手動の依存配列 との決定的な違いです。手動配列は書き漏れると古い値を参照するバグ(stale closure)を生みますが、自動追跡は「実際に読んだものだけ」を正確に記録するため原理的にこの種の齟齬が起きません。
createEffect(fn)は「fnの実行をcurrentComputationに設定してからfnを呼ぶ」だけの薄いラッパーです。fn内でシグナルをreadすれば自動的に依存として登録され、次回そのシグナルが変わるとfnが再実行されます。派生値(createMemoなど)は「自分もシグナルであり、かつ他のシグナルを読むエフェクトでもある」という二重の性質を持つノードとして実装されます。
依存グラフと伝播:プッシュ型の再計算
これらのシグナル・エフェクト・派生値をノードとして繋ぐと、有向グラフができます。シグナルが書き換わると、そのノードから購読者へ、さらにその購読者の購読者へと グラフを辿ってプッシュ通知 されます。これがプッシュ型(push-based)リアクティビティです。
対してReactのフックモデルは基本的にプル型に近く、「状態が変わった」という通知を受けたコンポーネントが次のレンダーフェーズで 改めて自分の描画関数を呼んで結果を取りに行く 構造です。どちらも「変化の伝播」を扱う点は同じですが、単位が違います。
| 観点 | React(仮想DOM差分) | シグナル(細粒度) |
|---|---|---|
| 更新の単位 | コンポーネント関数の再実行 | 値を直接使うDOM更新/計算のみ |
| 変化の検出 | レンダー後にツリーを比較(diff) | 依存グラフを辿るだけ、比較不要 |
| 依存関係の把握 | hooksの依存配列を手動宣言 | read時に自動追跡 |
| 再実行の対象 | 親から子へ再帰的に広がりやすい | 更新対象のノードのみ、局所的 |
| メモ化の要否 | React.memo/useMemoで明示的に抑制 | グラフ構造自体が不要な再計算を含まない |
仮想DOM差分は非効率だから存在するのではなく、「状態からUIを関数として毎回導出する」という宣言的モデルを保ったまま、実DOM操作のコストだけ最小化する ための機構です。差分計算自体はメモリ上のオブジェクト比較で済むため、DOM操作より遥かに安価。シグナルはこの差分計算そのものを不要にする 別のトレードオフ を選んでいるだけで、優劣ではなく設計方針の違いです。
グリッチ回避:ダイヤモンド依存と実行順序
複数の計算が同じシグナルに依存し、さらにそれらの計算同士も依存関係を持つ場合(ダイヤモンド依存)、素朴なプッシュ通知は同じノードを複数回、しかも古い値のまま再計算してしまう危険があります。
A(シグナル)
/ \
B(Aに依存) C(Aに依存)
\ /
D(B と C の両方に依存)
Aが更新されると、素朴な実装ではB経由の通知でDが1回、C経由の通知でDがもう1回、計2回実行されがちです。しかも1回目の実行時点ではCがまだ古い値のままの可能性があり、Dが一瞬だけ不整合な値を見てしまう(グリッチ)ことになります。
これを避けるため、実用的な実装は次のいずれかの戦略を取ります。
- トポロジカル順序での実行: 依存グラフを事前に順序付けし、あるノードの全ての依存元が更新し終わってから、そのノードを実行する。
- バッチング(同期的な更新のまとめ処理): 1つのイベントハンドラ内での複数のシグナル書き込みを即座に伝播させず、マイクロタスクや明示的な区切りまで溜めてから、影響を受けるノードを重複なく1回だけ再計算する。
- 世代カウンタ/バージョン管理: 各ノードに更新世代の番号を持たせ、同一世代内での再計算を1回に制限する。
グリッチ(glitch)とは、最終的には正しい値に収束するにもかかわらず、伝播の途中で一時的に矛盾した中間状態が観測されてしまう現象 です。DがBの更新だけを反映しCの更新をまだ反映していない瞬間に副作用(DOM書き込みなど)を実行してしまうと、画面に一瞬だけ不整合な表示が出ることがあります。正しい細粒度リアクティビティの実装は、グラフの構造を使って 各ノードが依存元すべての確定を待ってから1回だけ実行される ことを保証します。
実行時コストの違いが生む体感差
仮想DOM方式は更新のたびに「コンポーネント関数の再実行→仮想DOM生成→前回との比較→実DOM反映」という一連の処理を通ります。リスト内の1項目だけが変わっても、そのリストを描画するコンポーネント全体が再実行されるのが基本形で、これを避けるにはReact.memoやkeyの最適化を開発者が意識的に行う必要があります。
細粒度リアクティビティでは、コンポーネント関数自体は初期マウント時に1度しか実行されません。以降の更新は「シグナルの値を直接埋め込んだDOMノードのテキストや属性を書き換えるエフェクト」がピンポイントで走るだけで、コンポーネント関数の再実行もツリー比較も発生しません。更新コストが状態を使っている箇所の数に比例し、UIツリー全体の大きさには比例しないというのが、このモデルの中心的な利点です。もっとも、依存グラフの構築・維持自体にもコストがあり、シグナルが極端に細かく大量にあるアプリではグラフの管理オーバーヘッドが無視できなくなる点は留意が必要です。
まとめ
シグナルは「値+購読者集合」を持つノードで、読み取り時に実行中の計算を自動登録し、書き込み時にその購読者だけへ通知する仕組みです。これによりReactの仮想DOM差分のような「コンポーネント単位の再実行→ツリー比較」を経由せず、依存グラフを直接辿って更新対象のDOMノードだけをピンポイントで書き換えられます。複数の依存経路が合流するダイヤモンド依存では、トポロジカル順序やバッチングでグリッチ(一時的な不整合)を避けることが正確性の要です。仮想DOMと細粒度リアクティビティは優劣ではなくトレードオフの違いであり、後者は差分計算のコストをグラフ管理のコストに置き換えていると理解すると全体像が掴めます。関連する自動追跡の仕組みは MutationObserverの変更記録モデル や Proxyとリフレクションの内部メソッド(Vueのreactiveが依存追跡にProxyトラップを使う実例)と合わせて読むと理解が深まります。
Web/フロントエンド Article
シグナルと細粒度リアクティビティを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
JavaScript
比較で見る軸
難易度: advanced / カテゴリ: Web/フロントエンド / タグ数: 5
導入後に効く点
Reactは仮想DOM差分でコンポーネント単位に再レンダーするプル型/バッチ型、SolidJSやPreact Signalsは依存グラフを辿ってDOMノード単位だけ更新するプッシュ型。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- Web/フロントエンド
- タグ数
- 5
判断チェックリスト
- 自社の用途が「JavaScript / リアクティビティ」に近いか確認する。
- 強みである「シグナルは値と購読者集合を持つノードで、読み取り時に「今計算中の消費者」を自動登録し、書き込み時にその消費者だけを再実行する。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。