承接前面幾篇的內容(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 環境下使用,並探討一些框架渲染的限制。