watch
/ watchEffect
與我們的 createEffect
怎麼分工equals
)與規範化寫入的效能實務watch
/ watchEffect
:誰該看誰?ref
)→ 用 useSignalRef()
先把 signal/computed 變成 Vue ref
,再用 watch
/ watchEffect
。createEffect
;不要把 Vue 的追蹤混進我們的依賴圖。反例:在 watchEffect
直接 .get()
(雙重追蹤)
watchEffect(() => {
console.log(priceSig.get()); // ❌ 讓 Vue 也介入我們的依賴圖
});
正確:先橋接成 Vue ref
,再 watch
值
const price = useSignalRef(priceSig);
watch(price, (nv, ov) => {
console.log("price:", ov, "→", nv);
});
資料層副作用(仍留在我們的 effect)
createEffect(() => {
const id = productIdSig.get();
fetch(`/api/p/${id}`).then(/* ... */); // business effect
});
這邊和前面 React 的生命週期類似的概念,Vue 也有 V-DOM 的機制,處理的現象都是:「列表或路由切換 key 時,舊子樹卸載、新子樹掛載。」;如果你的 computed 建在模組域,沒退訂會留下上游連接邊界。
const subtotal = useComputedRef(() =>
cartSig.get().items.reduce((s, i) => s + i.qty * i.price, 0)
);
// 卸載時 `useComputedRef` 幫你 dispose()
const StoreKey = Symbol() as InjectionKey<{ subtotal: ReturnType<typeof coreComputed> }>;
export function provideStore() {
const subtotal = coreComputed(/* ... */);
provide(StoreKey, { subtotal });
onUnmounted(() => subtotal.dispose?.());
}
資料層用我們的 effect 管 lifecycle,Vue 只顯示三態。
export const userId = signal(1);
export const user = signal<{status:"idle"|"loading"|"ok"|"error"; data?:User; err?:any}>({status:"idle"});
createEffect(() => {
const id = userId.get();
user.set({ status: "loading" });
fetch(`/api/user/${id}`)
.then((res) => res.json())
.then((data) => user.set({ status: "ok", data: data }))
.catch((err) => user.set({ status: "error", err: err }));
});
<script setup lang="ts">
const u = useSignalRef(user);
</script>
<template>
<Spinner v-if="u.status==='loading'" />
<ErrorView v-else-if="u.status==='error'" :err="u.err" />
<Profile v-else :data="u.data" />
</template>
把你的三態包成 read()
,未就緒時 throw Promise,交給 Vue Suspense。
export function toResource<T>(src: { peek(): {status:string; data?:T; err?:any} }) {
let pending: Promise<void> | null = null;
return {
read(): T {
const s = src.peek();
if (s.status === "ok") return s.data!;
if (s.status === "error") throw s.err;
if (!pending) pending = new Promise(() => {}); // 記得要改 pending,不要無腦照抄
throw pending;
}
};
}
<script setup lang="ts">
const resource = toResource(user);
const data = resource.read(); // 未就緒時會 throw,交給 Suspense
</script>
<template>
<Profile :data="data" />
</template>
<!-- 外層 -->
<Suspense>
<UserPanel/>
<template #fallback><Spinner/></template>
</Suspense>
useSignalRef
初始值來自 peek()
,Hydration 前即有穩定值。onMounted()
才建立 createEffect
訂閱(避免在 SSR 期間啟動定時器/請求)。export function useSignalRefSSR<T>(src: Readable<T>): Ref<T> {
const r = shallowRef<T>(src.peek()) as Ref<T>;
let stop: (() => void) | undefined;
onMounted(() => {
stop = createEffect(() => {
r.value = src.get();
});
});
onUnmounted(() => stop?.());
return r;
}
重點:渲染期不做副作用,訂閱改在 mounted 後啟動,避免 SSR 端意外排程。
equals
與規範化寫入useComputedRef(fn, equals)
:把「值相等不刷新」的策略下放到核心 signal 機制,Vue 端只接收變更結果。shallowEqual
/ keyedEqual
等等值策略。// 依排序內容是否真的變動來決定是否推送到 Vue
const sorted = useComputedRef(
() => [...listSig.get()].sort((a,b) => a.id - b.id),
(a, b) => a.length === b.length && a.every((x, i) => x.id === b[i].id)
);
在 signal.set()
前判斷「真的變了才寫」,能源頭減少無效重跑。
userSig.set(prev => (prev.name === next ? prev : { ...prev, name: next }));
useComputedRef
裡讀 ref.value
症狀:它變成純 Vue 計算,不進我們的 reactive graph,失去 lazy/快取 與 依賴追蹤。
修正:callback 讀 signal.get()
;純 Vue 計算請用 Vue computed
。
// ❌
const wrong = useComputedRef(() => vueRef.value * 2);
// ✅
const ok = useComputedRef(() => countSig.get() * 2);
.get()
症狀:只拿到一次快照,不會自動更新。
修正:用 useSignalRef
暴露成 ref
給模板。
<!-- ❌ -->
<p>{{ countSig.get() }}</p>
<!-- ✅ -->
<p>{{ useSignalRef(countSig) }}</p>
watchEffect
讀 .get()
症狀:watchEffect
重跑次數異常、生命週期難以預期,甚至和我們的 effect 互相牽動。
修正:先用 useSignalRef()
把來源轉成 Vue ref
,再 watch
/ watchEffect
;或把這類副作用移回我們的 createEffect
。
// ❌ 直接讀 .get(),把 Vue 的追蹤牽進我們的依賴圖
watchEffect(() => {
console.log("price:", priceSig.get());
});
// ✅ 先轉成 Vue ref,再 watch「值」
const price = useSignalRef(priceSig);
watch(price, (nv, ov) => {
console.log("price:", ov, "→", nv);
});
// ✅ 若是資料層副作用,改用我們的 effect
createEffect(() => {
const id = productIdSig.get();
fetch(`/api/p/${id}`).then(/* ... */);
});
computed
不清理症狀:切頁/切 key 後,舊頁面不在了但某些計算/訂閱還在跑,甚至持續觸發上游更新。
修正:把衍生值綁進元件生命週期用 useComputedRef
,或集中到 Provider 中管理 dispose()
。
// ❌ 模組域 computed 永久存活,易殘留依賴
export const subtotal = computed(() => a.get() + b.get());
// ✅ 元件域,用 useComputedRef;卸載自動 dispose
const subtotal = useComputedRef(() => aSig.get() + bSig.get());
// ✅ 容器化:Provider 管生命週期
const StoreKey = Symbol();
export function provideStore() {
const subtotal = coreComputed(() => aSig.get() + bSig.get());
provide(StoreKey, { subtotal });
onUnmounted(() => subtotal.dispose?.());
}
症狀:以為 <Transition>
能延後 signal.set()
的生效;實際上資料立刻更新,導致依賴者先動、UI 動畫只是滯後顯示。
修正:資料仍由 signal 即時寫入;把「過渡」放在顯示層,或使用複本的方式先顯示、提交時再寫回 signal。
<!-- ❌ 以為 Transition 會延後資料寫入,事實不會 -->
<script setup lang="ts">
const q = useSignalRef(querySig);
function onInput(e: Event) {
querySig.set((e.target as HTMLInputElement).value); // 還是立刻寫入
}
</script>
<template>
<input :value="q" @input="onInput" />
<Transition><Expensive :query="q" /></Transition>
</template>
<!-- ✅ 做 UI 過渡,不改資料:顯示端使用「延遲版本」 -->
<script setup lang="ts">
import { shallowRef, watch } from "vue";
const q = useSignalRef(querySig);
const shown = shallowRef(q.value);
let t: any;
watch(q, (nv) => {
clearTimeout(t);
t = setTimeout(() => (shown.value = nv), 200);
});
</script>
<template>
<input :value="q" @input="e => querySig.set(e.target.value)" />
<Transition><Expensive :query="shown" /></Transition>
</template>
<!-- ✅ 利用複本(提交才寫回 signal) -->
<script setup lang="ts">
import { ref, watch } from "vue";
const committed = useSignalRef(titleSig);
const draft = ref(committed.value);
watch(committed, v => (draft.value = v)); // 外部改動時同步複本
function save() {
titleSig.set(draft.value); // 提交時才觸發全域更新
}
</script>
<template>
<input v-model="draft" />
<button @click="save">Save</button>
</template>
Transition/動畫只影響顯示,不改「資料寫入時機」。
到這裡,我們已經有一個 單向、可預期 的 Adapter:signals 仍掌握依賴追蹤與 lazy 重算,Vue 只管顯示與 UI 生命週期。
我這邊就挑這兩個框架的應用來示範了,為什麼不再示範其他框架,原因很簡單,我在之前的文章也曾說過,前端框架處理 UI 的方式大概能分成以下兩種:
補充說明
- Tagged template / HTML-in-JS:Lit、htm 等(不是 JSX,也不等同傳統模板)。
- 編譯式模板:Svelte、Marko(模板語法,但編譯成命令式 DOM,無 VDOM)。
- DOM-first/增強型:htmx、Alpine、Stimulus(以現成 HTML 為主體,屬性驅動行為)。
- Resumability:Qwik(常以 JSX 撰寫,但執行模型與 React 不同)。
下一篇,我們來探討 Signal 進階一點的內容,回頭重修如何進一步優化我們的核心機制。