iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0

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

先備:Day 10(Bloc 的 add/on 形狀、sealed class)、Day 16(不可變狀態與 copyWith)。本篇的 Dart code 是骨架示意,要跑的完整版在 Day 10。

Vue 生態有個有趣的空缺:我們沒有一套「官方感」的事件驅動狀態機。最接近的是 XState,但老實說,我十年來待過的 Vue 團隊沒有一個真的把它用進生產環境——多數人用一個 status 字串加自律撐過去。Flutter 那邊卻相反:Bloc 是三大主流方案之一,金融、大型團隊幾乎預設用它。同一個模式,兩個生態的採用率天差地遠,為什麼?我拿一個最日常的場景——表單送出——實戰對照一次,答案會自己浮出來。

結論先講:

Bloc 的樣板稅在人寫時很貴,在 agent 寫時接近零。

這句話就是採用率之謎的答案,也是本系列獨家論點目前最強的一個案例。

同樣要先交代落差:Waypoint Air 沒有用 Bloc(Day 10 說過理由,我的 side project 撐不起這個樣板成本),所以本篇的 code 是我為了把狀態機講清楚另外寫的最小範例,不是從 demo 挖出來的。

Vue 怎麼做

Vue 的典型寫法:一個 status ref 當狀態、幾個 async function 當轉移,狀態機存在於工程師的腦中,不存在於型別系統:

const status = ref('idle') // idle | loading | success | error
const errorMsg = ref('')

async function submit(payload) {
  if (status.value === 'loading') return // 防連點,靠自律
  status.value = 'loading'
  try {
    await api.submit(payload)
    status.value = 'success'
  } catch (e) {
    status.value = 'error'
    errorMsg.value = e.message
  }
}

這段 code 能跑、也夠用,但有三個隱形債:status 是裸字串,打錯字('sucess')沒人攔;「哪些轉移是合法的」只寫在註解和腦袋裡;errorMsgstatus === 'error' 的綁定關係靠默契——successerrorMsg 殘留舊值是經典 bug。專案小的時候這些都不是事,狀態一多、轉移一複雜,就開始咬人。

Flutter Web 怎麼做

Bloc 的世界觀:UI 丟事件(Event)進來,Bloc 吐狀態(State)出去,中間的轉移邏輯全部集中在一處。配上 Dart 3 的 sealed class,狀態機從「腦中的圖」變成「編譯器看得懂的型別」:

這一段與下面兩段是骨架示意,直接複製編譯不過——apiPayloadSubmitButton 都是我為了聚焦狀態機而省略的東西。想要可貼上就跑的完整範例,回頭看 Day 10 那支 Cubit 計數器。

// 狀態:sealed class,窮舉性由編譯器保證
sealed class SubmitState {}
class SubmitIdle extends SubmitState {}
class SubmitLoading extends SubmitState {}
class SubmitSuccess extends SubmitState {}
class SubmitFailure extends SubmitState {
  final String message; // 錯誤訊息只活在 Failure 態,不會殘留
  SubmitFailure(this.message);
}

// 事件
sealed class SubmitEvent {}
class SubmitPressed extends SubmitEvent {
  final Payload payload;
  SubmitPressed(this.payload);
}

class SubmitBloc extends Bloc<SubmitEvent, SubmitState> {
  SubmitBloc() : super(SubmitIdle()) {
    on<SubmitPressed>((event, emit) async {
      if (state is SubmitLoading) return;
      emit(SubmitLoading());
      try {
        await api.submit(event.payload);
        emit(SubmitSuccess());
      } catch (e) {
        emit(SubmitFailure(e.toString()));
      }
    });
  }
}

UI 端用 switch expression(有回傳值的 switch,JS 沒有;配 sealed class 就能做窮舉檢查)消費狀態。下面這四行是全篇的重點,也是全系列語法密度最高的一段,等一下我會逐行拆:

BlocBuilder<SubmitBloc, SubmitState>(
  builder: (context, state) => switch (state) {
    SubmitIdle() => SubmitButton(onPressed: _onSubmit),
    SubmitLoading() => const CircularProgressIndicator(),
    SubmitSuccess() => const SuccessBanner(),
    SubmitFailure(:final message) => ErrorBanner(message),
  },
)

先把最後一行拆開,因為它一行塞了三個 Dart 3 的新東西:

SubmitFailure(:final message) => ErrorBanner(message),
  • SubmitFailure(...) 在這裡不是在呼叫建構子,是一個「型別模式」:意思是「如果 state 這個東西的型別是 SubmitFailure」。
  • (:final message) 是物件模式解構(比對到這個型別時,順手把 message 欄位取出來綁成同名變數)。冒號開頭的寫法是簡寫,完整版是 (message: final message)——因為欄位名和你想要的變數名一樣,所以左邊省掉。
  • => 右邊就是這個分支要回傳的 widget,message 在這裡已經是一個 String,型別由編譯器推出來,不用轉型、不用 !

翻成 JS 大概是 if (state instanceof SubmitFailure) { const { message } = state; return ErrorBanner(message) }——三行變一行,代價是你得先認得這個語法。

sealed class + switch expression = 編譯期窮舉檢查。哪天需求加一個 SubmitRetrying 狀態,全專案每一個 switch 這個狀態的地方同時編譯失敗,錯誤清單就是待辦清單。Vue 那邊加一個 status 值,是靠全文搜尋 'error' 字串祈禱沒漏。

另外注意 errorMsg 的下場:它變成 SubmitFailure 的建構子參數,不可能在非錯誤態存在。「非法狀態不可表達」(make illegal states unrepresentable)這句函數式老話,Bloc + sealed class 是它在 UI 層最務實的落地。

還有一份紅利藏在測試裡。Vue 版的 status 字串狀態機,要驗轉移邏輯得掛載元件、mock API、斷言 DOM——狀態機和 UI 綁死。Bloc 的轉移是「事件進、狀態序列出」的純邏輯,bloc_test 直接斷言序列:

blocTest<SubmitBloc, SubmitState>(
  'submit 成功:Loading → Success',
  build: SubmitBloc.new,
  act: (bloc) => bloc.add(SubmitPressed(payload)),
  expect: () => [SubmitLoading(), SubmitSuccess()],
);

不用 pump widget、不用碰 DOM。對 agent 工作流這點很關鍵:狀態機規格可以直接寫成測試先行,agent 寫完自己跑、自己對答案。小字補一句:expect 比對靠 ==(Dart 的 == 預設比的是「是不是同一個物件」,不是內容相同),所以狀態類別記得配 Equatable 套件或自己覆寫相等性,不然 SubmitLoading() 永遠不等於另一個 SubmitLoading(),序列比對永遠不會過——Bloc 新手第二名錯誤,僅次於忘記註冊 handler。

差異與坑

坑一,也是採用率之謎的答案:boilerplate 成本結構。 上面 Vue 版 15 行,Dart 版 40 行外加 UI 樣板。人手寫,這個稅重到多數 Vue 團隊不願意為表單付——所以 XState 在 Vue 圈長不大。但我的工作流是 agent 寫:40 行和 15 行對 agent 是同一件事,稅率歸零,而換來的編譯期窮舉檢查讓 agent 的回饋迴圈快且確定。Bloc 的最大缺點在 agent 工作流裡直接蒸發——這是本系列獨家論點目前最強的一個案例。症狀:你在 Vue 專案裡提議導入 XState,第一個問題永遠是「所以一個表單要多寫幾行?」,然後這個提議就結束了。

坑二:不是每個狀態都值得建國。 CRUD 表單全上 Bloc 是儀式感過剩,Bloc 官方自己都提供輕量版 Cubit(去掉 Event 層,直接呼叫方法 emit 狀態)。我的線:轉移路徑 ≥3 條、或需要事件日誌可回放(審計)才上 Bloc,否則 Cubit 或昨天的 Riverpod。症狀:一個只有 loading 和 done 兩態的按鈕,你寫了兩個 Event 類別、三個 State 類別、一個 Bloc、一個 BlocProvider,然後在 code review 被問「這個為什麼不用 setState」。

坑三:事件併發。 使用者連點兩下送出,兩個事件排隊進來怎麼辦?Bloc 用 transformer 宣告策略(droppable 丟棄、restartable 重啟前一個),這是 Vue 版那行 if (status.value === 'loading') return 的型別化版本——但 agent 預設不會加,要在規格裡點名。寫起來只是一個參數的事:on<SubmitPressed>(_onSubmit, transformer: droppable())(來自官方的 bloc_concurrency 套件),處理中就丟棄後續連點。五種策略一個參數換,比 Vue 版散落各處的 if 防禦好維護——前提是你知道要加。症狀:使用者手快連點兩下送出,後端收到兩筆一模一樣的訂單,而前端從頭到尾沒有任何錯誤訊息。

坑四:Web 平台注意事項幾乎為零。 Bloc 純 Dart、不碰 platform channel,在 Flutter Web 上行為與 mobile 完全一致。狀態管理是本系列少數「Web 不另收稅」的主題。症狀:這一條沒有症狀,這就是重點——你在 mobile 教學裡學到的 Bloc 知識,搬到 Web 一個字都不用改。

小結與下一篇

一句話:Bloc 用大量樣板把狀態機交給編譯器看管,這筆稅在人寫時很貴、在 agent 寫時近乎免費——選型天平因此傾斜。明天 Day 18〈跨模組狀態共享:Vue provide/inject vs Flutter InheritedWidget〉,往下挖一層,看兩個框架的依賴注入地基。

參考資料

如果你卡在語法

深入原理


上一篇
Day 16|Riverpod Provider 種類與使用場景
系列文
Vue 前端工程師視角看 Flutter Web17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言