iT邦幫忙

2025 iThome 鐵人賽

DAY 13
1

回顧前情提要

  • graph.tsNode{ kind, deps, subs }link/unlinkwithObserver/track(沿用實作 Effect(I))
  • EffectRegistryEffects.get/set/delete(node: Node)(沿用實作Effect (II))
  • signal.tsset() 會遍歷 subs,對 effectEffects.get(sub)?.schedule()(下面會加上 computed 的分支)

這裡的 Computed 是哪種「Memo」?

TL;DR:等同 Vue 的 computed / Solid 的 createMemo;不是 React 的 useMemo
它是反應式資料流圖上的節點,自動追蹤依賴,且被讀取時才重算(lazy),不綁定任何元件生命週期。

差異對照

面向 本文的 computed(signals) Vue computed Solid createMemo React useMemo
依賴宣告 自動追蹤(讀誰訂誰) 自動 自動 手動 deps array
何時計算 讀取時stale 才重算(lazy) lazy lazy 渲染時依 deps array
生命週期 獨立於元件 隨實例 隨 root 綁元件 render
在資料流圖中的角色 Observer + Trackable(有 deps / subs) 同左 同左 不是 reactive node(只是 render 快取)
是否適合放副作用

對照範例

Vue

const a = ref(1);
const b = ref(2);
const sum = computed(() => a.value + b.value);

Solid

const [a] = createSignal(1);
const [b] = createSignal(2);
const sum = createMemo(() => a() + b());

本文(signals)目標

const a = signal(1);
const b = signal(2);
const sum = computed(() => a.get() + b.get()); // 自動追蹤,lazy 重算

React(僅供對照,目的不太一樣)

const [a, setA] = useState(1);
const [b, setB] = useState(2);
// 這是 render 階段快取,不參與 reactive graph
const sum = useMemo(() => a + b, [a, b]);

接下來你看到的 computed,就是「讀取才算、算完快取、圖上有上下游」的那種。

設計要點

  • computed 同時是 Observer(會去依賴別人)與 Trackable(可被依賴):既有 deps 又有 subs
  • 兩個狀態:
    • stale: boolean:是否需要重算(髒標記)
    • computing: boolean:重入保護(循環依賴偵測)
  • equals(a, b):結果相等時不更新快取(預設 Object.is

誰依賴誰

下圖箭頭代表「我依賴了誰」。computed 既是 Observer(有 deps)也是 Trackable(有 subs)。
https://ithelp.ithome.com.tw/upload/images/20250815/20129020SIP8UlW6zR.png

  • signal:只被依賴(沒有 deps)。
  • effect:只去依賴(沒有 subs)。
  • computed:上下游各一手,夾在中間。

推與拉:從 set 到重跑(含 Registry)

signal.set 只推送失效(作髒標記 computed、排程 effect);真正的「計算」等被讀取時才發生。
https://ithelp.ithome.com.tw/upload/images/20250815/20129020qKxcJL8gpM.png

  • effects registry:你的 EffectRegistry(Symbol 或 WeakMap 皆可)。
  • queued in microtask:同一輪多次 set 只重跑一次。

computed.get 的懶(Lazy)計算流程

被讀取時才檢查 stale。髒了就重算:先解除舊依賴,再在追蹤上下文內執行並快取。
https://ithelp.ithome.com.tw/upload/images/20250815/20129020dv5Y3R2KbR.png

  • push 失效:signal.setcomputed 只做 markStale,對 effect 交給 Effects.get(node)?.schedule()
  • pull 計算:computed.get() 檢查 stale 才重算與更新依賴。
  • 有循環保護:computing 期間再讀自己會丟錯,避免自觸發。

實作 computed

// computed.ts
import { link, unlink, withObserver, track, type Node } from "./graph";
import { SymbolRegistry as Effects } from "./registry";

type Comparator<T> = (a: T, b: T) => boolean;
const defaultEquals = Object.is;

/** 供 signal.set() 與其它 computed 標髒標記使用 */
export function markStale(node: Node) {
  if (node.kind !== "computed") return;
  const c = node as Node & { stale: boolean };
  if (c.stale) return; // 已經髒就別重複傳染
  c.stale = true;

  // 向下傳染:讓依賴此 computed 的節點一併反應
  for (const sub of node.subs) {
    if (sub.kind === "computed") {
      markStale(sub); // 傳染給下游 computed(多層)
    } else if (sub.kind === "effect") {
      Effects.get(sub)?.schedule(); // 讓 effect 排進 microtask
    }
  }
}

export function computed<T>(
  fn: () => T,
  equals: Comparator<T> = defaultEquals
) {
  const node: Node & {
    kind: "computed";
    value: T;
    stale: boolean;
    equals: Comparator<T>;
    computing: boolean;
    hasValue: boolean;
  } = {
    kind: "computed",
    deps: new Set(),
    subs: new Set(),
    value: undefined as unknown as T,
    stale: true, // 第一次讀時要算
    equals,
    computing: false,
    hasValue: false,
  };

  function recompute() {
    if (node.computing) throw new Error("Cycle detected in computed");
    node.computing = true;

    // 解除舊依賴(避免依賴漂移造成洩漏)
    for (const d of [...node.deps]) unlink(node, d);

    // 在追蹤上下文中計算,過程中讀到誰就自動 link(node → dep)
    const next = withObserver(node, fn);

    if (!node.hasValue || !node.equals(node.value, next)) {
      node.value = next;
      node.hasValue = true;
    }
    node.stale = false;
    node.computing = false;
  }

  const get = () => {
    track(node); // 讓觀察者(effect / computed)能訂閱我
    if (node.stale || !node.hasValue) recompute();
    return node.value;
  };

  const peek = () => node.value;

  const dispose = () => {
    // 解除與所有上/下游關係
    for (const d of [...node.deps]) unlink(node, d);
    for (const s of [...node.subs]) unlink(s, node);
    node.deps.clear();
    node.subs.clear();
    node.stale = true;
    node.hasValue = false;
  };
  // peek, dispose, _node 都是為了測試方便,正常情況下 computed 只要回傳 get 的方法就好
  return { get, peek, dispose, _node: node };
}

computed 接進 signal

signal.tsset() 訂閱迴圈補上 computed 分支:

// signal.ts
import { markStale } from "./computed";
// ...位於 set() 段落
for (const sub of node.subs) {
  if (sub.kind === "effect") {
    Effects.get(sub)?.schedule();
  } else if (sub.kind === "computed") {
    markStale(sub); // 只標髒,不立刻重算
  }
}

push 失效(標髒標記) + pull 計算(讀取時才計算),effect 會被排程重跑,但他在回呼的 compute.get() 處才會被觸發真正的重算。

使用範例

const a = signal(1);
const b = signal(2);

const sum = computed(() => a.get() + b.get()); // 3
const double = computed(() => sum.get() * 2); // 6

const stop = createEffect(() => {
  console.log("double =", double.get());
  onCleanup(() => console.log("cleanup")); // 重跑前呼叫
});

a.set(5); // 標髒標記 sum、double;排程 effect
// microtask:cleanup → "double = 14"(此刻才重算 sum 與 double)

stop();

流程圖:從 set 到 lazy 重算

https://ithelp.ithome.com.tw/upload/images/20250815/20129020IxsqLccWIc.png

結語

到目前為止我們完成了:

  • signal.set()push 失效——對 computed 只標記 stale,對 effect 交給排程(microtask 合併)
  • computed.get():pull 計算——被讀取時才在追蹤上下文內重算,並更新依賴
  • EffectRegistry:Node → EffectInstance 的查表(Symbol/WeakMap 皆可)

下一篇,我們來處理 Batch & Transation 的機制,來解決不必要的多次更新問題。


上一篇
實作 effect (II): Effect 內部選型
下一篇
實作 Batch & Transaction
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言