iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0

快速導覽

上一篇,我們介紹了:

  • 為什麼會 撕裂(tearing),如何保證 tear-free 訂閱
  • keys 重掛 下如何避免殘留訂閱 / 計算節點
  • 在 Transition 與 Suspense 場景下的「一致性」與「時機協調」實務

這一篇我們透過一些範例來理解錯誤與解決辦法。

常見陷阱範例

Stale closure:事件/非同步回呼裡讀到舊值

症狀:setTimeout / debounce / Promise.then 內用到的值總是舊的。
原因:事件處理/非同步回呼捕獲的是渲染當下的快照。
修正:回呼當下再次取快照(peek()),或以函式取值避免閉包持久化。

// ❌ 之後的 onClick / timeout 一直用「那次 render 的 v」
const v = useSignalValue(countSig);
const onClick = () => setTimeout(() => console.log(v), 500);

// ✅ 回呼當下再取最新值(不追蹤)
const onClick2 = () => setTimeout(() => console.log(countSig.peek()), 500);

// ✅ 或注入取值函式
const getCount = () => countSig.peek();
const onClick3 = () => setTimeout(() => console.log(getCount()), 500);

在元件 render 內建立 signal / computed 實例

症狀:每次 render 都重建實例,導致訂閱洩漏或效能抖動。
原因:元件函式每 render 都執行;沒有用 useMemo/自建 hook 穩定化。
修正:用 useSignalState/useComputeduseMemo 只建一次。

// ❌ 每次 render 都 new,一定洩漏
function Bad() {
  const local = signal(0);
  const sum = computed(() => local.get() + 1);
  // ...
}

// ✅ 穩定實例
function Good() {
  const [local, setLocal] = useSignalState(0);
  const sum = useComputed(() => local + 1);
}

computed 依賴 React 快照(不會更新)

症狀:useComputed(() => count * 2) 僅第一次生效,之後不變。
原因:computed 只會追蹤 在 callback 內呼叫 .get() 的 signal;React 的數值快照不在 reactive graph。
修正:在 computed 內讀 signal.get();若只是渲染期快取,用 useMemo

// ❌ 不追蹤
const count = useSignalValue(countSig);
const doubled1 = useComputed(() => count * 2);

// ✅ 追蹤 signal
const doubled2 = useComputed(() => countSig.get() * 2);

// ✅ 只要渲染用快取
const doubled3 = React.useMemo(() => count * 2, [count]);

手搓訂閱(useEffect + setState)造成撕裂或重繪過多

症狀:Concurrent 模式下 UI 與資料不同步、或 render 次數異常多。
原因:自己在 useEffectcreateEffect 然後 setState,React 無法在 commit 前重取快照。
修正:一律使用 useSignalValue / useSignalSelector(內部走 useSyncExternalStore)。

// ❌ 容易撕裂
useEffect(() => {
  const stop = createEffect(() => setV(src.peek()));
  return () => stop();
}, []);

// ✅ tear-free
const v = useSignalValue(src);

清理階段誤用 .get() 重新建立依賴

症狀:清理(cleanup)期間不小心又建立了新的依賴邊,造成異味或洩漏。
原因:在 onCleanup / React cleanup 內呼叫 .get()
修正:用先前的快照或 peek();避免在清理期追蹤。

createEffect(() => {
  const last = someComputed.peek(); // 先取快照
  onCleanup(() => {
    // ❌ avoid: someComputed.get();
    last; // ✅ 用快照
    someComputed.peek(); // ✅ 或:peek 不追蹤
  });
});

混用來源導致 UI 不一致(部分讀 signal、部分讀 React state)

症狀:同一區塊,一些元素先變、一 些晚變。
原因:有的讀 signals(即刻反映),有的走 React setState + Transition(延後)。
修正:統一來源。

// ✅ 統一讀外部,顯示端用 deferred
const q = useSignalValue(querySig);
const dq = useDeferredValue(q);

// ✅ 先用 React state 緩衝,再 commit 到 signal
const [draft, setDraft] = useState(useSignalValue(titleSig));
startTransition(() => titleSig.set(draft));

重點概念整理

  • 讀值:用 useSignalValue / useSignalSelector
  • 建依賴:computed callback 內一定要是 signal.get()
  • 實例生命週期:在元件內用 useSignalState / useComputed 穩定化。
  • 清理:cleanup 用快照/peek(),避免 .get()
  • 時機:UI/DOM 交給 React effect (useEffect);資料副作用交給 our effect(createEffect)。

結語

有了以上的範例,能更加讓大家掌握住使用 Signal 的特點,主要是幫忙避坑啦!

到這裡,我們已完成一套可用的核心與 React 實戰:

  • Core:signal / effect / computed(push 標髒標記 + pull 重算),最小 scheduler(microtask 合併)。
  • React 應用:useSyncExternalStore 橋接的 useSignalValue / useComputed / useSignalSelector,以及在 Concurrent/StrictMode 下的副作用分工與清理時機。

接下來,換個角度讓我們把同一套心智帶進 Vue。好消息是:本系列的 computed 與 Vue 的 computed 幾乎等價,實作上會非常順。

下一篇預告

我們會做兩個極簡 hook,讓模板直接使用你的狀態與衍生值:

// vue-adapter
export function useSignalRef<T>(src: { get(): T; peek(): T }) { /* 映射到 Vue ref,onUnmounted 清理 */ }
export function useComputedRef<T>(fn: () => T, equals = Object.is) { /* 生命週期內建立 computed,並轉成 ref */ }
  • useComputedRef 的 callback 要讀 signal.get(),不是讀 ref.value;否則只會變成純 Vue 計算,失去你的 reactive graph。
  • 橋接只把「值」同步到 Vue ref,不把 Vue 的 reactivity 反向接回你的圖,避免雙重依賴造成排程繞圈。
  • 清理用 onUnmounted:卸載即釋放 createEffect/computed

上一篇
React 應用(V):高頻陷阱與最佳實務(I)
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言