iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0

前端 Reactivity 三路線,究竟差在哪?

我們常在 Signal / Proxy / Virtual DOM 之間搖擺:哪個更快?哪個更好維護?

如果把口號 UI = f(state) 當作主線,答案其實會自己冒出來。

這篇先把這句話的來龍去脈講成一個小故事,接著拆解三種模型的差異,再附最小對照碼與選型清單,讓你在專案裡有所本,也更容易說服團隊。

「UI = f(state)」小故事

https://ithelp.ithome.com.tw/upload/images/20250905/20129020201JcSyAeu.png

1997 年的 ICFP 研討會上,Conal Elliott 發表〈Functional Reactive Animation〉,主張畫面其實是「時間 → 圖形」的純函數。當下只在學界泛起漣漪,卻種下「介面可以用數學映射描述」的種子。

十多年後,Evan Czaplicki 把這顆種子帶進瀏覽器,推出 Elm 0.1:view : Model → Html msg。他把狀態稱為 Model,把 UI 寫成 view 函數,讓前端工程師第一次看到「UI 其實等於 f(state)」的可運行範例。

到了 2013 年 JSConf US,Jordan Walke 用更貼近 JavaScript 的方式包裝成 React,並把口訣寫在投影片上:UI = f(state)──只要把最新 state 丟進 render,框架就負責把 DOM 修成正確的樣子。
這句話離開論文與 Haskell,成為日常工程溝通的共同語言。

也因此,後續才分化出三條優化之路:f 拆得更細 的 Signal、用 Proxy 免設定的 Vue reactivity,以及依賴 DiffVirtual DOM

UI = f(state) 不是口號,而是從 1990 年代函數式研究,到 2010 年代資料驅動 UI 思潮,最後在主流 JavaScript 生態落地的縮影。

理解這條演進,你就能看懂今日三種路徑為何各走各的實作,卻都在努力把同一個 f 變得更好寫、更高效。

各自的核心思想?(比出本質差異)

接下來,每種模型都用同一組問題對齊比較:

  1. 依賴如何被追蹤?(讀時/寫時、顆粒度落在哪)
  2. 更新如何被觸發與調度?(push/pull、批次/切片)
  3. DOM 如何被最小化更新?(直達節點 / 屬性級 / 樹差分)

Signal:把「該不該重算」綁在值上

  • 1. 依賴追蹤
    讀取 signal 時收集依賴、寫入時只通知「真的用到它」的計算;顆粒度可精確到單一值/表達式。
  • 2. 觸發與調度
    以 push 為主;常見有批次 batch()、微任務 queue,衍生值(memo)可 lazy 計算形成 Hybrid push/pull。
  • 3. DOM 更新
    由計算直達對應的 DOM 節點或屬性,幾乎無需整棵樹 Diff;高頻、局部更新時特別省。
  • 適合場景
    滑桿、地圖標記、Canvas/圖表的細碎更新;大型清單中只改某個 cell。

Proxy Reactive:用語言特性自動化依賴

  • 1. 依賴追蹤
    Proxy(get/set) 攔截任意屬性讀寫,屬性級依賴自動收集;深層物件需沿著 getter 鏈註冊依賴。
  • 2. 觸發與調度
    push 模式,寫入屬性時觸發相依計算;框架通常以微任務隊列合併更新(如 Vue 的 job queue)。
  • 3. DOM 更新
    編譯/運行期會依追蹤結果決定 patch;多數情況能準確命中,但深層結構在讀時存在額外成本。
  • 適合場景
    需保留「直接改物件」的手感;表單與巢狀資料結構。

Virtual DOM:用「比對」換取「無侵入」

  • 1. 依賴追蹤
    幾乎不追蹤屬性;將 render 結果視作 f(state) 的快照,再與上一版 Diff。
  • 2. 觸發與調度
    setState 使 component 重新 render;React 以 Fiber Scheduler 做優先級與時間切片,提升互動流暢度。
  • 3. DOM 更新
    透過 Diff 產生 patch;小變動也要重跑對應 component,再決定實際只改一個 text node。
  • 適合場景
    團隊深耕 React 生態、RSC/SSR 需求重;更新頻率不極端、DX 與配套最重要。

看看不同框架的解決方案

Solid

// Signal
const [count, setCount] = createSignal(0)
createEffect(() => {
  console.log('count changed ->', count())
})
setCount(1) // 只觸發用到 count 的 effect/綁定

Vue

// Proxy Reactive
const state = reactive({ count: 0 })
watchEffect(() => {
  console.log('count changed ->', state.count)
})
state.count++ // 觸發一次,深層物件會沿 getter 鏈註冊依賴

React

// Virtual DOM
function Counter() {
  const [count, setCount] = useState(0)
  console.log('Counter render') // 每次 set 都重新執行,再交給 Diff
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
  • 在 Solid / Vue 的解法,只有「用到那個欄位」的計算會重新執行;DOM patch 直接針對該 text node。
  • 在 React 中,Counter 整個函式重跑一次,再進入 Diff 流程,最後才決定只改文字。

你會發現差別不在 JSX 長相,而在狀態改變後「誰」決定該不該重算:

  • Signal/Proxy 由「資料」決定(push)
  • VDOM 由「比對」決定(pull)

何時該選哪一種?

需求情境 建議技術 原因
互動高度即時(遊戲座標、Canvas 畫筆) Signal micro-update 超低 diff 成本
巨型表單/深層 JSON 編輯器 Proxy Reactive 不必大量手動宣告 signal;語法貼近原生物件
團隊已深耕 React、生態依賴重 Virtual DOM DX、工具鏈、人才供應,改動成本最低
混合應用:大部分 React,局部需極速 React + Signals as 外掛 只在熱點用 signal,其他維持 VDOM

判斷依據

https://ithelp.ithome.com.tw/upload/images/20250905/20129020OZ5nsPJufK.png

結語

  • Signal:把「哪裡要更新」包在值本身,寫入即命中。
  • Proxy Reactive:用 ES6 Proxy 自動把「任意屬性」變成可追蹤值。
  • Virtual DOM:不問過程,只在最後比較「之前長怎樣」vs「現在長怎樣」。

理解了三者在「依賴 → 調度 → DOM」的成本分佈,你就能在不同專案裡找到最貼切的 reactivity 策略。

下一篇,讓我們來聚焦於這系列的主軸 Signal 的運作原理,來探討如何實踐這套體系的運作。

參考資料

FRP 原典 – Functional Reactive Animation, ICFP 1997
Elm Guide – “The Elm Architecture” 章(view : Model -> Html)
JSConf US 2013 React Talk(Jordan Walke)
UI is a function of state(2015 blog)
The Two Reacts(Dan Abramov,2024)


上一篇
Reactivity 兩大驅動模式: Pull-based vs. Push-based
系列文
Reactivity 小技巧大變革:掌握 Signals 就這麼簡單!5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言