前面已經示範了如何將我們設計好的 signal 機制,容入兩個主流框架下(React、Vue),這篇開始我們來回顧我們設計好的 signal 核心機制,探討哪些可以更好的地方。
把「多步、跨 await
的更新」合併成一次 effect 重跑,同時不影響我們既有的 lazy computed 與 microtask 排程設計。
本篇在不改你對外 API 的前提下,只擴充 scheduler.ts
與極少數接點。
set()
會被我們的 scheduler
(Set
+ queueMicrotask
)合併到同一個 microtask,effect 只重跑一次。await
不行:每個 await
都是新的微任務;沒有 Transaction,effect 會各跑一次。// ❌ 沒有 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。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)。set()
只把它標記為 stale
,不因為 transaction 而提前重算。set()
都不會排 microtask,只有在最外層 transaction
結束(或 throw)後,一次 flushJobs()
。batchDepth
支援巢狀交易,只有最外層退出時才 flush。fn
throw 也會正確出棧並 flush(見上方 catch
/finally
邏輯)。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。
<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 重跑。signal.set()
的時機。await
合併的時間線本篇把「跨 await
的多步更新」收斂為一次副作用重跑:
transaction(async)
與既有 batch
共用深度計數,只在最外層退出時 flushJobs()
。computed
依舊 lazy;只標髒標記、不提前重算。下一篇,我們把「合併」提升為「原子性」:失敗就回到進入交易前的狀態,簡單來說:這一篇解決「一次跑」問題,下一篇解決「要嘛全成,要嘛不動」。