iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0

引言

這篇會依照上一篇結尾的概念延續,基本上就是透過「閉包 + 解構賦值概念」,做出的狀態暫存機制,範例如下:

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 };
}

還記得這張圖嗎?
https://ithelp.ithome.com.tw/upload/images/20250812/201290207XqliKSlH7.png

我們還缺少了一個可以存儲依賴關係的 Observers,也就是要完成 Signal 最後一塊拼圖。

本篇目標

  • 讓回傳能夠新增一組 subscribe 函數,即 const { get, set, subscribe} = signal()
  • 依賴追蹤最小核心:Observer 與 Trackable 的互相遞迴型別
  • withObserver / track 工具:用「讀取行為」自動建立訂閱邊界(dependency edges)
  • 理解「事件訂閱(imperative)」vs「依賴追蹤(declarative)」差異

追蹤與觀察

首先我們先來釐清追蹤的概念,能被追蹤的角色代表者資訊的發起者,又或者叫被訂閱,舉個明顯的例子就是 Signal;那觀察的概念就很簡單了,又或者叫訂閱者,舉個明顯的例子就是 Effect。

那麼簡單的二分法,我們大概可以得到以下結論:

  • Trackable:
    可被訂閱的來源(例如 signal / computed)。
    內部維護:subs: Set<Observer>(誰訂閱了我)
  • Observer:
    觀察者(例如 computed / effect)。
    內部維護:deps: Set<Trackable>(我訂閱了誰)

https://ithelp.ithome.com.tw/upload/images/20250812/20129020l2lRP2GRRk.png
從來源者的角度來看,如下圖:
https://ithelp.ithome.com.tw/upload/images/20250812/20129020MYJWfI5g9x.png
從 Observer 的角度來看,如下圖:
https://ithelp.ithome.com.tw/upload/images/20250812/20129020XYYMWj76Ws.png

這是一個雙向圖:來源知道訂閱者,訂閱者知道來源。

對了,這邊探討的話會應用到 圖(Graph) 的資料結構概念,最好還是釐清一下基本原理,這樣才比較不會看不懂後續的描述,因為後面優化的部分就會頻繁提及這些概念。

TypeScript型別與核心

按照上述的概念可以簡化成以下的型別:

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);
}
  • 任何「在追蹤期間的讀取」都會建立邊界:誰讀了誰。
  • 這裡只建邊界,不通知。通知/失效(dirty)留到下一篇。

與開頭的 signal 結合

有了上面前面的機制,我們與開頭的基本閉包 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() 方便測試與接其他框架使用。

事件訂閱 vs 依賴追蹤

面向 事件訂閱(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:重跑前先移除舊依賴並執行清理。

上一篇
實作 Signal 前你需要的兩個 JS 基礎觀念
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言