iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0

前情提要

在前一篇中,我們已經理解了 Scheduler 的角色:負責在資料變動後,安排下游任務 (jobs) 進行批次更新。
但若要讓這個系統長期穩定運作,「記憶體管理」「依賴圖管理」 就變得非常關鍵。

這一篇我們就深入探討這兩個層面。

為什麼需要圖 (Graph)?

在 fine-grained reactivity 系統中,每個 signal、computed、effect 之間的關係,本質上就是一張 有向圖 (Directed Graph)

  • 節點 (Node)
    • Signal:儲存 state 的來源
    • Computed:由其他節點推導出的衍生值
    • Effect:具有副作用的節點(例如 DOM 更新)
  • 邊界 (Edge)
    • 從「依賴者 → 被依賴者」的關係建立邊界,用來追蹤依賴。

這樣的設計有兩個目的:

  1. 精準更新:當某個 signal 改變,只需要沿著邊去找到受影響的節點。
  2. 避免重複運算:依賴圖能確保計算只在必要時觸發。

如果沒有圖,Scheduler 只能用廣播 (broadcast) 的方式通知所有訂閱者,效能就會大打折扣。

記憶體管理的挑戰

一旦引入了「圖」,就會遇到兩個記憶體管理問題:

Dangling Node (懸掛節點)

當一個 effect 或 computed 被移除 (例如 React unmount,或 Vue component 銷毀),對應的節點若不清除,就會造成 memory leak。

→ 解法:在 dispose 時,需要 unlink 它對上游的所有邊。

Stale Edge (過期依賴)

當某個 computed 的依賴條件改變時,可能需要移除舊的依賴邊,重新建立新的依賴。

→ 解法:在 tracking 階段,使用「link/unlink」來動態維護依賴關係。

GC 與 Retain Cycle

在某些情況下,若 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;
}

這樣就能確保:

  • 不會殘留過期的依賴 (避免 Stale Edge)
  • 記憶體能隨著 component 銷毀而釋放 (避免 Memory Leak)

Scheduler 與圖的互動

Scheduler 在執行時,通常會依賴這張圖來決定哪些節點要更新:

  • signal.set() → 對應節點標記為 stale
  • Scheduler 將 stale 節點加入 queue
  • flushJobs() → 從 queue 取出,沿著 subs 更新下游
  • 若某些節點已無訂閱者 → 觸發 auto-unlinkdispose

https://ithelp.ithome.com.tw/upload/images/20250829/20129020CCL1u83P0Z.png

最佳化策略

在大型應用中,光有基本的圖管理還不夠,通常還需要進一步優化。以下幾個常見的策略:

1. Auto-Unlink(自動解除依賴)

在標準的 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();
  }
}

這樣,當一個 computedeffect 沒有任何訂閱者時,就會自動清理掉上游的依賴,避免「懸掛依賴」。
在 React 或 Vue 的語境下,這有點像是 unmount 時自動解除所有事件監聽器,確保資源能被釋放。

2. Equals 策略與效能

signal.set() 的流程中,我們通常會做一次 equals 檢查,以避免不必要的更新:

const set = (next: T) => {
  if (!equals(value, next)) {
    value = next;
    markStale(thisNode);
  }
};

問題是,「相等性檢查」本身就有不同的策略與成本差異:

  • Object.is(預設)
    • 優點:JS 原生的嚴格等價,比較速度最快,但僅限於 primitive 與 reference。
    • 缺點:對於物件或陣列,每次都會被當作新值。
  • Shallow Equal(淺比較)
    • 優點:適合常見的物件型 state,能避免因為 reference 改變造成的頻繁更新。
    • 缺點:仍有 O(n) 的比較成本。
  • Custom Equal(自訂策略)
    • 優點:高度彈性,允許使用者傳入一個函數,例如比較 ImmutableJS 結構、深度相等、甚至 domain-specific 規則。
    • 缺點:需要額外學習成本,且可能導致效能隱憂。

效能考量

  • 大量頻繁更新 的場景(例如遊戲 loop、動畫),建議用 Object.is,保持最輕量。
  • 表單輸入 / UI 狀態 場景,使用 shallow equal 是個務實折衷。
  • 複雜 domain 資料結構(例如 AST、Immutable.js)中,才會考慮 custom equals。

3. Lazy Disposal

有些框架會延遲真正的 dispose(),直到 GC 壓力出現時才清理。

4. 弱引用依賴

透過 WeakMap 保存某些邊界,可以避免強引用造成的 Retain Cycle。

5. 分層 Scheduler

將圖拆分成不同 layer,例如 UI 更新層、計算層、I/O 層,讓不同任務可以被分開調度。

結語

Scheduler 的工作,並不僅僅是「排程執行」而已。
真正讓 signal 系統高效且可持續的關鍵,在於 圖結構的維護記憶體管理策略

這也是為什麼 Solid、Vue、Preact Signals 等實作,都會有專門的一層 Graph 模組。

下一篇我們會繼續探討:
「優先級與分層 Scheduler」,也就是如何在不同類型的任務中進一步優化效能。


上一篇
進階內核(III):Scheduler 進階
下一篇
進階內核(V):優先級與分層 Scheduler
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言