承接前面幾篇的內容(signal, effect, computed),我們把「副作用的執行時機」做得更可控:
set 合併 → 同一輪只重跑一次 effect
signal → 避免中途狀態閃爍set 是同步寫入,computed 會在 get 時 lazy 重算)
computed 保持 lazy,batch / transaction 只負責調整 effect 的排程(push 合併),不會強迫提前重算。
我們透過模組化一個 scheduler 的方處來處理以下要點:
scheduler.ts:微型排程器(去重、microtask 合併、支援 batch / flushSync)batch(fn):把一串更新包起來,結束時再 flush 副作用transaction(fn):目前語意 = batch(fn)(目前是方便測試使用,之後可升級其他進階功能)effect 僅改一行:schedule() → 呼叫 scheduleJob(this)
scheduler.ts之前我們把「Set + queueMicrotask + flush」寫在 EffectInstance 裡;
現在抽成模組,語意更清楚,也方便擴充。
// scheduler.ts
export interface Schedulable { run(): void; disposed?: boolean }
const queue = new Set<Schedulable>();
let scheduled = false;
let batchDepth = 0;
// 將工作加入佇列;若不在 batch 中,排到下一個 microtask 一起執行
export function scheduleJob(job: Schedulable) {
  if (job.disposed) return;
  queue.add(job);
  if (!scheduled && batchDepth === 0) {
    scheduled = true;
    queueMicrotask(flushJobs);
  }
}
// 把一段更新合併成一次副作用重跑
export function batch<T>(fn: () => T): T {
  batchDepth++;
  try { return fn(); }
  finally {
    batchDepth--;
    if (batchDepth === 0) flushJobs();
  }
}
// 立即清空佇列(便於測試)
export function flushSync() {
  if (!scheduled && queue.size === 0) return;
  flushJobs();
}
function flushJobs() {
  scheduled = false;
  let guard = 0;
  while (queue.size) {
    const list = Array.from(queue);
    queue.clear();
    for (const job of list) job.run();
    if (++guard > 10000) throw new Error("Infinite update loop");
  }
}
// effect.ts
import { scheduleJob } from "./scheduler";
export class EffectInstance /* ... */ {
  /* ... 既有成員與 run() 不變 ... */
  schedule() { scheduleJob(this); } // ← 原本是把自己丟進 Set 並 queueMicrotask
}
signal.set() 與 computed.get() 不用改:
signal.set() 還是做「對 effect → schedule;對 computed → markStale」computed.get() 一樣在被讀時才 lazy 重算batch(fn)
batch 後,scheduleJob 只會把 effect 收進佇列,不會立刻排 microtaskbatch 時一次 flushJobs()
batch 仍只在最外層結束時 flushimport { batch } from "./scheduler";
batch(() => {
  a.set(10);
  b.set(20);
  a.set(30);
}); // effect 只重跑一次
transaction(fn)
signal.set() 仍是同步寫入,所以區塊內讀值會看到最新狀態import { batch } from "./scheduler";
// 目前 = batch;預留未來升級空間
export function transaction<T>(fn: () => T): T {
  return batch(fn);
}
const a = signal(1);
const b = signal(2);
const stop = createEffect(() => {
  console.log("sum =", a.get() + b.get());
});
batch(() => {
  a.set(10);
  b.set(20);
  a.set(30);
});
// 結束後只輸出一次:sum = 50
stop();
const a = signal(1);
const b = signal(2);
const sum = computed(() => a.get() + b.get());
const stop = createEffect(() => {
  console.log("double =", sum.get() * 2);
});
transaction(() => {
  a.set(5);
  b.set(7);
  // 區塊內讀 → 需要時 lazy 重算,拿到最新值
  console.log("peek in tx =", sum.get() * 2); // 24
});
// microtask:effect 只重跑一次 -> "double = 24"
stop();
多次 set 合併

在 batch 內重複 set 同一個 signal
以最後一次為準,這是預期行為,若有需要可在 set 前做去重。
在 batch 內做大量同步工作
microtask 只有在 batch 結束才排,若中間工作很重,UI 更新會延後。
想立即觀察 effect 是否已重跑
用 flushSync():立即把佇列跑完。
computed 決定「何時算」(lazy),batch/transaction 決定「何時跑副作用」(合併到結尾)。把這兩個概念釐清了,你的反應式系統用起來就會既穩又順。
到目前為止,我們開發的這套系統已經相當完整,如果懂行的朋友應該已經知道要怎麼移植到自己常用的框架內使用,大致上基本的概念我們已經實作完畢。
下一篇,我們來試著將這套系統,移入 React 環境下使用,並探討一些框架渲染的限制。