模組四|狀態管理(Day 15–19)
先備:Day 6(依賴追蹤 track/trigger)、Day 8(
ChangeNotifier與notifyListeners)。本篇會把 demo 的 store 全檔攤開,包括我自己寫的兩處any。
要公平地評價 Riverpod 和 Bloc,得先把量尺校準。這個模組接下來四天要對 Flutter 的三套狀態管理方案做選型結論,而我的量尺就是 Pinia——寫了這麼多年 Vue,它是我心中「狀態管理該有的樣子」。所以今天先不碰 Flutter 的新東西,把 Pinia 的設計哲學講清楚:state、getters、actions 三件套為什麼是這樣切,以及這個切法在 Dart 世界的最小對應物長什麼樣。
結論先講:
Pinia 三件套翻成 Dart 逐行都有對應,落差全在「誰來記帳」。
Vue 幫你記,Dart 要你自己記。這篇做完座標定位,Day 16 起逐一上秤。
Pinia 的核心主張只有一句:一個 store 就是一個有身分證的 composable。state 是 ref、getters 是 computed、actions 是普通函式,setup store 寫法下三者根本就是 Composition API 原班人馬:
// stores/cart.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useCartStore = defineStore('cart', () => {
// state
const items = ref([])
// getters:衍生值,依賴追蹤全自動
const totalPrice = computed(() =>
items.value.reduce((sum, i) => sum + i.price * i.qty, 0)
)
const isEmpty = computed(() => items.value.length === 0)
// actions:唯一建議的變更入口,天生支援 async
function add(item) {
const found = items.value.find(i => i.id === item.id)
found ? found.qty++ : items.value.push({ ...item, qty: 1 })
}
async function checkout() {
await api.post('/checkout', items.value)
items.value = []
}
return { items, totalPrice, isEmpty, add, checkout }
})
設計上有三個我認為值得帶去 Flutter 對照的重點:
totalPrice 只在 items 變動時重算,用它的元件也只在它變動時重繪。這個精準度來自 Vue 的細粒度依賴追蹤,開發者零成本。store.items.push(...),Pinia 不攔你。紀律靠 code review 維持。useXxxStore() 組合,沒有 Vuex 時代 module 巢狀的官僚體系。上面那支購物車是教學用的乾淨版本。實際的訂票 App 那支 src/store/booking.ts 全檔 56 行,用的是 options store 寫法,結構比教學版更土:11 個 state 欄位、9 個 action、零個 getter。
// src/store/booking.ts:4-22(節錄 state)
state: () => ({
departure: '台北 (TPE)',
destination: '',
departureDate: '2026-07-15',
passengers: 1,
cabinClass: '經濟艙',
selectedFlight: null as any, // :12 ← 型別逃逸
bookedFlights: [] as any[], // :14 ← 型別逃逸
language: '繁體中文',
// …共 11 個欄位
}),
第 12 行和第 14 行那兩個 as any 是這篇最該被看見的東西,我把它留在 repo 裡沒改。
為什麼會這樣寫?因為 Pinia 的 state 是一個回傳物件的函式,型別是推導出來的。selectedFlight 初始值是 null,TS 推出來就是 null 型別,之後指派任何航班物件都會報錯。正確做法是宣告一個 Flight 介面再寫 null as Flight | null。但我當下在趕 demo,as any 一寫就過了,而且它從此不會再抱怨任何事——你之後往裡面塞什麼都行。
9 個 action 裡有兩個的參數型別也是 any(setSelectedFlight(flight: any)、addBookedFlight(flight: any))。也就是說,這支 store 裡最重要的兩份資料——你選了哪班飛機、你訂了哪些行程——全程沒有型別。
我特別強調這件事,是因為 Day 16 要看 Flutter 端同一支 store 怎麼寫。那邊不是「比較嚴謹」,是那個語言不給我這個偷懶的選項。
Flutter 沒有「官方欽定的 Pinia」,但把 Pinia 三件套翻成 Dart,最小可用的對應物是 ChangeNotifier——它也是 Day 8 提過的 provider 生態的地基。state 變成私有欄位、getters 變成 Dart 原生 getter、actions 變成方法:
class CartStore extends ChangeNotifier {
// state:私有欄位,外部只能透過 getter 讀
final List<CartItem> _items = [];
List<CartItem> get items => List.unmodifiable(_items);
// getters:就是 Dart 的 getter,但沒有依賴追蹤
double get totalPrice =>
_items.fold(0, (sum, i) => sum + i.price * i.qty);
bool get isEmpty => _items.isEmpty;
// actions:改完 state 要自己喊一聲
void add(CartItem item) {
final found = _items.where((i) => i.id == item.id).firstOrNull;
if (found != null) {
found.qty++;
} else {
_items.add(item.copyWith(qty: 1));
}
notifyListeners(); // 忘了這行,畫面就是不動
}
Future<void> checkout() async {
await api.checkout(_items);
_items.clear();
notifyListeners();
}
}
add() 裡那個 firstOrNull(來自 collection 套件的擴充方法,非內建;找不到時回 null,不會丟例外)值得單獨提一句:Dart 內建的 .first 在空集合上會直接丟例外,所以要嘛先判斷長度,要嘛引入這個套件。JS 的 .find() 找不到回 undefined 是天經地義,Dart 這邊你得選一個立場。
結構幾乎逐行對得上,但骨子裡差一件大事:Pinia 的響應式是推導出來的(依賴追蹤),ChangeNotifier 的響應式是你手動宣告的(notifyListeners())。Vue 幫你記帳,Dart 要你自己記帳。
手動記帳還有下半場:通知發出去之後,誰重建?ChangeNotifier 的通知不分青紅皂白,所有 listener 一起收。要找回 Pinia 那種「只有用到 totalPrice 的元件才更新」的精準度,provider 套件給了 context.select——由消費端宣告「我只關心這個投影」:
// 只在 totalPrice 變動時重建,items 增減但總價不變就不動
final total = context.select<CartStore, double>((s) => s.totalPrice);
注意方向反過來了:Vue 的精準更新是提供端免費附送,Flutter 是消費端自己加訂。忘了加不會壞,只是整棵子樹陪著重建——用 DevTools 的 rebuild 統計抓得到,但你得先知道要去抓。
坑一:忘記 notifyListeners() 是 Flutter 新手(和 AI agent)的第一名錯誤。 它不會報錯、不會警告,只是畫面不更新。我讓 agent 寫這類 store 時的對策是把「每個變更方法結尾必呼叫 notifyListeners」寫進規格,再用 widget test 驗 UI 反應——這種錯編譯期抓不到,是 Dart 強型別防線上少數的洞。症狀:按下按鈕,資料真的改了(你在 debugger 裡看得到新值),畫面完全沒反應,而且 console 一片乾淨——跟 Day 6 那句「沒有人在聽」是同一種靜默失敗。
坑二:封裝強度反轉。 Pinia 的 state 是敞開的,防呆靠人;Dart 這邊私有欄位加 List.unmodifiable,外部 store.items.add(...) 直接在執行期丟例外、編譯期被型別提示。對人類團隊這是風格之爭,對 agent 工作流這是實質差異:agent 產出違規存取的 code,回饋在秒級就到,不用等 code review。這是本系列獨家論點在狀態管理層的第一個落點。症狀:Vue 那邊某個元件偷偷繞過 action 直接改 store.items,跑起來一切正常,三個月後才有人發現這個 store 有兩個變更入口;Flutter 那邊同一行寫下去,IDE 當場就畫紅線。
坑三:getters 沒有快取。 Dart getter 每次存取都重算,totalPrice 這種 O(n) 的還好,重的計算得自己 memoize 或改存欄位。Vue 人習慣了 computed 的免費快取,這裡會不適應。務實的分級:輕量摺疊直接算,別過早最佳化;真正重的(大列表排序、過濾)改成欄位、在每個變更方法裡同步維護——代價是「衍生值一致性」從框架保證變成你的紀律,而這正是 Pinia 用依賴追蹤替你扛掉的那份心智負擔。症狀:列表一長就開始掉幀,你 profile 半天才發現某個 getter 在一次 build 裡被讀了幾十次,每次都重跑一遍 O(n) 的摺疊。
坑四:Web 平台反而少一個議題。 Pinia 在 Nuxt 下要處理 SSR hydration、state 序列化;Flutter Web 沒有 SSR(Day 25 會展開),這整包複雜度直接消失——用不存在的功能換來的簡單,算不算優點見仁見智。症狀:你去搜「Flutter Web SSR hydration」,搜到的全是 Nuxt 和 Next 的文章——因為這個問題在這裡不存在,而它不存在的理由你不會喜歡(Day 25 會講)。
還有一題 Pinia 人一定會問:store 之間怎麼組合?Pinia 裡 useCartStore() 內部直接呼叫 useUserStore() 就完事。ChangeNotifier 世界的對應物是建構子注入——CartStore(this.userStore),再由 MultiProvider/ProxyProvider 在樹頂組裝。功能等價,但依賴圖從「呼叫時隱式建立」變成「組裝時顯式宣告」,store 一多,樹頂那坨組裝碼就是你的依賴清單——難看,但誠實。
另外先預告:ChangeNotifier 是最小對應物,不是最佳對應物。它的通知是粗粒度的(一喊全體 listener 重建),Pinia 那種精準更新要靠 Riverpod 的 provider 依賴圖找回來——這正是明天的主題。
一句話:Pinia 三件套翻成 Dart 逐行都有對應,真正的落差在「依賴追蹤自動記帳 vs notifyListeners 手動記帳」。明天 Day 16〈Riverpod Provider 種類與使用場景〉,看 Riverpod 怎麼用一堆 Provider 型別把記帳自動化找回來。
如果你卡在語法
深入原理