模組二|響應式對照(Day 6–10)
先備:Day 8(
context.read/context.watch的取用方式)、Day 9(Riverpod 的不可變 state 與copyWith)。第一次讀可跳過事件流那一段,先看「差異與坑」。
Vue 生態花了好幾年才把 Vuex 的 mutation 丟掉——Pinia 的宣傳詞之一就是「不用再 commit 了,直接改」。結果一轉頭,Flutter 生態最大的門派之一 Bloc 把「禁止直接改 state、一切透過事件」奉為正統,社群還很買單。模組二的最後一天,我想搞清楚兩件事:這套事件驅動到底在堅持什麼?以及 Vue 人其實見過它,只是我們親手埋葬了它。
先把結論放在前面,你可以帶著它讀下去:
Bloc 就是 Vuex 的 mutation 換一個生態活了下來。
它沒有帶來新的心智模型,帶來的是 Vuex 當年做不到的兩件事:事件從字串升格成型別,以及一條官方的事件流可以掛觀察者。判斷該不該用它的準則,跟你當年判斷該不該用 Vuex 幾乎一樣——差別只在兩個生態的預設值站在相反邊。
Vue 的主流文化是「直接改」:counter.increment() 裡面就是 count.value++,沒有儀式。但事件驅動的形狀 Vue 不是沒有——Vuex 的 commit('INCREMENT') 就是。
假設你要做一個計數器,而且有個額外要求:每次狀態變化都要留下一筆有名字的紀錄,方便之後查「這個數字到底是誰改的」。直接改的寫法做不到這件事,因為 count.value++ 沒有名字。用 Composition API 把 reducer 模式包成 composable,三十行就能補上這個能力:
// useCounterMachine.js — reducer 模式的 composable
import { ref, readonly } from 'vue'
const initial = { count: 0 }
function reducer(state, event) {
switch (event.type) {
case 'increment': return { count: state.count + 1 }
case 'reset': return { ...initial }
default: return state // ← 漏接的事件靜靜掉進這裡
}
}
export function useCounterMachine() {
const state = ref(initial)
function dispatch(event) {
console.log('[event]', event.type) // 每次變化都有名字、可記錄
state.value = reducer(state.value, event)
}
return { state: readonly(state), dispatch } // 對外唯讀,只能靠 dispatch 改
}
UI 端只能 dispatch({ type: 'increment' }),伸手改 state 這條路被 readonly 封死了。這個模式的賣點從來就不在好寫,它換來的是可追溯:每次狀態變化都是一個有名字的事件,能 log、能重播、能審計。
Vue 社群的集體判斷是「多數場景不值得這個儀式」,所以 Pinia 贏了 Vuex。請記住這個判斷,等一下要拿它跟 Flutter 社群的判斷並排——同一道題,兩邊算出了不同的答案。
順帶留意 reducer 裡的 default: 那一行。你新增一個 'decrement' 事件卻忘了寫對應的 case,JS 不會有任何抱怨,它安靜地回傳原本的 state,畫面就是不動。這個洞等一下 Dart 會補起來。
先交代落差:Waypoint Air 沒有用 Bloc。這個模式在團隊協作與狀態機明確的專案才划算,我的 side project 規模撐不起它的樣板成本——這件事本身就是 Day 19 選型的一個輸入,所以我不會假裝自己是 Bloc 重度使用者。本篇的 code 是我為了搞懂它另外寫的最小範例,不是從 demo 裡挖出來的。
Bloc 生態給你兩個階級的選擇。輕量版 Cubit 幾乎沒有儀式:直接呼叫方法、emit 新的 state,用起來跟 Day 8 的 ChangeNotifier 手感接近。下面這段是完整可跑的版本——flutter create 一個新專案,flutter pub add flutter_bloc,把 lib/main.dart 整個換掉就會動:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0); // 初始 state
void increment() => emit(state + 1); // 直接呼叫方法,儀式感最低
}
void main() => runApp(
MaterialApp(
home: BlocProvider( // 對應 Day 8 的 Provider 注入
create: (_) => CounterCubit(),
child: const CounterPage(),
),
),
);
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: BlocBuilder<CounterCubit, int>( // 訂閱 state,變了就重建這一小塊
builder: (context, count) => Text('$count'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: const Icon(Icons.add),
),
);
}
}
逐行對照 Day 8 的 provider:
BlocProvider ≒ ChangeNotifierProvider,一樣掛在讀它的 widget 上方,掛錯層一樣執行期紅屏。BlocBuilder<CounterCubit, int> 就是 Day 8 的 context.watch 換一種寫法,兩個型別參數分別是「訂閱誰」和「state 是什麼型別」。context.read<CounterCubit>() 就是 Day 8 那個 read,語意完全一致:我只是要呼叫方法,不想訂閱。emit(state + 1) ≒ notifyListeners(),差別在 Cubit 逼你把新值交出來,不能就地改。完整版 Bloc 則把「事件」升格為一等公民:UI 只能 add 事件,狀態轉移全部集中在 handler 裡。而它真正比 Vuex 多出來的東西,藏在事件類別的宣告方式——sealed class(子類別只能定義在同一個檔案,所以編譯器知道全部可能值):
sealed class CounterEvent {}
class Incremented extends CounterEvent {}
class Reset extends CounterEvent {}
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<Incremented>((event, emit) => emit(state + 1));
on<Reset>((event, emit) => emit(0));
}
}
// UI 端:
// context.read<CounterBloc>().add(Incremented());
// BlocBuilder<CounterBloc, int>(
// builder: (context, count) => Text('$count'),
// )
跟上面那個 Vue reducer composable 對照:add ≒ dispatch、on<Event> ≒ reducer 的 case、BlocBuilder ≒ template 讀 state。形狀根本是同一套。
差別在兩處。第一處是那個 default: 的洞被縮小了:事件從字串升格成型別,Incremented 打錯字當場編不過,不會像 JS 那樣打錯了還安靜地掉進 default。但這裡要說得比多數教學文精確——我實測過,sealed class 的窮舉檢查只作用在 switch 上,不作用在 on<T>() 這種逐一註冊的執行期 API:我加了一個 Decremented 事件卻忘了註冊 handler,flutter analyze 照樣回我 No issues found!,要跑起來按下去才會丟 StateError: add(Decremented) was called without a registered event handler.。編譯期真正兜得起來的窮舉在 state 那一邊——用 switch expression 逐一比對狀態,Day 17 會實戰一次。第二處是生態完成度:Bloc 有官方的 BlocObserver 可以全域攔截每個事件與狀態轉移,logging、錯誤回報、時間旅行除錯都是現成的——Vuex 當年的 devtools 體驗,在 Flutter 這邊由 Bloc 陣營繼承了。
又一次回扣本系列的獨家論點:這種「狀態機的每一步都有型別、錯了會指名道姓」的特性,讓 AI agent 產出 Bloc code 的正確率高得反直覺。樣板碼多對人是成本,對 agent 根本不是——它不會累,而且它最需要的就是一個會當場指著某一行說「這裡漏了」的工具鏈,哪怕那個聲音來自執行期而非編譯期。
坑一:把 Bloc 當預設。 這是 Flutter 圈的真實文化戰爭。我的 Vue 直覺站 Pinia 那邊:計數器、表單、多數 CRUD 頁面,事件驅動純粹是稅。Bloc 官方自己都提供 Cubit 當減稅方案——從 Cubit 開始,真的需要事件可追溯性再升級 Bloc,這是連 bloclibrary.dev 文件都認可的路徑。症狀: 一個只有三個欄位的設定頁,你寫出五個 Event 類別、五個 State 類別、一個 Bloc,加起來一百多行,而它的全部功能就是把三個開關存起來。
坑二:以為 Vue 沒有對應物就不用學。 複雜的多步驟流程(下單、KYC、播放器狀態)在 Vue 裡我們最後也是掏出 XState 或手寫狀態機。Bloc 教的東西是可攜的:當你發現 Pinia action 裡的 if 開始互相打架,就是事件驅動的入場時機——兩個生態的判準其實一致,只是預設值站在相反邊。症狀: 某個 action 裡的旗標愈加愈多(isLoading && !isSubmitting && hasPaid),改一個條件就壞掉另一條路徑,而你已經說不清楚這個頁面總共有幾種合法狀態。
坑三:BlocBuilder 不設 buildWhen。 預設每次狀態變化都重建,state 是複合物件時跟 Day 8 的 notifyListeners 同病。粒度,又是粒度——模組二講了五天,Flutter 狀態管理的所有分歧最後都繞回這題。症狀: 表單裡改一個欄位,整頁的清單跟著閃一下;打開 Flutter DevTools 的 rebuild 計數,發現一個跟這次變更完全無關的區塊也在跑 build。
順帶交代 Bloc 的地基:整套東西建立在 Dart 原生的 Stream(=一串會陸續到達的值,可以想成「會發生很多次的 Promise」)上,BlocBuilder 本質是在聽一條狀態流。Vue 人可以用 RxJS 的世界觀理解它——事件進、狀態出,中間是一條可觀察的管線。這也解釋了為什麼 Bloc 處理防抖、節流、事件併發特別順手:EventTransformer 直接掛在事件流上宣告,同樣的需求在 Vue 裡得自己在 action 外面包一層 lodash debounce,還要祈禱沒人繞過它。
選型速記(Day 19 會給完整結論):區域 UI 狀態用 setState;跨元件共享從 Riverpod 起手;事件需要留下審計軌跡、或狀態機複雜到需要窮舉檢查,才輪到 Bloc。
一句話:Bloc 就是 Vue 社群親手埋掉的 Vuex mutation 模式在 Flutter 的轉世,靠 sealed class 和 BlocObserver 活得比前世更體面——該不該用,判準跟 Vue 一樣:你的狀態變化需不需要名字。
模組二到此收工。五天下來,Vue 的 Proxy 自動追蹤對上 Flutter 的三條手動路線(setState、Riverpod、Bloc),差異全部指向同一件事:Flutter 把「誰該重建」這個決定交還給你,代價是你得自己說清楚粒度。
下一篇 Day 11〈vue-router 基礎回顧:history mode、路由守衛〉,進入模組三,換座標系對照路由——那是 Flutter Web 另一個心智翻車重災區。
如果你卡在語法
sealed / base / final)
深入原理