iT邦幫忙

2025 iThome 鐵人賽

DAY 2
1

為何需要 Signal?

有了第一篇的概念之後,相信大家都已經在心中埋下懷疑的種子,那我們來重新審視一下 React 是怎麼處理 state 的吧!
https://ithelp.ithome.com.tw/upload/images/20250812/20129020vIIaX2bGN7.png

相信大家對這套流程並不陌生吧! 可以說是面試必考問題,詳細的可以參考官方文檔
那我們接著看看 Fine-grained Reactivity 的概念下是怎麼處裡的:
https://ithelp.ithome.com.tw/upload/images/20250806/20129020dD0Lsnk1Ee.png

下面用個表格來比較一下差異會比較直觀一點:

面向 React useState & VDOM Fine-Grained Reactivity (Signal/Atom)
更新單位 Component(整棵子樹) State cell(單一值)
依賴追蹤方式 每次 render 時重新執行函式 → 比對 VDOM 讀取時即註冊依賴,寫入時精準通知
排程模型 異步批次 (setState → 任務列) + Diff + Commit 異步批次 (batch) + 拓撲排序 + 直接執行副作用
閒置成本 即使視覺不變仍需重跑函式、生成 VDOM 若結果相同會短路,不進入下游
心智模型 「宣告 UI = f(state)」 「宣告 state = f(source);UI 只是其中一種 Effect」
典型最佳化手段 memo, useCallback, useMemo 內建;僅在需要跨邏輯分層時才顯式 memo
DevTools 生態 成熟、豐富 正在起步(Solid, Vue3, MobX DevTools…)

Signal 讓「資料 ≈ 最小可觀測單元」,將重渲染邊界從 Component 級縮到 State 級,系統複雜度隨應用規模成 線性 增長,而非 指數 增長。

為什麼用 useState 會「多做功」?

  1. 函數重跑開銷
    React 透過「重新執行元件函數」(re-call function)取得最新 VDOM → 再與前一次 diff。對深層子樹 而言,大多數 render 只是把同樣的值再計算一次;大專案很容易在效能剖析器上看到 “重新渲染但 UI 不變” 的黃線。
  2. 元件邊界 ≠ 資料邊界
    • 在 UI 層為了可組合、可讀性,常把 layout、樣式與多個 state 放在同一組件。
    • 任何 state 改動都會觸發整個函數重跑,除非手動抽離、加 memo。

Signal 的核心術語與心智圖

術語 對應 React 類比 功能
Source / Signal useState 的 state 變數 最原始、可寫入的資料節點
Computed / Derivation useMemo 由 Signal 衍生、具快取的純函數
Effect / Reaction useEffect 為了 副作用,在追蹤到依賴變化後觸發
Batch / Transaction unstable_batchedUpdates 將多次寫入壓縮為一次更新傳播
Graph / Dependency Map React Fiber 追蹤資料依賴關係的有向圖

心智圖

https://ithelp.ithome.com.tw/upload/images/20250807/201290200xRWqSShC7.png

Computed C 依賴 Source ASource B;當任一 Source 改變,只重算 C,隨後觸發 Effect D。沒有任何涉入的元件被渲染,除非 D 裡真的去動到 DOM。

Fine-grained Reactivity三步驟:讀-寫-傳

以下以 Solid.js / Vue3 Composition API 為代表;MobX、Jotai、Signals.js 亦同理。

階段 示意程式碼 內部動作
讀 (Tracking) console.log(count()) Runtime 記錄「目前執行的 Computed/Effect 依賴 count
寫 (Mark Dirty) count.set(v => v + 1) 只做兩件事: 1. 更新值; 2. 把受影響節點標為 dirty,放進 queue
傳 (Propagate) (Scheduler flush) 拓撲排序:先重算 Computed → 若值變,才往下游 Effect;否則終止

結果:若 count +1 後,某個 Computed 回傳值仍相同(e.g. Math.floor),整條支鏈都不會觸發,成本≈受影響節點數 × O(1)。

範例對照組: Counter

React

function Counter() {
  const [count, set] = useState(0);
  return <button onClick={() => set(c => c + 1)}><snap>{count}</snap></button>;
}
  • 點擊 → Counter 重新執行 → 產生新 VDOM → diff <button> → commit;
    若把 Counter 包在父層巨型表格內,整行 React call stack 依序出現。

Fine-grained Reactivity (Solid js)

const [count, setCount] = createSignal(0);
const Counter = () => (
  <button onClick={() => setCount(c => c + 1)}>
    <snap>{count()}</snap> {/* 文字節點直接綁定 signal */}
  </button>
);
  • 點擊只有觸發「文字節點內容 = 1 → 2」;沒有函數重跑、沒有 diff。

Fine-grained Reactivity 的優勢

1. 更新成本與 UI 大小解耦

只重算受影響的 Computed / Effect;大型列表、White-board、Spreadsheets 成本與資料單元數線性相關。

2. 預測式資料流

依賴圖是顯式可走訪的有向圖 → 可做變動追蹤、諸如 why-did-you-update 之類的分析,而不用把 Fiber 打平成 JSON 再逆向追。

3. 副作用分離

effect(() => doSomething(domRef(), data())) 只會在依賴值真正改變時觸發,避免 React useEffect 常見的「deps array 漏 / 過度依賴」陷阱。

4. 單元測試更輕量

Computed 是純函式;可在 Node 中跑邏輯測試,不必拉 DOM/mock React Test Renderer

5. 漸進採用 / 易與框架整合

  • React:用 useSyncExternalStore 包起來。
  • Vue3:本來就內建。
  • Svelte/MobX:理念一致,可相互 bridge。

常見疑惑 & 設計取捨

問題 回應
和 MobX/Proxy-based 寫法差在哪? Signal 強制使用 getter/setter 暴露變數 (signal.value);可預測、不依賴 Proxy 差異化行為。
支援 Async 嗎? 核心只處理同步值 → 高階 API (createResource/createAsync) 以狀態機包裹 Promise;更新仍走相同依賴圖。
需要 Context 嗎? 跨階層共享狀態可直接傳遞 Signal;理論上不需 Context Provider,除非要借用 React DevTools 等生態系統。
效能是否一定更好? 高互動 & 大量細粒度更新 場景明顯領先;但對 靜態內容或批量一次性渲染,React diff 成本可忽略。選型仍視需求而定。

結語

Fine-grained reactivity 的本質優勢,在於把「重新計算」縮小到資料最小單元,讓效能與複雜度不再被元件樹綑綁。

Signal 透過顯式資料單元、隱式依賴追蹤、最小傳播結構,把 UI 更新問題轉譯成「圖論 + 排程」,這就是從程式設計角度切入前端效能的現代解法。

下一篇,我們來深入理解 Fine-grained reactivity 的思想與發展過程。


上一篇
什麼是 Signal ?
下一篇
Reactivity 的概念與演進
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言