iT邦幫忙

2025 iThome 鐵人賽

DAY 23
1

前言

前面已經示範了如何將我們設計好的 signal 機制,容入兩個主流框架下(React、Vue),這篇開始我們來回顧我們設計好的 signal 核心機制,探討哪些可以更好的地方。

快速導覽

把「多步、跨 await 的更新」合併成一次 effect 重跑,同時不影響我們既有的 lazy computedmicrotask 排程設計

本篇在不改你對外 API 的前提下,只擴充 scheduler.ts 與極少數接點。

為什麼需要 Transaction(async)

  • 現在就已很穩:同一個 call stack 內,多次 set() 會被我們的 schedulerSet + queueMicrotask)合併到同一個 microtask,effect 只重跑一次。
  • 但跨 await 不行:每個 await 都是新的微任務;沒有 Transaction,effect 會各跑一次。

沒有 vs 有 Transaction

// ❌ 沒有 transaction:effect 會跑兩次
async function onClick() {
  a.set(1); // 安排第 1 輪 flush
  await fetch("/api");
  b.set(2); // 安排第 2 輪 flush
}

// ✅ 有 transaction:effect 只跑一次(交易結束才 flush)
async function onClick() {
  await transaction(async () => {
    a.set(1);
    await fetch("/api");
    b.set(2);
  });
}

最小改動:擴充 scheduler.ts

核心想法

  • 共用原本的 batchDepth:讓 batch()transaction() 可任意巢狀。
  • scheduleJob()batchDepth > 0 時只入佇列,不安排 microtask。
  • 離開最外層 transaction 時做一次 flushJobs()
// scheduler.ts
export interface Schedulable { run(): void; disposed?: boolean }

const queue = new Set<Schedulable>();
let scheduled = false;
let batchDepth = 0;

export function scheduleJob(job: Schedulable) {
  if (job.disposed) return;
  queue.add(job);
  // 只有在「不在批次/交易中」才安排 microtask
  if (!scheduled && batchDepth === 0) {
    scheduled = true;
    queueMicrotask(flushJobs);
  }
}

// 與原本相同:同步區塊合併,結尾 flush 一次
export function batch<T>(fn: () => T): T {
  batchDepth++;
  try {
    return fn();
  } finally {
    batchDepth--;
    if (batchDepth === 0) flushJobs();
  }
}

// Promise 判斷
function isPromiseLike<T = unknown>(v: any): v is PromiseLike<T> {
  return v != null && typeof v.then === "function";
}

// 新增:支援 async 的交易;跨 await 合併,最外層結束時 flush 一次
export function transaction<T>(fn: () => T): T;
export function transaction<T>(fn: () => Promise<T>): Promise<T>;
export function transaction<T>(fn: () => T | Promise<T>): T | Promise<T> {
  batchDepth++;
  try {
    const out = fn();
    if (isPromiseLike<T>(out)) {
      // 非同步:等 fn 完成(成功/失敗)後再出站並視需要 flush
      return Promise.resolve(out).finally(() => {
        batchDepth--;
        if (batchDepth === 0) flushJobs();
      });
    }
    // 同步:直接出站並視需要 flush
    batchDepth--;
    if (batchDepth === 0) flushJobs();
    return out as T;
  } catch (e) {
    // 例外也要正確出站並做一次 flush
    batchDepth--;
    if (batchDepth === 0) flushJobs();
    throw e;
  }
}

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");
  }
}

  • 行為相容:你現有的所有 batch 用法完全不受影響;多了 transaction(async) 後,跨 await 的多次 set() 也會合併成一次 effect 重跑。
  • 既有程式不用改signal.set() 照舊呼叫 effect.schedule()EffectInstance.schedule() 內是 scheduleJob(this)。computed 仍維持 lazy(只標髒標記,不進 scheduler)。

行為保證

  • Computed 還是 lazyset() 只把它標記為 stale,不因為 transaction 而提前重算。
  • Effect 只在交易結束跑一次:交易內任何 set() 都不會排 microtask,只有在最外層 transaction 結束(或 throw)後,一次 flushJobs()
  • 可巢狀batchDepth 支援巢狀交易,只有最外層退出時才 flush。
  • 例外安全fn throw 也會正確出棧並 flush(見上方 catch/finally 邏輯)。

使用指南

React 中怎麼用

await 的多步更新 → transaction(async) 合併為一次重跑

// Counter.tsx
import React from "react";
import { signal } from "../core/signal.js";
import { createEffect } from "../core/effect.js";
import { transaction } from "../core/scheduler.js";
import { useSignalValue } from "./react-adapter";

// 資料層(可與 React 無關)
const a = signal(0);
const b = signal(0);

// 用 createEffect 觀察重跑次數
createEffect(() => {
  // 一次重跑會同時讀到 a/b 的最新值
  console.log("effect run:", a.get(), b.get());
});

export function Counter() {
  const va = useSignalValue(a);
  const vb = useSignalValue(b);

  const onClick = async () => {
    await transaction(async () => {
      a.set(va + 1);
      await Promise.resolve(); // 模擬一個 await(例如 fetch)
      b.set(vb + 1);
    }); // ← 交易結束才 flush,一次重跑
  };

  return (
    <div>
      <p>a={va} / b={vb}</p>
      <button onClick={onClick}>+a, then await, then +b(一次重跑)</button>
    </div>
  );
}

表單「本地複本」+ 提交時一次寫回(通常不需要 startTransition

import { useEffect } from "react";
import { signal } from "../core/signal.js";
import { transaction } from "../core/scheduler.js";
import { useSignalValue, useSignalState } from "./react-adapter";

const titleSig = signal("Hello");

export function Editor() {
  const committed = useSignalValue(titleSig);
  const [draft, setDraft] = useSignalState(committed); // 本地 signal 草稿

  // 外部值變更時,同步草稿(可選)
  useEffect(() => setDraft(committed), [committed]);

  const save = async () => {
    await transaction(() => {
      titleSig.set(draft); // 提交時一次寫回全域 signal
      // 若此處還有大量 React setState,才考慮用 startTransition 包「那些 setState」
    });
  };

  return (
    <>
      <input value={draft} onChange={(e) => setDraft(e.target.value)} />
      <button onClick={save}>Save</button>
      <p>committed: {committed}</p>
    </>
  );
}

提醒:startTransition 不會改變 signal.set() 的優先級;它只影響 React 自己的 setState。多步資料提交合併 → 用 transaction(async);UI 過渡 → 用 useDeferredValue 或複本 state/signal。

Vue 中怎麼用

跨 await 的多步更新(SFC)

<script setup lang="ts">
import { signal } from "../core/signal.js";
import { transaction } from "../core/scheduler.js";
import { useSignalRef } from "./vue-adapter";

const a = signal(0);
const b = signal(0);

const va = useSignalRef(a); // Vue ref
const vb = useSignalRef(b);

async function run() {
  await transaction(async () => {
    a.set(va.value + 1);
    await Promise.resolve(); // 模擬 await
    b.set(vb.value + 1);
  }); // ← 一次 flush,一次重跑
}
</script>

<template>
  <p>a={{ va }} / b={{ vb }}</p>
  <button @click="run">+a, await, +b(一次重跑)</button>
</template>

本地複本 + 提交時一次寫回

<script setup lang="ts">
import { ref, watch } from "vue";
import { signal } from "../core/signal.js";
import { transaction } from "../core/scheduler.js";
import { useSignalRef } from "./vue-adapter";

const titleSig = signal("Hello");
const committed = useSignalRef(titleSig); // 讀外部值
const draft = ref(committed.value); // 本地草稿(Vue 自己的狀態)

watch(committed, v => (draft.value = v)); // 可選:外部變時同步複本

async function save() {
  await transaction(() => {
    titleSig.set(draft.value); // 提交時一次寫回
  });
}
</script>

<template>
  <input v-model="draft" />
  <button @click="save">Save</button>
  <p>committed: {{ committed }}</p>
</template>

提醒:Vue 的 <Transition>/動畫只影響顯示時機,不會延後資料寫入。資料提交合併 → transaction(async);若要讓重 UI 區域慢一點更新,可以在 UI 層做延遲或分區顯示。

概念統整

  • 資料層:把「跨 await 的多步寫入」包進 transaction(async) → 一次 effect 重跑。
  • UI 層:過渡/動畫調整顯示,不要指望它改變 signal.set() 的時機。
  • 複本模式:編輯期用元件本地 state/signal;提交時再進交易寫回全域。

搞懂跨 await 合併的時間線

https://ithelp.ithome.com.tw/upload/images/20250825/20129020La9NYMr4LZ.png

結語

本篇把「跨 await 的多步更新」收斂為一次副作用重跑:

  • transaction(async) 與既有 batch 共用深度計數,只在最外層退出時 flushJobs()
  • computed 依舊 lazy;只標髒標記、不提前重算。
  • 巢狀與例外場景都能安全出站,維持我們目前的 API 與心智模型。

下一篇,我們把「合併」提升為「原子性」:失敗就回到進入交易前的狀態,簡單來說:這一篇解決「一次跑」問題,下一篇解決「要嘛全成,要嘛不動」。


上一篇
Vue 應用 (II):交互操作與進階議題
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言