這篇會依照上一篇結尾的概念延續,基本上就是透過「閉包 + 解構賦值概念」,做出的狀態暫存機制,範例如下:
export type Signal<T> = {
get(): T;
set(next: T | ((prev: T) => T)): void;
};
export function signal<T>(initial: T): Signal<T> {
let value = initial;
const get = () => value;
const set = (next: T | ((p: T) => T)) => {
const nxtVal = typeof next === 'function' ? (next as (p: T) => T)(value) : next;
const isEqual = Object.is(value, nxtVal);
if (!isEqual) value = nxtVal;
};
return { get, set };
}
還記得這張圖嗎?
我們還缺少了一個可以存儲依賴關係的 Observers,也就是要完成 Signal 最後一塊拼圖。
const { get, set, subscribe} = signal()
withObserver
/ track
工具:用「讀取行為」自動建立訂閱邊界(dependency edges)首先我們先來釐清追蹤的概念,能被追蹤的角色代表者資訊的發起者,又或者叫被訂閱,舉個明顯的例子就是 Signal;那觀察的概念就很簡單了,又或者叫訂閱者,舉個明顯的例子就是 Effect。
那麼簡單的二分法,我們大概可以得到以下結論:
subs: Set<Observer>
(誰訂閱了我)deps: Set<Trackable>
(我訂閱了誰)
從來源者的角度來看,如下圖:
從 Observer 的角度來看,如下圖:
這是一個雙向圖:來源知道訂閱者,訂閱者知道來源。
對了,這邊探討的話會應用到 圖(Graph) 的資料結構概念,最好還是釐清一下基本原理,這樣才比較不會看不懂後續的描述,因為後面優化的部分就會頻繁提及這些概念。
按照上述的概念可以簡化成以下的型別:
export interface Trackable {
subs: Set<Observer>;
};
export interface Observer {
deps: Set<Trackable>;
};
我們透過 currentObserver
+ track
的機制運行:
let currentObserver: Observer | null = null;
export function withObserver<T>(obs: Observer, fn: () => T): T {
const prev = currentObserver;
const currentObserver = obs;
try {
return fn();
} finally {
currentObserver = prev;
}
}
export function track(dep: Trackable) {
if (!currentObserver) return;
dep.subs.add(currentObserver);
currentObserver.deps.add(dep);
}
有了上面前面的機制,我們與開頭的基本閉包 signal 範例結合,實現出一個可訂閱的最小核心:
type Kind = 'signal' | 'computed' | 'effect';
export interface Node {
kind: Kind;
deps: Set<Node>; // 我依賴了誰(只對 computed/effect 會用到)
subs: Set<Node>; // 誰依賴我(signal/computed 都會被訂閱)
}
// 不變式:signal 不能有 deps;effect 不提供 subs
export function link(from: Node, to: Node) {
if (from.kind === 'signal') {
throw new Error('Signal nodes cannot depend on others');
}
from.deps.add(to);
to.subs.add(from);
}
export function unlink(from: Node, to: Node) {
from.deps.delete(to);
to.subs.delete(from);
}
// 追蹤工具:在「observer context」中讀取時自動建邊界(只建圖,不通知)
let currentObserver: Node | null = null;
export function withObserver<T>(obs: Node, fn: () => T): T {
const prev = currentObserver;
currentObserver = obs;
try {
return fn();
} finally {
currentObserver = prev;
}
}
function track(dep: Node) {
if (!currentObserver) return; // 非追蹤階段就只是一般讀取
link(currentObserver, dep); // 觀察者 -> 被觀察者
}
// 物件回傳,便於解構; 將 Object.is 先抽出來成 equals,下一篇用於通知判斷
type Comparator<T> = (a: T, b: T) => boolean;
const defaultEquals = Object.is;
export function signal<T>(initial: T, equals: Comparator<T> = defaultEquals) {
// 單一節點 + 私有值
const node: Node & { kind: 'signal'; value: T; equals: Comparator<T> } = {
kind: 'signal',
deps: new Set(), // 永遠保持空集合(由 link() 保證)
subs: new Set(),
value: initial,
equals,
};
const get = () => {
track(node);
return node.value;
};
const set = (next: T | ((prev: T) => T)) => {
const nxtVal = typeof next === 'function' ? (next as (p: T) => T)(node.value) : next;
if (node.equals(node.value, nxtVal)) return;
node.value = nxtVal;
// 本篇只談訂閱建圖,不做 dirty/通知;下一篇再接續
};
// 事件式顯式訂閱(用來對照宣告式追蹤);回傳取消訂閱
const subscribe = (observer: Node) => {
if (observer.kind === 'signal') {
throw new Error('A signal cannot subscribe to another node');
}
link(observer, node);
return () => unlink(observer, node);
};
return { get, set, subscribe, peek: () => node.value };
}
為什麼既有 track
又提供 subscribe
?
track()
是宣告式依賴:在「追蹤區塊」內讀到誰,就自動訂閱誰(之後給 computed
/effect
用)。subscribe()
是命令式訂閱:你可以手動把某個 Observer 掛到某個 signal 上,對照傳統事件訂閱的做法,便於相容於不同框架。peek()
方便測試與接其他框架使用。面向 | 事件訂閱(subscribe(cb) ) |
依賴追蹤(track /withObserver ) |
---|---|---|
目標 | 值改變時 立即呼叫 callback | 建立 資料流圖,供之後的重算/排程使用 |
建立方式 | 手動註冊與移除 | 在「讀取階段」自動追蹤 |
適用情境 | I/O、日誌、橋接第三方系統 | computed/effect 的來源收集與傳染式失效 |
生命週期 | 由使用者自己管理 | 之後可由 computed/effect 的生命周期自動管理 |
到這裡,我們已完成「signal + 訂閱機制」:
在 withObserver(() => a.get())
的追蹤區塊裡,會自動建立 Observer → Trackable 的依賴邊界。
本篇僅建圖,不觸發任何重新執行。
下一篇要做的事很單純:實作 effect,讓圖真正「動」起來。
kind: 'effect'
的節點,第一次執行時用 withObserver
收集依賴。signal.set()
發生時,通知對應的 effect,在微任務中合併重新執行。dispose
/ onCleanup
:重跑前先移除舊依賴並執行清理。