模組四|狀態管理(Day 15–19)
先備:Day 9(
ref.watch/ref.read的分工)、Day 15(Pinia 三件套與那兩個any)。本篇後半會把 demo 的 Dart 版 store 攤開來跟昨天的 TS 版對照。
第一次打開 Riverpod 文件,我的反應跟多數 Vue 人一樣:Provider、NotifierProvider、FutureProvider、StreamProvider……一個狀態管理庫為什麼需要這麼多種容器?Pinia 只有一種 store 啊。但對照著拆完之後我改觀了:Riverpod 不是比 Vue 多發明了東西,它是把 Vue 生態裡「不成文的慣例」全部顯性化、型別化了。這篇就做這件翻譯工作。
結論先講:
Riverpod 的容器種類,就是 Vue 的不成文慣例被寫進型別系統的樣子。
昨天那支 booking.ts 裡的兩個 any,在 Dart 端沒有活下來——不是因為我比較自律,是因為那個語言不給我這個選項。
Vue 其實也有「狀態容器的種類學」,只是沒人這樣叫它。回想你日常的分工:同步衍生值用 computed,可變共享狀態用 Pinia store,非同步資料包成 composable(或直接 Nuxt 的 useFetch),即時串流再包一個 WebSocket composable。慣例存在,但全靠團隊默契:
// 1. 同步衍生值:computed
const doubled = computed(() => count.value * 2)
// 2. 非同步資料:自己包 composable,loading/error 三態自己管
export function useUser(userId) {
const user = ref(null)
const error = ref(null)
const loading = ref(true)
watchEffect(async () => {
loading.value = true
error.value = null
try {
user.value = await fetchUser(userId.value)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
})
return { user, error, loading }
}
注意第二段:loading、error、user 三個 ref 的狀態組合其實有非法區(loading 中卻有 error?),但 JS 不會阻止你漏處理任何一態。這件事記著,等下對照。
Riverpod 的每一種 Provider,都能在上面的 Vue 慣例裡找到座位:
| Riverpod | Vue 對應物 | 用途 |
|---|---|---|
Provider |
computed |
唯讀衍生值,依賴變動自動重算 |
NotifierProvider |
Pinia store | 可變狀態 + 變更方法 |
FutureProvider |
async composable / useFetch |
一次性非同步資料 |
StreamProvider |
WebSocket composable | 持續推送的串流 |
.family 修飾 |
帶參數的 composable | 同一邏輯、不同參數各自快取 |
.autoDispose 修飾 |
元件卸載自動清理 | 沒人 watch 就銷毀狀態 |
把 Day 15 的購物車翻成 Riverpod,再加一個吃參數的非同步查詢:
// NotifierProvider ≈ Pinia store
class Cart extends Notifier<List<CartItem>> {
@override
List<CartItem> build() => [];
void add(CartItem item) => state = [...state, item]; // 不可變更新
}
final cartProvider = NotifierProvider<Cart, List<CartItem>>(Cart.new);
// Provider ≈ computed:watch 誰就依賴誰,自動重算
final totalPriceProvider = Provider<double>((ref) {
final items = ref.watch(cartProvider);
return items.fold(0, (sum, i) => sum + i.price * i.qty);
});
// FutureProvider.family ≈ useUser(id)
final userProvider = FutureProvider.family<User, String>((ref, id) async {
return fetchUser(id);
});
UI 端消費 userProvider 時拿到的是 AsyncValue<User>,用 when 展開:
ref.watch(userProvider(id)).when(
loading: () => const CircularProgressIndicator(),
error: (e, _) => Text('錯誤:$e'),
data: (user) => Text(user.name),
);
這就是剛剛請你記著的對照:Vue 的三個 ref 靠自律組合,AsyncValue 把 loading/error/data 做成編譯期強制窮舉的型別——少寫一個分支,編譯不過。Day 15 說 ChangeNotifier 丟掉了 Pinia 的自動依賴追蹤,totalPriceProvider 裡的 ref.watch 則把它找回來了:依賴圖明確、更新精準,沒有 notifyListeners 這回事。
日常操作也各有座位,補三組對應免得你到時候翻文件:Pinia 的 $reset ↔ ref.invalidate(cartProvider)(銷毀重建);useFetch 的 refresh() ↔ ref.refresh(userProvider(id))(重跑非同步);storeToRefs 拆欄位訂閱 ↔ ref.watch(cartProvider.select((items) => items.length))(只訂閱一個投影,其他變動不重建)。第三組最容易被漏掉——agent 預設整顆 watch,列表頁的效能就是這樣一格一格掉的。
any,在這裡活不下來現在把 Day 15 那支 56 行的 booking.ts 跟它的 Dart 對照組並排。Flutter 端的 lib/store/booking_store.dart 是 178 行——三倍多,多出來的部分幾乎全是型別。第一段 :5-72,Vue 端那兩個 any 在這裡被展開成三個真正的類別(FlightOption、BookedFlight、PaymentCard):
// lib/store/booking_store.dart:5-24(節錄)
class FlightOption {
final String id;
final String carrier;
final int price;
// …共 8 個欄位,全部 final、建構子全部 required
}
Vue 端 selectedFlight: null as any 的那個「任何東西」,在這裡必須先被定義成什麼。這不是我變自律了,是 Dart 沒有 any。
第二段是狀態類本身(:75-138),11 個欄位跟 Vue 版一一對應,但每一個都是 final:
// lib/store/booking_store.dart:75-90(節錄)
class BookingState {
final String departure;
final int passengers;
final FlightOption? selectedFlight; // ← 對應 Vue 的 `null as any`
final List<BookedFlight> bookedFlights; // ← 對應 Vue 的 `[] as any[]`
// …共 11 個欄位,全部 final,建構子每個都有預設值
final 代表這個物件建好之後改不了,所以每個 action 都不能就地改,只能生一個新的——那就是 copyWith 存在的理由(Day 9 看過一次):void setLanguage(String lang) => state = state.copyWith(language: lang);。
第三段是整支 store 對外的入口,:175-177,只有三行:
final bookingProvider = NotifierProvider<BookingNotifier, BookingState>(
BookingNotifier.new,
);
對照 Pinia 的 defineStore('booking', {...}):一樣是一行宣告、全域可取用、跟 widget 樹的位置無關。兩個型別參數把「誰改」和「改什麼」寫在臉上,而 BookingNotifier.new 就是 Day 9 那個 constructor tear-off。
三倍的行數換到什麼?換到那兩份最重要的資料——你選了哪班飛機、你訂了哪些行程——從「執行期才知道裡面有什麼」變成「編譯期就講好」。划不划算,Day 19 給結論。
坑一:不可變更新是硬規定。 state = [...state, item] 才觸發更新;agent(和 Vue 肌肉記憶)很愛寫 state.add(item)——編譯過、執行不炸、UI 就是不動。這是 Riverpod 對 Vue 人最陰的一個坑,因為 Pinia 教你的正是「直接 mutate 沒關係」。對策同 Day 15:規格裡寫死,widget test 兜底。症狀:你新增一筆資料,log 印出來清單長度確實變成 4 了,畫面卻還是三筆——因為 Riverpod 比對的是「這還是同一個 list 物件嗎」,而你動的是它的內容。
坑二:ref.watch 與 ref.read 混用。 準則很簡單——build 裡用 watch 建立依賴,事件回呼裡用 read 拿一次性快照。寫反了要嘛畫面不更新、要嘛無限重建。Vue 沒有這個區分(template 裡引用就是依賴),所以直覺帶不過來。症狀:在 build 裡用了 read,資料變了畫面不動;反過來在 onPressed 裡用了 watch,畫面開始無限重建、CPU 直接滿載。
坑三:codegen 分裂。 Riverpod 另有 @riverpod 註解 + build_runner 的產生器寫法,文件和社群範例兩種語法混雜。讓 agent 寫的話務必在指示裡鎖定一種,不然它會兩種風格交替出現。我選手寫版——少一層工具鏈,錯誤訊息更直接。症狀:你照著某篇教學寫 @riverpod 註解,跑起來說找不到 xxx.g.dart——因為那份教學預設你已經跑過 build_runner,而它沒說。
坑四:autoDispose 忘了加。 預設 provider 是全域長壽的,列表頁進出十次,十份查詢狀態都還活著。Vue 的 composable 跟著元件生命週期走,天生沒這問題。反向操作也有:加了 autoDispose 的查詢想在返回時保留快取,用 ref.keepAlive() 手動續命。兩邊都是「預設選一邊、另一邊開洞」——只是 Riverpod 預設留、Vue 慣例丟,方向相反,遷移時最容易背錯的就是預設值。症狀:搜尋頁進出十次之後記憶體一路往上,DevTools 看得到十份查詢結果都還活著,而畫面上一份都沒用到。
回到開頭的疑問:種類多是缺點嗎?我的結論是——對人類初學者是認知負擔,對 agent 反而是資產:每種容器的合法操作被型別寫死,agent 選錯容器或用錯方法,flutter analyze 當場報。Vue 把慣例留給人,Riverpod 把慣例交給編譯器——這正好落在本系列「強型別讓 agent 回饋迴圈更快」的論點上。
一句話:Riverpod 的六種 Provider 不是新概念,是 Vue 生態隱形慣例的型別化版本,代價是不可變更新的肌肉記憶重訓。明天 Day 17〈Bloc 狀態機設計實戰〉,看事件驅動路線把「狀態轉移」本身也型別化之後,會發生什麼事。
如果你卡在語法
深入原理