iT邦幫忙

2025 iThome 鐵人賽

DAY 14
0

前情提要

承接前面幾篇的內容(signal, effect, computed),我們把「副作用的執行時機」做得更可控:

  • 多次 set 合併 → 同一輪只重跑一次 effect
  • 一次性更新多個 signal → 避免中途狀態閃爍
  • 在區塊內讀取 → 總是拿到最新值(因為 set 是同步寫入,computed 會在 get 時 lazy 重算)

為什麼要 Batch

https://ithelp.ithome.com.tw/upload/images/20250815/201290207nwhBBivbI.png

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 銜接(只改一行)

// 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 & Transaction

batch(fn)

  • 進入 batch 後,scheduleJob 只會把 effect 收進佇列,不會立刻排 microtask
  • 退出最外層 batch 時一次 flushJobs()
  • 巢狀 batch 仍只在最外層結束時 flush
import { batch } from "./scheduler";

batch(() => {
  a.set(10);
  b.set(20);
  a.set(30);
}); // effect 只重跑一次

transaction(fn)

  • 目前語意 = batch(fn):合併副作用重跑
  • signal.set() 仍是同步寫入,所以區塊內讀值會看到最新狀態
  • 目前作用就是方便測試觀察,之後進階一點會結合 log & rollback
import { batch } from "./scheduler";

// 目前 = batch;預留未來升級空間
export function transaction<T>(fn: () => T): T {
  return batch(fn);
}

範例:Effect 只重跑一次

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();

範例:和 computed 的互動

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();

Batch 的時間線

多次 set 合併
https://ithelp.ithome.com.tw/upload/images/20250815/20129020KiFTPxNONP.png

Batch 資料流

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

踩過的坑

  • 在 batch 內重複 set 同一個 signal
    以最後一次為準,這是預期行為,若有需要可在 set 前做去重。

  • 在 batch 內做大量同步工作
    microtask 只有在 batch 結束才排,若中間工作很重,UI 更新會延後。

  • 想立即觀察 effect 是否已重跑
    flushSync():立即把佇列跑完。

結語

computed 決定「何時算」(lazy),batch/transaction 決定「何時跑副作用」(合併到結尾)。把這兩個概念釐清了,你的反應式系統用起來就會既穩又順。

到目前為止,我們開發的這套系統已經相當完整,如果懂行的朋友應該已經知道要怎麼移植到自己常用的框架內使用,大致上基本的概念我們已經實作完畢。

下一篇,我們來試著將這套系統,移入 React 環境下使用,並探討一些框架渲染的限制。


上一篇
實作 Computed
下一篇
React 應用(I):渲染心智與限制
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言