iT邦幫忙

2025 iThome 鐵人賽

DAY 17
0
Modern Web

Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!系列 第 17

React 應用(III):生命週期沒消失—副作用分工與清理時機

  • 分享至 

  • xImage
  •  

從未消失的生命週期

從本系列的開頭講解,一直到現在的實作,都是圍繞著「資料層的生命週期」:資料如何被讀取、失效、重算、與何時觸發副作用。

這並不衝突於框架本身的生命週期,事實上,React 從來沒有拿掉生命週期,它是把生命週期重構為兩個階段:

  • Render:純計算 UI 的階段,可被多次執行、打斷、丟棄;這裡理想上不做副作用。
  • Commit:一次性把變更提交到 DOM 的階段,同步執行 useLayoutEffect/useEffect 的 setup/cleanup,這裡才是副作用的合法落點。

如果對 React 的 useLayoutEffect / useEffect 還不熟的朋友可以參考我之前鐵人賽的文章講解,至少要知道 React 怎麼透過這些 hook 來處理 UI 節點 mount / unmount 時機的。

React 強調的是 「一般寫法下,UI 對 state 的依賴不會被顯式標註」。因此它在 state 更新時,預設重跑該 component 的 render ,再透過 VDOM 的比對機制找到最小 DOM 變更;是否向下波及子樹,則交由 bailout 與記憶化策略 決定。

Signals 走的是「顯式依賴圖」:系統知道「誰依賴誰」,所以能精準推送更新,並以排程器(microtask 合併等)控制副作用觸發的時機。

兩條路線看似不同,其實都在嚴格管理生命週期邊界:

  • React 用 Render/Commit 邊界保護副作用時機。
  • Signals 用節點失效/重算/訂閱的邊界管理資料生命週期。

生命週期不曾消失,只是抽象層次不同。

本篇目標

  • UI 相關副作用(DOM 量測、操作、動畫)→ React(useLayoutEffect / useEffect)。
  • 資料流副作用(根據 signal/computed 的值觸發業務邏輯)→ signals(createEffect,由 adapter 管理生命週期)。
  • render 必須純:不要在 render 階段 signal.set() 或建立任何外部 effect。

時序總覽:誰先跑、誰先清

https://ithelp.ithome.com.tw/upload/images/20250820/20129020fe07ELNsb9.png

  • React 的 Effect 清理useEffect/useLayoutEffect 的 cleanup)在下一次 commit 之前發生。
  • 我們的 effect 清理onCleanup)在 同一個 microtask 的重跑前發生,兩者互不衝突。

副作用分工

事情 放哪裡 說明與範例
讀/寫 DOM、量測、動畫 useLayoutEffect / useEffect commit 之後執行,時機可預期
根據值觸發業務邏輯(請求、記錄、跨層事件) createEffect(透過前一篇實作的 adapter 訂閱) 我們的 scheduler 會在 microtask 合併重跑
多次同步 set 合併 交給 scheduler(microtask 去重) 同一輪只重跑一次 our effects
讀取當前快照 useSignalValue / useComputed useSyncExternalStore + peek(),Concurrent 下不撕裂

正確使用姿勢

Render 階段寫入 vs 事件/Effect 寫入

錯誤範例(render 期副作用)

function Bad() {
  const v = useSignalValue(mySig);
  if (v < 0) mySig.set(0); // render 階段寫入,會造成無窮重渲染/StrictMode 問題
  return null;
}

正確使用(放在事件或 React effect)

function Good() {
  const v = useSignalValue(mySig);
  React.useEffect(() => {
    if (v < 0) mySig.set(0);
  }, [v]); // ✅ commit 後執行,時機安全
  return null;
}

// 或事件中
<button onClick={() => mySig.set(x => Math.max(0, x))}>Clamp</button>

這裡與 react state 常見問題是一樣的,有點經驗的基本上不會在 FC(Function Component) 渲染期間值接更動 state。

在「我們的 effect」操作 DOM vs React Effect 控制 DOM

錯誤範例(our effect + DOM 改寫時機不受 React 管)

createEffect(() => {
  const h = panelHeight.get();
  panelEl.style.height = h + "px"; // 可能與 React commit 衝突
});

正確使用(把值交給 React,DOM 改寫放 useLayoutEffect)

function Panel({ el }: { el: HTMLElement }) {
  const h = useSignalValue(panelHeightSignal);
  React.useLayoutEffect(() => {
    el.style.height = h + "px"; // commit 後同步執行,安全
  }, [el, h]);
  return null;
}

DOM 的生命週期歸 React 管(commit 後),我們的 effect 適合做「資料的副作用」。

useEffect 拉訂閱 vs useSyncExternalStore

錯誤範例(useEffect + setState 容易撕裂(tearing)/時機不對)

function BadSubscribe() {
  const [v, setV] = React.useState(mySig.peek());
  React.useEffect(() => {
    const stop = createEffect(() => {
      setV(mySig.peek()); // 會造成 tearing
    });
    return () => stop();
  }, []);
  return <div>{v}</div>;
}

正確使用(透過前面寫的 adapter 提供 hook)

function GoodSubscribe() {
  const v = useSignalValue(mySig); // useSyncExternalStore + peek,commit 前會重取快照
  return <div>{v}</div>;
}

// 如果要支援比較早期的版本,可以考慮使用第三方的 library

透過 useSyncExternalStore 保障 Concurrent 模式下的「快照一致性」,避免 tearing。

computed 依賴 React 快照 vs 依賴 signal

錯誤範例(useComputed 讀的是 React 的數值,無法建立依賴)

const count = useSignalValue(countSig);
const doubled = useComputed(() => count * 2); // 只會算一次,不會更新

正確使用(在 computed 內讀 signal)

const count = useSignalValue(countSig);
const doubled = useComputed(() => countSig.get() * 2); // 建立依賴,會隨 signal 更新

或者使用純 React 衍生值(不需要 reactive node)

const count = useSignalValue(countSig);
const doubled = useMemo(() => count * 2, [count]); // 僅作渲染期快取

computed 必須在其執行函式中讀取 signal 的 .get() 才會被追蹤。

錯誤中學習

從上述錯誤中可以得出以下要點:

  • render 必須純:不要在 render 期 createEffect / signal.set
  • UI 副作用 → useLayoutEffect/useEffect;資料副作用 → 我們的 createEffect
  • 訂閱一律用 useSignalValueuseSyncExternalStore)。
  • computed 要讀 .get(),不要拿 React 快照當依賴。

結語

透過上面的範例,我們可以很清楚的知道 React 的 Effect,是包含 UI 渲染更新的,所以想要讓我們設計的 signal 系統能在 React 環境下使用,就必須要透過既有的 hook 來做整合。

簡單來說,屬於我們本身機制提供的 api,盡量接受 signal 的參數進行使用,如果是透過 hook 封裝的值,會長成 React 的形狀,那麼就會需要使用 React 所提供的 hook 來交互使用,這樣追蹤才會是正常的。

下一篇,我們透過範例來看看要如何互補使用。


上一篇
React 應用(II):不撕裂的訂閱
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言