iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Modern Web

Vue 前端工程師視角看 Flutter Web系列 第 16

Day 16|Riverpod Provider 種類與使用場景

  • 分享至 

  • xImage
  •  

模組四|狀態管理(Day 15–19)

先備:Day 9(ref.watchref.read 的分工)、Day 15(Pinia 三件套與那兩個 any)。本篇後半會把 demo 的 Dart 版 store 攤開來跟昨天的 TS 版對照。

第一次打開 Riverpod 文件,我的反應跟多數 Vue 人一樣:ProviderNotifierProviderFutureProviderStreamProvider……一個狀態管理庫為什麼需要這麼多種容器?Pinia 只有一種 store 啊。但對照著拆完之後我改觀了:Riverpod 不是比 Vue 多發明了東西,它是把 Vue 生態裡「不成文的慣例」全部顯性化、型別化了。這篇就做這件翻譯工作。

結論先講:

Riverpod 的容器種類,就是 Vue 的不成文慣例被寫進型別系統的樣子。

昨天那支 booking.ts 裡的兩個 any,在 Dart 端沒有活下來——不是因為我比較自律,是因為那個語言不給我這個選項。

Vue 怎麼做

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 }
}

注意第二段:loadingerroruser 三個 ref 的狀態組合其實有非法區(loading 中卻有 error?),但 JS 不會阻止你漏處理任何一態。這件事記著,等下對照。

Flutter Web 怎麼做

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 的 $resetref.invalidate(cartProvider)(銷毀重建);useFetchrefresh()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 在這裡被展開成三個真正的類別(FlightOptionBookedFlightPaymentCard):

// 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.watchref.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 狀態機設計實戰〉,看事件驅動路線把「狀態轉移」本身也型別化之後,會發生什麼事。

參考資料

如果你卡在語法

深入原理


上一篇
Day 15|Pinia store 設計:state/getters/actions
下一篇
Day 17|Bloc 狀態機設計實戰
系列文
Vue 前端工程師視角看 Flutter Web17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言