上一篇我們已經完成一個具備訂閱功能的 Signal 核心,這一篇我們來實作 Effect,讓每個依賴項都能自動去追蹤,順利讓原本靜態的圖能具有響應性的動起來。
createEffect(fn):在追蹤區塊執行 fn,自動收集依賴邊。signal.set():通知相依的 effect,microtask 合併重跑一次。onCleanup(cb) 與 dispose():重跑前清理、手動解除依賴。我們透過這張圖,來先建立角色分工的基本認識:
run / schedule / dispose。
EffectInstance 擁有節點 (Node),Registry 只是「查表」讓我們從節點拿回實例。
這張描述建構與首次執行:

建圖先於通知。本篇的行為核心是「能重跑」;圖的維護(收集與解除)在
run()內部完成。
這張把「訊號更新 → 重跑」走完:
signal.set 更新值。subs(誰訂閱了這個 signal)。schedule。
Registry 只是查表,真正「做事」的是
EffectInstance.schedule()(microtask 合併後呼叫run())。
// 上一篇的模型
// graph.ts
export type Kind = 'signal' | 'computed' | 'effect';
export interface Node {
  kind: Kind;
  deps: Set<Node>; // 我依賴了誰(effect / computed)
  subs: Set<Node>; // 誰依賴我(signal / computed)
}
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 -> Trackable
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;
  }
}
export function track(dep: Node) {
  if (!currentObserver) return;
  link(currentObserver, dep);
}
// registry.ts
import type { Node } from "./graph.js";
export interface EffectInstanceLike {
  schedule(): void;
}
export const EffectSlot: unique symbol = Symbol("EffectSlot");
export interface EffectCarrier {
  [EffectSlot]?: EffectInstanceLike;
}
export interface EffectRegistry {
  get(node: EffectCarrier): EffectInstanceLike | undefined;
  set(node: EffectCarrier, inst: EffectInstanceLike): void;
  delete(node: EffectCarrier): void;
}
export const SymbolRegistry: EffectRegistry = {
  get(node) {
    return node[EffectSlot];
  },
  set(node, inst) {
    Object.defineProperty(node, EffectSlot, {
      value: inst,
      enumerable: false,
      configurable: true
    });
  },
  delete(node) {
    Reflect.deleteProperty(node, EffectSlot);
  }
};
「Registry 負責維護 Node → EffectInstance 的關聯,讓
signal.set()能 O(1) 找到該重跑的 effect。
// effect.ts
import { unlink, withObserver, type Node } from "./graph.js";
import { SymbolRegistry, type EffectInstanceLike } from "./registry.js";
type Cleanup = () => void;
// 共用工具:LIFO 執行,確保最後清空
function drainCleanups(list: Cleanup[], onError?: (err: unknown) => void) {
  // LIFO:從尾到頭執行
  for (let i = list.length - 1; i >= 0; i--) {
    const cb = list[i];
    try {
      cb();
    } catch (e) {
      onError?.(e);
    }
  }
  list.length = 0;
}
// microtask 合併
const pending = new Set<EffectInstance>();
let scheduled = false;
function schedule(inst: EffectInstance) {
  if (inst.disposed) return;
  pending.add(inst);
  if (!scheduled) {
    scheduled = true;
    queueMicrotask(() => {
      scheduled = false;
      const list = Array.from(pending);
      pending.clear();
      for (const ef of list) ef.run();
    });
  }
}
let activeEffect: EffectInstance | null = null;
export function onCleanup(cb: Cleanup) {
  if (activeEffect) activeEffect.cleanups.push(cb);
}
export class EffectInstance implements EffectInstanceLike {
  node: Node = {
    kind: 'effect',
    deps: new Set(),
    subs: new Set()
  };
  cleanups: Cleanup[] = [];
  disposed = false;
  constructor(private fn: () => void | Cleanup) {
    SymbolRegistry.set(this.node, this); // 只碰 Registry
  }
  run() {
    if (this.disposed) return;
    
    // 1) 清理上次
    drainCleanups(this.cleanups);
    // 2) 解除舊依賴
    for (const dep of [...this.node.deps]) unlink(this.node, dep);
    // 3) 追蹤上下文執行,收集新依賴;支援回傳 cleanup
    activeEffect = this;
    try {
      const ret = withObserver(this.node, this.fn);
      if (typeof ret === 'function') this.cleanups.push(ret);
    } finally {
      activeEffect = null;
    }
  }
  schedule() { schedule(this); }
  dispose() {
    if (this.disposed) return;
    this.disposed = true;
    drainCleanups(this.cleanups);
    for (const dep of [...this.node.deps]) unlink(this.node, dep);
    this.node.deps.clear();
    SymbolRegistry.delete(this.node); // 只碰 Registry
  }
}
export function createEffect(fn: () => void | Cleanup) {
  const inst = new EffectInstance(fn);
  inst.run(); // 先跑一次收集依賴
  return () => inst.dispose();
}
// signal.ts
import { link, track, unlink, type Node } from "./graph.js";
import { SymbolRegistry } from "./registry.js";
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 | ((p: 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;
    for (const sub of node.subs) {
      if (sub.kind === 'effect') SymbolRegistry.get(sub)?.schedule();
    }
  };
  const subscribe = (observer: Node) => {
    link(observer, node);
    return () => unlink(observer, node);
  };
  return { get, set, subscribe, peek: () => node.value };
}
import { signal } from './signal';
import { createEffect, onCleanup } from './effect';
const a = signal(1);
const b = signal(2);
const stop = createEffect(() => {
  console.log('sum =', a.get() + b.get());
  onCleanup(() => console.log('cleanup before rerun'));
});
a.set(10); // microtask:cleanup before rerun → sum = 12
b.set(20); // microtask:cleanup before rerun → sum = 30
stop(); // 解除訂閱與清理

經過上述的流程,我們會建立一個可以符合運作的 Effect 核心,但還是要注意一些問題:
const v = a.get(); a.set(10); v 仍是舊值;要最新值請再呼叫 get()
set 合併:同一輪多次 set() 只重跑一次 effect(microtask 合併)。unlink 舊依賴再用 withObserver 收集新依賴,避免訂閱集合不斷增長。這一篇花了很多時間再製作圖表,也是希望能讓大家比較容易理解,畢竟牽扯到圖(Graph)這種資料結構,就會有很多細節需要處理。
下一篇,我們來討論 Effect 內部選型 (Symbol vs WeakMap),理解 WeakMap 的基礎,並嘗試另一種 WeakMapRegistry 的實踐方法。