リアクティビティ(signal)
実装:
frontend/reactivity// 実行:pnpm test(frontend/)
仮想DOMは状態が変わるたびに木を丸ごと作り直し、前回との差分を当てる。だが変わらぬ部分まで作る無駄がある。signalベースのリアクティビティは逆の発想だ。状態そのものが「誰が自分を読んだか」を覚え、変わったときにその読み手だけを再実行する。木を比べる必要がない。signal・effect・computedを実装し、読んだ人だけ反応するきめ細かさ、依存が変わるまで再計算しないキャッシュを見る。
この章で作るもの
仮想DOMは、状態が変わるたびに「あるべき木」を丸ごと作り直し、前回の木と比べて(diff)、変わった箇所だけを実DOMに当てた。宣言的で分かりやすく、十分速い。だが無駄もある。ボタンのカウントが 1 増えただけでも、コンポーネントの木を丸ごと一度作り、全体を比べる。変わっていない大部分も、作っては捨てている。
signal ベースのリアクティビティは、発想を逆にする。木を作り直して比べるのでなく、状態そのものに「自分が変わったら誰を更新すべきか」を覚えさせる。カウントの signal は、自分を読んだ関数(エフェクト)を購読者として記録しておく。カウントが変わったら、その購読者だけを再実行する。木の比較はいらない。変わった値に繋がっている箇所だけが、ピンポイントで動く。これがきめ細かな(fine-grained)リアクティビティで、Solid・Vue・Preact Signals の心臓部だ。この章では、その中核である signal・effect・computed を実装する。
signal(count) ──読まれる──▶ effect A(count を表示)
│ ──読まれる──▶ computed(count×2) ──▶ effect B
│
count.set(5) → 購読者 A と computed だけ再実行(B も computed 経由で)
count を読んでいない他のエフェクトは動かない順に見ていく。
- signal は購読者を覚える: 読まれたとき、実行中のエフェクトを購読者に登録する(依存追跡)
- effect は依存が変わると再実行: 読んだ signal だけを購読し、それが変わったときだけ動く(きめ細かさ)
- computed はキャッシュする: 依存が変わるまで再計算しない。読まれなければ計算もしない(遅延評価)
① signal: 値と購読者
まず signal を作る。値を 1 つ持ち、それに加えて「自分を読んだエフェクトの集合」を持つ。読まれたときに、今実行中のエフェクトを購読者に登録するのが要だ:
// きめ細かなリアクティビティ(signal ベース)を最小構成でフルスクラッチする。
//
// 仮想DOMは「状態が変わったら全体を作り直し、差分を当てる」やり方だった。
// リアクティビティは逆に、状態そのものが「誰が自分を読んだか」を覚えておき、
// 変わったときにその読み手だけを再実行する。木を丸ごと比べる必要がない。
// Solid / Vue / Preact Signals の心臓部がこれだ。
//
// 肝は3つ:
// 1. signal = 値 + 購読者の集合。読むと今実行中のエフェクトを購読者に登録する
// 2. effect = signal を読む関数。読んだ signal が変わると自動で再実行される
// 3. computed = 派生値。依存が変わるまで再計算せずキャッシュする(遅延評価)
// #region signal{ts}
// Effect は再実行できる仕事の単位。deps は自分が購読している購読者集合の逆参照
// (再実行前に古い購読を解除するため)。
interface Effect {
run: () => void;
deps: Set<Set<Effect>>;
}
// activeEffect は「今まさに実行中」のエフェクト。signal はこれを読み取って
// 「誰が自分を読んだか」を知る(依存追跡)。
let activeEffect: Effect | null = null;
// track は signal / computed が読まれたときに、実行中のエフェクトを購読者に登録する。
function track(subs: Set<Effect>): void {
if (activeEffect) {
subs.add(activeEffect);
activeEffect.deps.add(subs); // 逆参照も張る
}
}
// Signal は 1 つの値と、その値を読んだエフェクトの集合を持つ。
export interface Signal<T> {
get(): T;
set(v: T): void;
}
// signal は読み書きできるリアクティブな値を作る。
export function signal<T>(initial: T): Signal<T> {
let value = initial;
const subs = new Set<Effect>();
return {
get() {
track(subs); // 読んだエフェクトを購読者に登録
return value;
},
set(v: T) {
if (Object.is(v, value)) return; // 変化なしなら何もしない
value = v;
// 反復中の購読変更に備えてコピーしてから走らせる。
for (const e of [...subs]) e.run();
},
};
}
// #endregion signal{ts}
// #region effect{ts}
// cleanup はエフェクトの購読をすべて解除する(再実行前に呼ぶ)。
// これにより、今回読まなかった signal からは購読が外れる(動的な依存)。
function cleanup(e: Effect): void {
for (const dep of e.deps) dep.delete(e);
e.deps.clear();
}
// effect は fn を即座に実行し、fn が読んだ signal が変わるたびに再実行する。
// 戻り値を呼ぶと購読を解除して以後再実行されなくなる(dispose)。
export function effect(fn: () => void): () => void {
const e: Effect = {
deps: new Set(),
run() {
cleanup(e); // 前回の依存を捨てる
const prev = activeEffect;
activeEffect = e; // 実行中はこのエフェクトを active にする
try {
fn(); // fn 内で読まれた signal が track され購読が張られる
} finally {
activeEffect = prev;
}
},
};
e.run();
return () => cleanup(e);
}
// #endregion effect{ts}
// #region computed{ts}
// Computed は依存が変わるまで再計算しない派生値(遅延 + キャッシュ)。
export interface Computed<T> {
get(): T;
}
// computed は fn の結果をキャッシュし、依存が変わったら「汚れた」印だけ付ける。
// 次に get されたときに初めて再計算する(遅延評価)。読まれなければ計算もしない。
export function computed<T>(fn: () => T): Computed<T> {
const subs = new Set<Effect>(); // この computed を読んだエフェクト
let value: T;
let stale = true;
// 依存が変わると呼ばれる内部エフェクト。値は捨てず、汚れ印を付けて
// 自分の購読者に伝播するだけ(連鎖する computed のため)。
const runner: Effect = {
deps: new Set(),
run() {
if (!stale) {
stale = true;
for (const e of [...subs]) e.run();
}
},
};
return {
get() {
if (stale) {
cleanup(runner);
const prev = activeEffect;
activeEffect = runner; // 再計算中に読む signal は runner が購読
try {
value = fn();
} finally {
activeEffect = prev;
}
stale = false;
}
track(subs); // この computed を読んだエフェクトを購読者に
return value;
},
};
}
// #endregion computed{ts}get() の中の track が依存追跡の心臓だ。グローバルな activeEffect は「今まさに実行中のエフェクト」を指す。エフェクトが実行中に signal を読むと、その signal は activeEffect を自分の購読者に加える。誰も実行中でなければ(activeEffect が null)、ただ値を返すだけで購読は張られない。set() は、値が実際に変わったときだけ、購読者を全部再実行する。同じ値をセットしたときは何もしない。テストで、signal を変えると読んだエフェクトが再実行され、同じ値なら再実行しないことを固定した。
② effect: 依存を張り直しながら再実行する
次にエフェクト。signal を読む関数を受け取り、即座に実行し、読んだ signal が変わるたびに再実行する。肝は、再実行のたびに前回の購読をすべて解除してから走らせることだ:
// きめ細かなリアクティビティ(signal ベース)を最小構成でフルスクラッチする。
//
// 仮想DOMは「状態が変わったら全体を作り直し、差分を当てる」やり方だった。
// リアクティビティは逆に、状態そのものが「誰が自分を読んだか」を覚えておき、
// 変わったときにその読み手だけを再実行する。木を丸ごと比べる必要がない。
// Solid / Vue / Preact Signals の心臓部がこれだ。
//
// 肝は3つ:
// 1. signal = 値 + 購読者の集合。読むと今実行中のエフェクトを購読者に登録する
// 2. effect = signal を読む関数。読んだ signal が変わると自動で再実行される
// 3. computed = 派生値。依存が変わるまで再計算せずキャッシュする(遅延評価)
// #region signal{ts}
// Effect は再実行できる仕事の単位。deps は自分が購読している購読者集合の逆参照
// (再実行前に古い購読を解除するため)。
interface Effect {
run: () => void;
deps: Set<Set<Effect>>;
}
// activeEffect は「今まさに実行中」のエフェクト。signal はこれを読み取って
// 「誰が自分を読んだか」を知る(依存追跡)。
let activeEffect: Effect | null = null;
// track は signal / computed が読まれたときに、実行中のエフェクトを購読者に登録する。
function track(subs: Set<Effect>): void {
if (activeEffect) {
subs.add(activeEffect);
activeEffect.deps.add(subs); // 逆参照も張る
}
}
// Signal は 1 つの値と、その値を読んだエフェクトの集合を持つ。
export interface Signal<T> {
get(): T;
set(v: T): void;
}
// signal は読み書きできるリアクティブな値を作る。
export function signal<T>(initial: T): Signal<T> {
let value = initial;
const subs = new Set<Effect>();
return {
get() {
track(subs); // 読んだエフェクトを購読者に登録
return value;
},
set(v: T) {
if (Object.is(v, value)) return; // 変化なしなら何もしない
value = v;
// 反復中の購読変更に備えてコピーしてから走らせる。
for (const e of [...subs]) e.run();
},
};
}
// #endregion signal{ts}
// #region effect{ts}
// cleanup はエフェクトの購読をすべて解除する(再実行前に呼ぶ)。
// これにより、今回読まなかった signal からは購読が外れる(動的な依存)。
function cleanup(e: Effect): void {
for (const dep of e.deps) dep.delete(e);
e.deps.clear();
}
// effect は fn を即座に実行し、fn が読んだ signal が変わるたびに再実行する。
// 戻り値を呼ぶと購読を解除して以後再実行されなくなる(dispose)。
export function effect(fn: () => void): () => void {
const e: Effect = {
deps: new Set(),
run() {
cleanup(e); // 前回の依存を捨てる
const prev = activeEffect;
activeEffect = e; // 実行中はこのエフェクトを active にする
try {
fn(); // fn 内で読まれた signal が track され購読が張られる
} finally {
activeEffect = prev;
}
},
};
e.run();
return () => cleanup(e);
}
// #endregion effect{ts}
// #region computed{ts}
// Computed は依存が変わるまで再計算しない派生値(遅延 + キャッシュ)。
export interface Computed<T> {
get(): T;
}
// computed は fn の結果をキャッシュし、依存が変わったら「汚れた」印だけ付ける。
// 次に get されたときに初めて再計算する(遅延評価)。読まれなければ計算もしない。
export function computed<T>(fn: () => T): Computed<T> {
const subs = new Set<Effect>(); // この computed を読んだエフェクト
let value: T;
let stale = true;
// 依存が変わると呼ばれる内部エフェクト。値は捨てず、汚れ印を付けて
// 自分の購読者に伝播するだけ(連鎖する computed のため)。
const runner: Effect = {
deps: new Set(),
run() {
if (!stale) {
stale = true;
for (const e of [...subs]) e.run();
}
},
};
return {
get() {
if (stale) {
cleanup(runner);
const prev = activeEffect;
activeEffect = runner; // 再計算中に読む signal は runner が購読
try {
value = fn();
} finally {
activeEffect = prev;
}
stale = false;
}
track(subs); // この computed を読んだエフェクトを購読者に
return value;
},
};
}
// #endregion computed{ts}なぜ毎回購読を張り直すのか。動的な依存に対応するためだ。エフェクトが flag ? a.get() : b.get() のように、条件で読む signal を変えるとする。flag が true のときは a を購読し、false になったら b を購読すべきで、もう読まない a からは購読が外れてほしい。毎回 cleanup で全購読を解除し、実行のたびに実際に読んだ signal だけを購読し直せば、これが自然に実現する。テストで、読んでいない signal の変更では再実行されないこと(きめ細かさ)、条件で読む signal が変わると購読先が切り替わること(動的依存)を固定した。戻り値の関数を呼べば購読を解除して停止できる(dispose)。
③ computed: 遅延とキャッシュ
最後に computed。signal から派生した値(幅×高さ=面積、など)を表す。素朴には毎回計算すればいいが、それでは同じ値を何度読んでも計算し直してしまう。computed は、依存が変わったときだけ「汚れた」印を付け、次に読まれたときに初めて再計算してキャッシュする:
// きめ細かなリアクティビティ(signal ベース)を最小構成でフルスクラッチする。
//
// 仮想DOMは「状態が変わったら全体を作り直し、差分を当てる」やり方だった。
// リアクティビティは逆に、状態そのものが「誰が自分を読んだか」を覚えておき、
// 変わったときにその読み手だけを再実行する。木を丸ごと比べる必要がない。
// Solid / Vue / Preact Signals の心臓部がこれだ。
//
// 肝は3つ:
// 1. signal = 値 + 購読者の集合。読むと今実行中のエフェクトを購読者に登録する
// 2. effect = signal を読む関数。読んだ signal が変わると自動で再実行される
// 3. computed = 派生値。依存が変わるまで再計算せずキャッシュする(遅延評価)
// #region signal{ts}
// Effect は再実行できる仕事の単位。deps は自分が購読している購読者集合の逆参照
// (再実行前に古い購読を解除するため)。
interface Effect {
run: () => void;
deps: Set<Set<Effect>>;
}
// activeEffect は「今まさに実行中」のエフェクト。signal はこれを読み取って
// 「誰が自分を読んだか」を知る(依存追跡)。
let activeEffect: Effect | null = null;
// track は signal / computed が読まれたときに、実行中のエフェクトを購読者に登録する。
function track(subs: Set<Effect>): void {
if (activeEffect) {
subs.add(activeEffect);
activeEffect.deps.add(subs); // 逆参照も張る
}
}
// Signal は 1 つの値と、その値を読んだエフェクトの集合を持つ。
export interface Signal<T> {
get(): T;
set(v: T): void;
}
// signal は読み書きできるリアクティブな値を作る。
export function signal<T>(initial: T): Signal<T> {
let value = initial;
const subs = new Set<Effect>();
return {
get() {
track(subs); // 読んだエフェクトを購読者に登録
return value;
},
set(v: T) {
if (Object.is(v, value)) return; // 変化なしなら何もしない
value = v;
// 反復中の購読変更に備えてコピーしてから走らせる。
for (const e of [...subs]) e.run();
},
};
}
// #endregion signal{ts}
// #region effect{ts}
// cleanup はエフェクトの購読をすべて解除する(再実行前に呼ぶ)。
// これにより、今回読まなかった signal からは購読が外れる(動的な依存)。
function cleanup(e: Effect): void {
for (const dep of e.deps) dep.delete(e);
e.deps.clear();
}
// effect は fn を即座に実行し、fn が読んだ signal が変わるたびに再実行する。
// 戻り値を呼ぶと購読を解除して以後再実行されなくなる(dispose)。
export function effect(fn: () => void): () => void {
const e: Effect = {
deps: new Set(),
run() {
cleanup(e); // 前回の依存を捨てる
const prev = activeEffect;
activeEffect = e; // 実行中はこのエフェクトを active にする
try {
fn(); // fn 内で読まれた signal が track され購読が張られる
} finally {
activeEffect = prev;
}
},
};
e.run();
return () => cleanup(e);
}
// #endregion effect{ts}
// #region computed{ts}
// Computed は依存が変わるまで再計算しない派生値(遅延 + キャッシュ)。
export interface Computed<T> {
get(): T;
}
// computed は fn の結果をキャッシュし、依存が変わったら「汚れた」印だけ付ける。
// 次に get されたときに初めて再計算する(遅延評価)。読まれなければ計算もしない。
export function computed<T>(fn: () => T): Computed<T> {
const subs = new Set<Effect>(); // この computed を読んだエフェクト
let value: T;
let stale = true;
// 依存が変わると呼ばれる内部エフェクト。値は捨てず、汚れ印を付けて
// 自分の購読者に伝播するだけ(連鎖する computed のため)。
const runner: Effect = {
deps: new Set(),
run() {
if (!stale) {
stale = true;
for (const e of [...subs]) e.run();
}
},
};
return {
get() {
if (stale) {
cleanup(runner);
const prev = activeEffect;
activeEffect = runner; // 再計算中に読む signal は runner が購読
try {
value = fn();
} finally {
activeEffect = prev;
}
stale = false;
}
track(subs); // この computed を読んだエフェクトを購読者に
return value;
},
};
}
// #endregion computed{ts}computed は signal とエフェクトの両方の顔を持つ。内部に runner というエフェクトを持ち、依存の signal が変わると runner が呼ばれるが、そこでは再計算せず stale = true の印を付けて自分の購読者に伝播するだけだ。実際の再計算は、次に get() されたときまで遅延する。読まれなければ計算もしない。一度計算したら stale が false になり、依存が変わるまでキャッシュを返す。テストで、依存が変わるまで再計算しないこと(2 回読んでも計算は 1 回)、作っただけでは計算しないこと(遅延評価)、computed を連鎖できることを固定した。この遅延とキャッシュが、派生値の多いUIで無駄な再計算を防ぐ。
動かす
下のデモは、signal・computed・effect の依存グラフを可視化する。ある signal を変えたとき、どのエフェクトが再実行され、どれが動かないか(きめ細かさ)、computed がいつ再計算されるか(キャッシュ)を見てほしい。
width を変えると area が再計算され、area を読む E1 と width を読む E3 が再実行される。だが label を読む E2 は動かない(読んでいない signal の変更には反応しない=きめ細かさ)。label を変えれば E2 だけが動く。 area は依存(width/height)が変わるまで再計算せず、height を変えなければ計算回数は増えない(キャッシュ)。 仮想DOMのように木を丸ごと比べず、変わった値に繋がる購読者だけがピンポイントで動く。
設計の観点
- きめ細かさ vs 仮想DOM: signal は変わった箇所だけ更新するので、大きなUIで無駄が少ない。仮想DOMは木の比較にコンポーネント単位のコストがかかる。どちらも実用で、Solid/Svelte は signal 寄り、React は仮想DOM 寄り
- 依存追跡は実行時: どの signal を読むかを、コードを解析せず実行して観測する。だから条件分岐で依存が変わっても自然に追随する。コンパイル時解析(Svelte)と実行時追跡(Solid/Vue)の二系統がある
- computed は純粋であるべき: 依存の signal から値を計算するだけにする。副作用を混ぜると、遅延評価やキャッシュで実行回数が読めなくなる
- バッチ処理: 複数の signal を続けて変えると、素朴にはエフェクトが複数回走る。実物は変更をまとめて 1 回にする batch を持つ
- メモリとクリーンアップ: 購読は signal とエフェクトの双方向参照。エフェクトを dispose しないと購読が残り、リークする。フレームワークはコンポーネント破棄時に自動 dispose する
対照と実例
| 仮想DOM(React) | signal(Solid/Vue) | |
|---|---|---|
| 更新の起点 | 状態変更 → 再レンダリング | signal 変更 → 購読者 |
| 変更検出 | 木を diff | 依存グラフをたどる |
| 更新の粒度 | コンポーネント単位 | signal 単位(細かい) |
| 派生値 | useMemo(手動) | computed(自動追跡) |
| 無駄 | 変わらぬ部分も再構築 | 変わった箇所だけ |
裏どり:
- SolidJS: signal・effect・memo(computed)を前面に出したフレームワーク。この章の API はこれに近い
- Vue 3 Reactivity:
ref/reactive/computed/watchEffect。Proxy で読み取りを追跡する実行時方式 - Preact Signals: React にも signal を持ち込んだ実装。仮想DOMと signal の共存例
- Svelte 5 Runes: コンパイル時に依存を解析する方式。実行時追跡とは別のアプローチ
簡略化したこと
- バッチ処理なし: 複数の set をまとめる batch は省略。set ごとに即座にエフェクトが走る
- 同期実行: 実物はマイクロタスクでまとめて次のフレームで反映することが多い
- DOM 連携なし: signal を DOM 更新に繋ぐ部分は扱わない(テンプレートや vdom の役目)
- 循環依存の検出なし: 依存が輪を作ると無限ループしうる
参考資料
- SolidJS: Reactivity — signal ベースのリアクティビティの入門
- Vue: Reactivity in Depth — 依存追跡の仕組みの解説
- Preact Signals — signal の設計と仮想DOMとの共存
- 実装: frontend/reactivity