はじめに
昨年、signalの監視を調整する:Angularのuntrackedの基本と実践という記事を書きました。
そんなuntrackedを使う機会が増えてきた中、untrackedの付け忘れに気づかず、あれ?と思う機会も同時に増えてきました。
そこで、untrackedが監視対象から外れる仕組みが気になり、今年はもう少し深掘りして、コードを読んで仕組みを理解しようと思います。
※実際にangularをgit cloneして、cursorでAIに聞きながらこの記事を書きました。
1. 読むべき対象ソースファイル
・packages/core/primitives/signals/src/untracked.ts
・packages/core/primitives/signals/src/graph.ts
この2つのソースコードを読みました。
2. untracked()は何をしているか?
untracked()自体はシンプル。ActiveConsumerを一瞬 null に差し替えて、終わったら元に戻す。
import {setActiveConsumer} from './graph';
export function untracked<T>(nonReactiveReadsFn: () => T): T {
const prevConsumer = setActiveConsumer(null);
try {
return nonReactiveReadsFn();
} finally {
setActiveConsumer(prevConsumer);
}
}
setActiveConsumerはgraph.tsにある共有ユーティリティ。
単に現在のコンシューマポインタを差し替えて返しているのがわかる。
export const SIGNAL: unique symbol = /* @__PURE__ */ Symbol('SIGNAL');
export function setActiveConsumer(consumer: ReactiveNode | null): ReactiveNode | null {
const prev = activeConsumer;
activeConsumer = consumer;
return prev;
}
※activeConsumerは依存関係を収集しているconsumer、つまり下記コード例のdoubled。
const count = signal(0);
const doubled = computed(() => count() * 2);
3. リンク構築
graph.tsのconsumerBeforeComputation/consumerAfterComputationを読むと、「計算開始→リンク構築→終了時にスタック復元」の流れがわかります。
リンク構築の流れ
-
計算開始時 (consumerBeforeComputation)
・setActiveConsumer(node) で「今、この effect が計算中」と記録
・これ以降、この effect が signal を読むと自動でリンクが作られる -
計算中
・effect が signal を読むたび、「この signal をこの effect が読んだ」というリンクが自動生成される
・このリンクがあるから、signal が変わったときに「どの effect を再実行すべきか」がすぐわかる -
計算終了時 (consumerAfterComputation)
・setActiveConsumer(prevConsumer) で親の consumer に戻す
・今回アクセスしなかった古いリンクは削除して、依存関係を最新化
具体例で理解する
const count = signal(0);
effect(() => {
console.log(count()); // ← ここで count を読む
});
このとき何が起きる?
- consumerBeforeComputation が呼ばれる
・「今、この effect が計算中」と記録 - count() を読む
・「count をこの effect が読んだ」というリンクが自動生成 - consumerAfterComputation が呼ばれる
・計算終了を記録
その後、count.set(1) すると:
・count が変わったことを検知
・リンクを辿って「この effect を再実行すべき」と判断
・effect が再実行される
コードで確認
export function consumerBeforeComputation(node: ReactiveNode | null): ReactiveNode | null {
if (node) resetConsumerBeforeComputation(node);
return setActiveConsumer(node); // ← ここで「今この effect が計算中」と記録
}
export function consumerAfterComputation(
node: ReactiveNode | null,
prevConsumer: ReactiveNode | null,
): void {
setActiveConsumer(prevConsumer); // ← 親の consumer に戻す
if (node) finalizeConsumerAfterComputation(node); // ← 不要なリンクを削除
}
4. untracked() が監視対象から外す仕組み
まず、通常の signal 読み取り(監視される場合)
effect(() => {
const value = someSignal(); // ← 通常の読み取り
});
このとき何が起きる?
- consumerBeforeComputation で setActiveConsumer(effect) が呼ばれる
- someSignal() を読むと、内部で producerAccessed(someSignal) が呼ばれる
- producerAccessed 内で activeConsumer !== null なので、リンクが作られる
- 「someSignal を effect が読んだ」という依存関係が記録される
untracked() を使った場合(監視されない)
effect(() => {
const value = untracked(() => someSignal()); // ← untracked で包む
});
このとき何が起きる?
- consumerBeforeComputation で setActiveConsumer(effect) が呼ばれる
- untracked() が呼ばれる
export function untracked<T>(nonReactiveReadsFn: () => T): T {
const prevConsumer = setActiveConsumer(null); // ← ここで activeConsumer を null に!
try {
return nonReactiveReadsFn(); // ← someSignal() を読む
} finally {
setActiveConsumer(prevConsumer); // ← 元に戻す
}
}
3. someSignal() を読むと、内部で producerAccessed(someSignal) が呼ばれる
4. しかし activeConsumer === null なので、producerAccessed は何もしない(リンクが作られない)
export function producerAccessed(node: ReactiveNode): void {
if (activeConsumer === null) {
return; // ← ここで終了!リンクは作られない
}
// ... 以下、リンク構築のコード(実行されない)
}
5. untracked() の finally で setActiveConsumer(prevConsumer) が呼ばれ、元の consumer に戻る
結果: リンクが作られない → 依存関係が記録されない → 監視対象から外れる ✅
つまり、4でリンクが作られないためリンクを辿って再実行する判断ができないから監視対象から外れる。
最後に
フワッとではあるが、Consumer、リンクについて知ることができました。
また、untrackedがどうやって監視対象から外れるかも理解できました。
明日は、 @rysiva さんです!!