在前一篇中,我們已經理解了 Scheduler 的角色:負責在資料變動後,安排下游任務 (jobs) 進行批次更新。
但若要讓這個系統長期穩定運作,「記憶體管理」 與 「依賴圖管理」 就變得非常關鍵。
這一篇我們就深入探討這兩個層面。
在 fine-grained reactivity 系統中,每個 signal、computed、effect 之間的關係,本質上就是一張 有向圖 (Directed Graph)。
這樣的設計有兩個目的:
如果沒有圖,Scheduler 只能用廣播 (broadcast) 的方式通知所有訂閱者,效能就會大打折扣。
一旦引入了「圖」,就會遇到兩個記憶體管理問題:
當一個 effect 或 computed 被移除 (例如 React unmount,或 Vue component 銷毀),對應的節點若不清除,就會造成 memory leak。
→ 解法:在 dispose 時,需要 unlink 它對上游的所有邊。
當某個 computed 的依賴條件改變時,可能需要移除舊的依賴邊,重新建立新的依賴。
→ 解法:在 tracking 階段,使用「link/unlink」來動態維護依賴關係。
在某些情況下,若 effect 間產生閉環引用,JS 的 GC 雖然能處理,但如果我們的內部結構仍保留強引用,就會讓記憶體無法釋放。
→ 解法:避免雙向持有,或使用弱引用 (WeakMap/WeakRef) 來追蹤。
在 signal 系統中,通常會有一個 Graph Layer 來專門處理這些問題。
你可以對應一下我們之前寫的 signal,下面是簡化版本的:
export interface Node {
deps?: Set<Node>; // 上游
subs?: Set<Node>; // 下游
stale?: boolean; // 是否為髒值
disposed?: boolean; // 是否已被釋放
}
補: 有的可能命名方式不同,或擺的方式不同,但大致上都差不多,多看一些 library 的源碼就能理解了。
當我們進行一來追蹤的時候:
export function link(source: Node, target: Node) {
(source.deps ??= new Set()).add(target);
(target.subs ??= new Set()).add(source);
}
export function unlink(source: Node, target: Node) {
source.deps?.delete(target);
target.subs?.delete(source);
}
在 effect 銷毀時:
export function dispose(node: Node) {
if (node.deps) {
for (const s of node.deps) {
s.subs?.delete(node);
}
}
node.deps?.clear();
node.subs?.clear();
node.disposed = true;
}
這樣就能確保:
Scheduler 在執行時,通常會依賴這張圖來決定哪些節點要更新:
signal.set()
→ 對應節點標記為 stale
stale
節點加入 queueflushJobs()
→ 從 queue 取出,沿著 subs 更新下游auto-unlink
或 dispose
在大型應用中,光有基本的圖管理還不夠,通常還需要進一步優化。以下幾個常見的策略:
在標準的 link/unlink 流程中,當某個 target node 不再有任何下游訂閱者 (subscribers) 時,若我們仍然保留它的上游依賴,會導致 無效的邊界 長期存在。這雖然不會馬上造成錯誤,但卻會讓整張圖越來越龐大,影響效能。
解法就是 auto-unlink:
function autoUnlink(node: Node) {
if (!node.subs || node.subs.size === 0) {
for (const s of node.deps ?? []) {
s.subs?.delete(node);
}
node.deps?.clear();
}
}
這樣,當一個 computed
或 effect
沒有任何訂閱者時,就會自動清理掉上游的依賴,避免「懸掛依賴」。
在 React 或 Vue 的語境下,這有點像是 unmount
時自動解除所有事件監聽器,確保資源能被釋放。
在 signal.set()
的流程中,我們通常會做一次 equals 檢查,以避免不必要的更新:
const set = (next: T) => {
if (!equals(value, next)) {
value = next;
markStale(thisNode);
}
};
問題是,「相等性檢查」本身就有不同的策略與成本差異:
Object.is
,保持最輕量。有些框架會延遲真正的 dispose(),直到 GC 壓力出現時才清理。
透過 WeakMap
保存某些邊界,可以避免強引用造成的 Retain Cycle。
將圖拆分成不同 layer,例如 UI 更新層、計算層、I/O 層,讓不同任務可以被分開調度。
Scheduler 的工作,並不僅僅是「排程執行」而已。
真正讓 signal 系統高效且可持續的關鍵,在於 圖結構的維護 與 記憶體管理策略。
這也是為什麼 Solid、Vue、Preact Signals 等實作,都會有專門的一層 Graph 模組。
下一篇我們會繼續探討:
「優先級與分層 Scheduler」,也就是如何在不同類型的任務中進一步優化效能。