iT邦幫忙

2026 iThome 鐵人賽

0
JavaScript

生活中的資料結構與演算法:30 天學會把現實問題變成可推理的模型系列 第 31 篇

Day 30|從生活問題回到軟體:為什麼 Reactive System 也是一張 Graph?

  • 分享至 

  • xImage
  •  

這 30 天,我們談過很多看起來完全不同的問題。
排隊時,我們用了 Queue。
復原操作時,我們看到了 Stack。
找資料時,我們談 Hash Map。
捷運路線讓我們認識 Graph。
導航帶出了最短路徑。
工作安排讓我們碰到排程(Scheduling)。
有限容量帶出了 Knapsack 與 Bin Packing。
而當所有條件開始互相牽制時,我們又談到限制條件、最佳化,以及為什麼工程師有時候必須接受一個「夠好的答案」。

表面上看起來,這些問題彼此差異很大。
但如果回頭看整個系列,我們其實一直在做同一件事情:

找到問題真正的結構

今天是最後一天,我們不再找新的生活例子,而是回到很多前端工程師每天都在接觸的一個東西:

狀態(State)


一個看起來很普通的購物車

假設我們有一個購物車,裡面有幾個基本資料:

  • price
  • quantity
  • discount
  • taxRate

然後系統會根據這些資料計算:

  • subtotal
  • total

例如:

subtotal = price × quantity

total = (subtotal - discount) × (1 + taxRate)

如果只看程式碼,我們可能會把它理解成幾個變數與幾個計算。

但如果把「誰依賴誰」畫出來,事情會開始變得不一樣:
https://ithelp.ithome.com.tw/upload/images/20260914/20129020MmQnIATqKB.png

現在先不要想 React、Vue、Angular,也不要想任何框架。
只看這張圖,它比較像什麼?
是元件樹(Component Tree)嗎?
還是:

一張依賴圖(Dependency Graph)?


狀態之間其實存在「關係」

在系列前半段,我們第一次談 Graph 時曾經說過:

一張 Graph 最基本的元素是:

  • 節點(node)
  • 邊(edge)

節點代表某個東西。
邊代表兩個東西之間的關係。
例如捷運 台北車站 ─── 西門 站點是節點。
兩個站之間可以通行,是邊。

到了有向圖(Directed Graph),我們又多了一件事:

關係可能具有方向

例如 A → B 代表的不是單純「A 跟 B 有關」,還傳達了 B 依賴 A 的訊息。

現在重新看剛才的購物車 price → subtotal 意思就是:

subtotal 的值依賴 price

而 subtotal → total 代表:

total 又依賴 subtotal

這就是我們從 Day 13 開始建立的:

依賴關係(Dependency)


price 改變後,為什麼 total 也要重新計算?

假設原本:

price = 100
quantity = 2
discount = 20
taxRate = 0.05

那麼:

subtotal = 200
total = 189

現在 price 從 100 變成 120,實際發生的流程是:

price
  ↓
subtotal
  ↓
total

這正是 Day 17 談過的:

傳播(Propagation)

一個節點發生變化之後,影響可能會沿著依賴關係往下游傳遞。

A → B → C 如果 A 改變,B 可能受到影響。
而 B 一旦改變,C 也可能受到影響。

所以 Reactive System 很重要的一個問題其實就是:

當某個節點改變時,哪些下游節點需要被重新處理?

此時比「怎麼重新渲染元件」更前面的一層是:

哪些資料已經不再可信?


這其實就是失效標記

假設現在:

price → subtotal → total

price 改變之後,我們甚至不一定需要立刻重新計算所有東西,我們可以先知道 subtotal,已經不能保證是最新的。
既然 subtotal 可能過期,那麼 total 也可能已經過期。
所以系統可以先做的事情是:

price 改變

subtotal → 已過期

total → 已過期

這就是:

失效標記(Invalidation)

它和「重新計算」其實是兩件不同的事情。

失效標記回答的是:

哪些值現在已經不能相信?

重新計算回答的才是:

那我什麼時候要重新把它算出來?

這也是為什麼一個 Reactive System 並不只是 值改變 → 執行回呼函式。
困難的地方在於:

系統必須知道依賴關係

否則它根本不知道 price 改變之後,到底有哪些東西受到影響。


依賴關係從哪裡來?

假設我們寫:

const subtotal = computed(() => {
  return price() * quantity()
})

const total = computed(() => {
  return (subtotal() - discount()) * (1 + taxRate())
})

從程式表面上看,我們只是執行了一個函式。

但 Reactive System 真正想知道的其實是:

subtotal 讀取了誰?

當 subtotal 執行期間讀到了:

price
quantity

系統就可以建立:

price ───────→ subtotal
quantity ────→ subtotal

而 total 讀取了 subtotal、discount 與 taxRate,因此系統還可以建立:

subtotal ────→ total
discount ────→ total
taxRate ─────→ total

於是依賴圖就逐漸形成了。
這和我們 Day 8 談 Graph 的表示方式時,其實是同一件事情。
紙上可以直接畫 A → B 但電腦裡不能真的存一條「線」。

系統必須想辦法記錄 A 的下游有誰? 或者 B 依賴哪些上游?

例如概念上可能像:

price:
  下游訂閱者:
    - subtotal

以及:

subtotal:
  上游依賴:
    - price
    - quantity

到了這裡,Reactive System 看起來是不是已經越來越不像單純的「狀態管理」?
因為我們真正維護的,其實是:

狀態之間的關係


元件樹和依賴圖是不同的東西

前端工程師非常習慣把畫面想成一棵元件樹:

App
├─ Header
├─ ProductPage
│  ├─ ProductInfo
│  └─ Cart
└─ Footer

這描述的是:

UI 的結構

但資料之間的依賴關係不一定遵守這棵樹。

例如 user 可能同時影響:

Header
Checkout
Profile
Analytics

甚至某個衍生值 shippingFee 可能同時依賴:

cartTotal
userLocation
membershipLevel
promotion

這些關係很難自然畫成一棵樹,反而更接近:

cartTotal ────────┐
userLocation ─────┤
membershipLevel ──┼→ shippingFee
promotion ────────┘

這就是一張 Graph,所以元件樹描述的是:

UI 怎麼組成

而依賴圖描述的是:

資料與計算之間誰依賴誰

這兩件事情可以相關,但它們不是同一件事情。


循環問題又回來了

既然 Reactive System 是一張 Graph,那我們前面談過的 Graph 問題自然也可能重新出現。

例如:

A → B
B → A

這是什麼?

正是 Day 14 談過的:

循環(Cycle)

假設:

A = B + 1
B = A + 1

那系統要先算誰?

如果 A 需要 B,B 又需要 A,我們就重新遇到當時那個問題:

每個人都在等另一個人

甚至在 Effect 中,循環不一定會直接寫成:

A 依賴 B
B 依賴 A

它可能是:

A 改變
  ↓
Effect 1
  ↓
寫入 B
  ↓
Effect 2
  ↓
寫入 A

最後形成:

A
↓
Effect 1
↓
B
↓
Effect 2
↓
A

看起來像完全不同的反應式問題。
但如果把框架的 API 名稱全部拿掉,它其實還是我們之前看過的:

Graph 中的循環


反應式依賴圖甚至還是一張動態圖

Day 18 我們談過:

Graph 本身也可能會改變

這就是動態圖(Dynamic Graph),Reactive System 裡也經常發生同樣的事情。

例如:

const result = computed(() => {
  if (loggedIn()) {
    return user().name
  }

  return 'Guest'
})

第一次執行時:

loggedIn = false

依賴關係可能只有 loggedIn → result,因為程式根本沒有讀取 user,但下一次:

loggedIn = true

執行路徑改變了,現在依賴關係可能變成:

loggedIn → result
user ─────→ result

也就是說:

Graph 的邊本身會隨著程式執行改變

再下一次 loggedIn 變回 false:

user → result

這條依賴關係甚至可能又消失,所以一個 Reactive System 不只是維護 Graph。

它維護的是:

一張會隨程式執行持續改變的依賴圖

這就是為什麼依賴追蹤、清理與解除連結等機制會變得重要。
因為舊的邊如果沒有移除,Graph 描述的就不再是現在真正的依賴關係。


Signal、Computed、Effect 到底是什麼?

到這裡,我們終於可以把前端熟悉的幾個名詞放回來。

  • Signal
  • Computed
  • Effect

如果只看 API,我們可能會把它們當成三種框架功能。
但從 Graph 的角度看,它們可以被理解成:

依賴圖中具有不同責任的節點

例如:
Signal 通常是 Graph 中的來源節點。
它保存某個可以改變的狀態:

price
quantity
taxRate

Computed 通常是一個衍生節點。
它從其他節點取得資料,計算出新的值:

price
   ↓
subtotal

它會依賴上游節點,也可能成為其他下游節點的依賴來源。
例如:
price → subtotal → total,所以 subtotal 同時使用上游的 price 與 quantity,也為下游的 total 提供資料。


而 Effect 則常可視為依賴圖的下游終點,它通常不會產生可供其他節點依賴的新值。
而是在依賴關係改變之後,執行某個副作用:

狀態
  ↓
Effect
  ↓
DOM

或者:

狀態
  ↓
Effect
  ↓
console

甚至:

狀態
  ↓
Effect
  ↓
外部系統

所以從這個角度看:

  • Signal
  • Computed
  • Effect

不是三個彼此獨立的 API,它們共同存在於同一張:

反應式依賴圖(Reactive Dependency Graph)

只是每一種節點的責任不同。


畫面更新只是依賴圖下游可能發生的一件事情

很多人第一次接觸反應式機制(reactivity),會把它理解成 狀態改變 → 畫面更新 這當然沒有錯。
但這其實只是其中一種用途,如果依賴圖是:

price
  ↓
subtotal
  ↓
total

total 更新之後,可能:

total
↓
DOM

也可能:

total
↓
紀錄日誌

也可能:

total
↓
本地儲存

甚至:

total
↓
另一個計算

因此,Reactive System 關心的不只是:

如何重新渲染 UI?

更底層的問題是:

當某個資訊改變時,變化應該沿著哪些依賴關係傳遞?

畫面更新只是這張依賴圖某些路徑最後產生的副作用之一。


從 Day 1 回頭看,答案其實沒有變

Day 1 時,我們問了一個問題:

如果所有資料都可以塞進 Array,為什麼還需要 Queue、Tree、Graph?

那時候我們得到的答案是:

資料結構不只存放資料,也表達我們想保留的關係與操作規則

排隊時,最重要的是先來後到。
所以 Queue 很適合。

復原操作關心最後發生的事情。
所以 Stack 很自然。

組織架構具有階層關係。
所以 Tree 很直覺。

捷運站之間可以任意連接。
所以 Graph 很合理。

而 Reactive System 真正關心的是:

誰依賴誰?
一個東西改變之後,會影響誰?

如果現在要描述的是狀態與計算如何互相影響,而不是 UI 如何組成,那麼最自然的表示方式就是:

依賴圖

再回頭看最開始的購物車:
https://ithelp.ithome.com.tw/upload/images/20260915/20129020RUIYsLQTZa.png

第三章談過的 Graph 觀念,也在這裡重新聚合起來:

  • 節點與邊 → 資料與計算形成彼此相連的結構
  • 有向圖 → 依賴關係具有方向
  • 傳播與失效標記 → 上游改變會讓下游結果需要重新確認
  • 循環 → 依賴關係可能繞一圈回到自己
  • 動態圖 → 依賴關係會隨著執行路徑建立與移除

於是 Signal、Computed、Effect 不再只是幾個需要背下來的 API 名稱,而是同一個基本模型中具有不同責任的節點。


最後,真正要學的是看見問題結構

我們花了 30 天學的不只是:

  • Queue
  • Graph
  • BFS
  • Greedy

這些名稱都是工具,貫穿整個系列的是一種更基本的能力:

如何看見一個問題背後真正的結構,再決定應該如何處理它

有時候問題的核心是順序、有時候是階層關係、有時候是資料之間的連結。
有時候是依賴關係、有時候是有限資源、有時候是多個彼此衝突的限制條件。
而只有當問題被描述清楚之後,資料結構與演算法才真正有意義。

今天,AI 已經可以快速協助我們完成許多實作。
它會寫 Queue、會寫 BFS、會寫 Dijkstra,也能協助我們建立 Reactive System。
但在要求 AI 產生答案之前,我們仍然必須先釐清:

  • 這是一個 Graph 問題,還是排程問題?
  • 真正的最佳化目標是什麼?
  • 哪些限制條件不能被違反?
  • 答案必須在多久之內產生?

這些問題都發生在實作之前。
也因此,我們真正需要培養的能力比以前更往前移了一步。
花時間去理解:

我現在到底在解什麼問題?

因為 AI 可以幫我們寫出答案。

但我們仍然必須知道,它到底回答了什麼問題


上一篇
Day 29|為什麼工程師常常接受「夠好的答案」?
系列文
生活中的資料結構與演算法:30 天學會把現實問題變成可推理的模型 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言