iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Modern Web

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

Day 15|Pinia store 設計:state/getters/actions

  • 分享至 

  • xImage
  •  

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

先備:Day 6(依賴追蹤 track/trigger)、Day 8(ChangeNotifiernotifyListeners)。本篇會把 demo 的 store 全檔攤開,包括我自己寫的兩處 any

要公平地評價 Riverpod 和 Bloc,得先把量尺校準。這個模組接下來四天要對 Flutter 的三套狀態管理方案做選型結論,而我的量尺就是 Pinia——寫了這麼多年 Vue,它是我心中「狀態管理該有的樣子」。所以今天先不碰 Flutter 的新東西,把 Pinia 的設計哲學講清楚:state、getters、actions 三件套為什麼是這樣切,以及這個切法在 Dart 世界的最小對應物長什麼樣。

結論先講:

Pinia 三件套翻成 Dart 逐行都有對應,落差全在「誰來記帳」。

Vue 幫你記,Dart 要你自己記。這篇做完座標定位,Day 16 起逐一上秤。

Vue 怎麼做

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 對照的重點:

  1. getters 不是快取語法糖,是依賴圖的節點totalPrice 只在 items 變動時重算,用它的元件也只在它變動時重繪。這個精準度來自 Vue 的細粒度依賴追蹤,開發者零成本。
  2. 變更集中在 actions 是慣例、不是強制。任何元件拿到 store 都能直接 store.items.push(...),Pinia 不攔你。紀律靠 code review 維持。
  3. 一個領域一個 store,store 之間可以互相 useXxxStore() 組合,沒有 Vuex 時代 module 巢狀的官僚體系。

我自己的 store 長什麼樣(以及它的兩個洞)

上面那支購物車是教學用的乾淨版本。實際的訂票 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 裡有兩個的參數型別也是 anysetSelectedFlight(flight: any)addBookedFlight(flight: any))。也就是說,這支 store 裡最重要的兩份資料——你選了哪班飛機、你訂了哪些行程——全程沒有型別。

我特別強調這件事,是因為 Day 16 要看 Flutter 端同一支 store 怎麼寫。那邊不是「比較嚴謹」,是那個語言不給我這個偷懶的選項

Flutter Web 怎麼做

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),再由 MultiProviderProxyProvider 在樹頂組裝。功能等價,但依賴圖從「呼叫時隱式建立」變成「組裝時顯式宣告」,store 一多,樹頂那坨組裝碼就是你的依賴清單——難看,但誠實。

另外先預告:ChangeNotifier 是最小對應物,不是最佳對應物。它的通知是粗粒度的(一喊全體 listener 重建),Pinia 那種精準更新要靠 Riverpod 的 provider 依賴圖找回來——這正是明天的主題。

小結與下一篇

一句話:Pinia 三件套翻成 Dart 逐行都有對應,真正的落差在「依賴追蹤自動記帳 vs notifyListeners 手動記帳」。明天 Day 16〈Riverpod Provider 種類與使用場景〉,看 Riverpod 怎麼用一堆 Provider 型別把記帳自動化找回來。

參考資料

如果你卡在語法

深入原理


上一篇
Day 14|深層連結(Deep Link)與 Web URL 同步策略比較
下一篇
Day 16|Riverpod Provider 種類與使用場景
系列文
Vue 前端工程師視角看 Flutter Web17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言