iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0

快速導覽

  • watch / watchEffect 與我們的 createEffect 怎麼分工
  • 元件重掛(keys)下避免殘留訂閱/計算節點
  • 非同步資料的兩種落地做法 & 如何(可選)接到 Vue Suspense
  • SSR/Hydration 的快照與訂閱時機
  • 等值比較(equals)與規範化寫入的效能實務
  • Vue 常見 陷阱 → 修正 對照

watch / watchEffect:誰該看誰?

使用原則

  • Vue 端只看「值」(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
});

keys 重掛(remount):生命週期

這邊和前面 React 的生命週期類似的概念,Vue 也有 V-DOM 的機制,處理的現象都是:「列表或路由切換 key 時,舊子樹卸載、新子樹掛載。」;如果你的 computed 建在模組域,沒退訂會留下上游連接邊界。

解法 A 元件域:useComputedRef(卸載即清)

const subtotal = useComputedRef(() =>
  cartSig.get().items.reduce((s, i) => s + i.qty * i.price, 0)
);
// 卸載時 `useComputedRef` 幫你 dispose()

解法 B 容器化:利用 Provider 管生命週期

const StoreKey = Symbol() as InjectionKey<{ subtotal: ReturnType<typeof coreComputed> }>;

export function provideStore() {
  const subtotal = coreComputed(/* ... */);
  provide(StoreKey, { subtotal });
  onUnmounted(() => subtotal.dispose?.());
}

非同步資料

方法 A:狀態驅動

資料層用我們的 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>

方法 B:Suspense Resource

把你的三態包成 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>

  • 專案沒大量用 Suspense → 方法 A 就夠。
  • 已有 Suspense 基礎設施 → 方法 B 可融入既有模式(務必使用真 pending promise)。

SSR / Hydration:快照與訂閱時機

  • 快照useSignalRef 初始值來自 peek(),Hydration 前即有穩定值。
  • 訂閱時機:若想更保守,可在 onMounted() 才建立 createEffect 訂閱(避免在 SSR 期間啟動定時器/請求)。

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

在模板或 setup 直接 .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?.());
}

期待「Vue Transition/動畫」會延後資料寫入

症狀:以為 <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 的方式大概能分成以下兩種:

  • Template 型(在 HTML 模板上綁定語法/指令,例如 Vue/Angular/Svelte 模板)
  • JSX 型(用 JavaScript 表達 UI,例如 React/Preact/Solid)
    我這邊已經提供了相對應的解決方案,其他框架也和這兩個範例相似,我就不再繼續著墨了。

補充說明

  • Tagged template / HTML-in-JS:Lit、htm 等(不是 JSX,也不等同傳統模板)。
  • 編譯式模板:Svelte、Marko(模板語法,但編譯成命令式 DOM,無 VDOM)。
  • DOM-first/增強型:htmx、Alpine、Stimulus(以現成 HTML 為主體,屬性驅動行為)。
  • Resumability:Qwik(常以 JSX 撰寫,但執行模型與 React 不同)。

下一篇,我們來探討 Signal 進階一點的內容,回頭重修如何進一步優化我們的核心機制。


上一篇
Vue 應用 (I):最小橋接器與用法
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!22
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言