模組二|響應式對照(Day 6–10)
先備:Day 6(依賴追蹤 track/trigger)、Day 8(provider 套件與
context.watch/context.read)。第一次讀可跳過本篇 Flutter code 的細節,先看「差異與坑」。
Day 8 那顆購物車,我把 ChangeNotifierProvider 掛錯了層——掛在讀它的那顆 widget 底下。flutter analyze 回我 No issues found!,跑起來畫面直接紅屏:Error: Could not find the correct Provider<Cart> above this CartBadge Widget。編譯器全綠、執行期才爆,這種組合我在 Vue 待了十幾年早就麻木,但 provider 的作者 Remi Rousselet 顯然沒有:他為了消滅這一整類錯誤,把自己的套件整個重寫成 Riverpod。「Pinia 之於 Vue,就是 Riverpod 之於 Flutter」,兩邊社群都這麼說,我一開始不信——一個建立在 Proxy 自動追蹤上,一個活在沒有響應式系統的語言裡,能像到哪去?把兩份 code 並排之後我改口了。
結論先講:Riverpod 和 Pinia 的 setup store 同形狀——全域定義、自動追蹤依賴;差別是 Pinia 就地改物件,Riverpod 整顆換掉,錯誤搬到編譯期。
先把「像」的那一半立起來。這裡要解決的問題是:計數器的狀態要跨元件共用,還要有一個自動跟著它變的加倍值,而且我不想在每顆用到它的元件祖先掛任何東西。Pinia 的 setup store 就是把 Composition API 直接當 store 寫——state 是 ref、getter 是 computed、action 是普通函式:
// stores/counter.js
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubled = computed(() => count.value * 2) // 依賴 count,自動重算、自動快取
function increment() {
count.value++ // 就地改,Proxy 攔截後通知所有讀過它的人
}
return { count, doubled, increment }
})
元件端只要 const counter = useCounterStore(),模板寫 {{ counter.count }} × 2 = {{ counter.doubled }},按鈕綁 @click="counter.increment()",沒有 mapState、沒有 commit。三個特徵記著,等一下逐項對照:store 定義在元件樹之外(就是一個全域模組),使用時不需要祖先掛東西;doubled 跟 count 的依賴關係是 Day 6 那套 track/trigger 自動連起來的,我從沒宣告過;跨 store 組合就是在一個 store 裡呼叫另一個 useXxxStore()。
動筆之前先回答一個問題:Day 8 的 provider 套件已經能把資料往下傳、也能觸發重建了,為什麼還要換一套?同一個作者、隔一套套件,重寫的理由有三個,剛好對應開場那個紅屏的三種成因。
provider 的取用方式是 context.watch<Cart>(),型別寫在尖括號裡,但「這棵樹上到底有沒有 Cart」要跑起來才知道,找不到就丟 ProviderNotFoundException。Riverpod 把 provider 本身變成一個全域變數:ref.watch(cartProvider) 的回傳型別由 cartProvider 自己決定,名字打錯或型別對不上,analyzer 當場畫紅線,紅屏那一類錯誤整個絕種。BuildContext。 provider 的一切都掛在 widget 樹上,所以你只能在拿得到 context 的地方讀它——想在一個純 Dart 的 service 或背景任務裡讀狀態?沒辦法。Riverpod 給你的 ref 跟 widget 樹脫鉤,樹外面照樣讀得到,測試更是直接受益(見下面坑五)。ref.watch 另一個 provider,依賴關係連成一張圖,上游變了下游自動重算。這就是 Pinia 那個「在一個 store 裡呼叫另一個 store」,只是 Riverpod 把它做成了套件的核心機制,而非慣例。理由講完,概念一次登場一個。① Notifier 就是 setup store 裡「state + 改 state 的那幾個函式」打包成的 class:繼承 Notifier<int>,用 int build() => 0; 給初始值,用 void increment() => state++; 當 action;state 這個欄位由 Notifier 提供,指派給它就等於通知所有訂閱者。兩個方法都用了 Day 2 那個 => 單表達式函式體——int build() => 0; 就是 int build() { return 0; } 的簡寫,跟 JS 的箭頭函式沒有血緣關係。
② NotifierProvider 是把這顆 Notifier 掛上全域的握把,地位等同 defineStore('counter', ...):全域、跟 widget 樹的位置無關。它收的參數是 Counter.new(constructor tear-off:把建構子本身當成一個值傳進去,等同寫 () => Counter(),Dart 2.15 才有的語法)——Riverpod 要的是「怎麼造一顆 Counter」的說明書,因為造與丟的時機由它決定,不由你決定。
③ 純 Provider 是 computed 的對應物:沒有 class,就是一個函式,函式體裡 ref.watch 到誰就自動依賴誰。④ ConsumerWidget 與 WidgetRef 是讀取端:ConsumerWidget 是 StatelessWidget 的 Riverpod 版,build() 多收一個 WidgetRef ref 參數,那就是 widget 裡的取水口。四樣湊齊組起來,下面這段在 flutter create 出來的專案裡(pubspec.yaml 加一行 flutter_riverpod)貼上就能跑:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
// ① state + action:Notifier ≒ setup store 的 ref + function
class Counter extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
}
// ② 全域握把:這一行 ≒ defineStore('counter', ...)
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
// ③ derived provider ≒ computed,watch 誰就依賴誰
final doubledProvider = Provider<int>((ref) => ref.watch(counterProvider) * 2);
// ④ 讀取端:ConsumerWidget = StatelessWidget 多一個 ref 參數
class CounterView extends ConsumerWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
final doubled = ref.watch(doubledProvider);
return Column(mainAxisSize: MainAxisSize.min, children: [
Text('$count × 2 = $doubled'),
ElevatedButton(
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Text('+1'),
),
]);
}
}
void main() => runApp(const ProviderScope( // = app.use(createPinia())
child: MaterialApp(home: Scaffold(body: Center(child: CounterView()))),
));
逐行對照:第 11 行的 NotifierProvider ≒ defineStore,全域定義、跟 widget 樹的位置無關,只要根部包一個 ProviderScope(第 32 行)就好。第 13 行的 doubledProvider ≒ computed——Day 6 那套依賴追蹤回來了:我沒宣告過它依賴 counterProvider,是 ref.watch 這個動作自己去登記的。第 20–21 行,widget 用同一個 ref.watch 訂閱,counterProvider 一變,doubledProvider 重算、這顆 widget 重建,全程沒有 notifyListeners()、沒有 setState。第 25 行的 ref.read(...) 後面接 .notifier,是取「那顆 Notifier 實例」來呼叫 action;watch 訂閱、read 只取一次,這條分工跟 Day 8 的 context.watch/context.read 同一條規則,心智直接搬過來就好。
還有一個 Pinia 使用者會羨慕的東西:FutureProvider。非同步資料直接宣告成 provider,UI 端 ref.watch 拿到的是 AsyncValue,loading/error/data 三態被型別系統逼著你窮舉處理,漏一個分支 analyzer 就抗議。Vue 這邊同樣的需求,要嘛自己手管 isLoading 旗標,要嘛上 TanStack Query;Riverpod 把它做成了語言等級的模式匹配,agent 產出的錯誤處理因此從「經常忘記」變成「不寫編不過」。
坑一:Pinia 就地改,Riverpod 整顆換。 像的部分到此為止,而這是搬家時最痛的一刀——用兩個 demo 的真實 code 並排看最清楚。Waypoint Air 的訂票 store,Vue 這邊是 Pinia 的 options store,action 直接對 this 上的欄位賦值:
// flight-booking-vue/src/store/booking.ts:23-32
actions: {
setDestination(dest: string) {
this.destination = dest
},
// 互換出發地與目的地
swapStations() {
const from = this.departure
this.departure = this.destination
this.destination = from
},
同樣這兩個功能,Flutter 端是逐行照搬過去的(檔頭的 doc comment 直接寫 1:1 port of the Pinia booking store),寫出來卻是另一個形狀——因為 BookingState 的每個欄位都是 final,改不動,只能生一顆新的整個換掉:
// flight-booking-flutter/lib/store/booking_store.dart:144-155(節錄)
void setDeparture(String dep) => state = state.copyWith(departure: dep);
void setDestination(String dest) => state = state.copyWith(destination: dest);
/// 互換出發地與目的地 — the caller guards against an empty destination, as in
/// the Vue store.
void swapStations() {
state = state.copyWith(
departure: state.destination,
destination: state.departure,
);
}
逐行對照:this.destination = dest ↔ state = state.copyWith(destination: dest),左邊改的是 store 裡那個欄位,右邊換掉的是整顆 state 物件,copyWith 把沒指定的欄位原封複製過去。swapStations 差得更明顯:Pinia 版需要一個 from 暫存變數,因為它是循序賦值,「改到一半」的中間狀態真的存在;Riverpod 版不需要暫存變數,因為 state.destination 和 state.departure 讀的都是「換之前」那顆物件,中間狀態根本不存在。這顆 store 的入口只有一行——NotifierProvider<BookingNotifier, BookingState>(BookingNotifier.new)(booking_store.dart:175-177),上面講的 tear-off 在真案子裡就長這樣。症狀:你照 Vue 直覺寫 state.destination = dest,analyzer 回你 'destination' can't be used as a setter because it's final;改寫成 state.bookedFlights.add(flight) 更陰險——編譯過,執行期丟 Unsupported operation: Cannot add to an unmodifiable list,就算你繞過去了畫面照樣不動,因為 state 那顆物件本身沒被換掉。
坑二:autoDispose 與生命週期。 Pinia store 預設活到 app 結束;Riverpod 的 provider 可以宣告成 autoDispose(沒人在看的時候自動把狀態丟掉),而在 Riverpod 3 的新語法裡,這是預設行為。忘了這件事,頁面切走再回來 state 歸零,你會以為是 bug——它是 feature。需要續命有 ref.keepAlive() 可以手動保活,但那是一個刻意的決策,跟 Pinia「反正都活著」的預設是兩種思路。用 Vue 的話翻譯:Riverpod 把「store 的生命週期」從無限期改成了引用計數,這對長時間掛著的 Web app 是記憶體優勢,對習慣狀態常駐的 Vue 人是第一週的驚嚇來源。症狀:填到一半的表單切去別的分頁再切回來,欄位全空,而且沒有任何錯誤訊息。
坑三:provider 種類爆炸。 Provider、NotifierProvider、FutureProvider、StreamProvider⋯⋯Pinia 一招 defineStore 走天下,Riverpod 光選型就是一道題(Day 16 專篇盤點)。初學者的痛,換個角度也是 agent 的優勢:種類即型別,選錯了編譯器會講話。症狀:你想包一個非同步請求,在四種 provider 之間反覆橫跳,官方文件每一種都說得通,但你不知道自己這個場景該用哪一種。
坑四:codegen 路線分歧。 官方主推 @riverpod 註解 + build_runner 產生程式碼,型別更乾淨,但多了一個編譯步驟、跑起來不快。本文用非 codegen 寫法讓對照乾淨;上真案子前這個選擇要自己做,Day 19 會把它算進選型成本。症狀:你照官方文件抄了 @riverpod 上去,analyzer 滿江紅說找不到 counterProvider——因為你還沒跑 dart run build_runner watch,那個變數是產生出來的。
坑五:測試體驗(其實是甜點)。 Pinia 測 store 前要 setActivePinia(createPinia()) 佈景;Riverpod 一個 ProviderContainer 就能在純 Dart 環境測任何 provider,完全不碰 widget 樹,還能用 overrides 把任意上游 provider 換成假資料——這是上面第 2 個理由「ref 不依賴 BuildContext」的直接紅利。我讓 agent 寫 Riverpod 單元測試幾乎一次過:container、override、expect 的組合極其固定。症狀:這一條你不會被坑,反而會在寫完第一支測試後回頭問「Vue 那邊為什麼要 mock 那麼多東西」。
編譯期 vs 執行期,這是本系列獨家論點的主場。 Pinia 的爽建立在動態語言上:store 拿來就用,錯了執行期見。Riverpod 反向操作——watch 錯型別、忘了包 ProviderScope、在 Notifier 外面改 state,全部在編譯或 analyze 那一關就被攔下來。這一篇讓我對 Flutter Web 的懷疑減少了一格,理由跟「好不好用」無關:我讓 agent 改 Waypoint Air 的訂票 store 時,它寫出 state.destination = dest 的次數不少,但那些嘗試一次都沒走到我眼前,analyzer 在 commit 之前就攔掉了;Pinia 那邊同一類錯(改了巢狀物件卻沒觸發更新)我得靠肉眼在畫面上抓。編譯期安全對人是紀律,對 agent 是護欄——這是這個論點目前最硬的一塊證據。
一句話:Riverpod 用「provider 圖 + ref.watch」把 Vue 的 computed 式依賴追蹤搬進了 Flutter,代價是把 Pinia 的執行期自由換成了編譯期紀律——而這個代價對 AI agent 工作流來說根本是折扣。
下一篇 Day 10〈Bloc/Cubit:事件驅動狀態管理,Vue 有類似模式嗎?〉——Flutter 生態的另一大門派,走的是 Vue 社群早就丟掉的那條路。
如果你卡在語法
深入原理
ref.keepAlive())