模組四|狀態管理(Day 15–19)
先備:Day 8(
InheritedWidget是往下傳資料的底層機制)、Day 3(我埋的那個Theme.of(context)伏筆,今天兌現)。
每個 Vue 專案都有那個時刻:props 傳到第三層,你受不了了,改用 provide/inject。在 Vue 的世界裡,這是一道逃生門——文件甚至委婉提醒你優先考慮 Pinia。但在 Flutter 的世界裡,同一個模式叫 InheritedWidget,而它不是逃生門,是地基:Theme、MediaQuery、Navigator、provider 套件、Riverpod 的 scope,全部蓋在它上面。同一個模式,一邊是備案、一邊是基礎建設,這個地位落差是今天的主題。
結論先講:
provide/inject 在 Vue 是逃生門,InheritedWidget 在 Flutter 是地基。
但今天最有意思的不是這個對照,是我自己的專案打了這個結論一巴掌——那條地基我建好了,然後一次都沒走上去。
provide/inject 的合約很簡單:祖先 provide 一個值,任意深的後代 inject 它,中間層不用知情。搭配 InjectionKey 和 readonly 是比較講究的寫法:
// keys.js —— 用 Symbol 當 key,TS 下可帶型別 InjectionKey<Theme>
export const themeKey = Symbol('theme')
<script setup>
// 祖先元件
import { provide, reactive, readonly } from 'vue'
import { themeKey } from './keys'
const theme = reactive({ mode: 'dark', accent: '#42b883' })
provide(themeKey, readonly(theme)) // 唯讀下發,變更權留在提供者手上
function toggleMode() {
theme.mode = theme.mode === 'dark' ? 'light' : 'dark'
}
</script>
<script setup>
// 任意深的後代
import { inject } from 'vue'
import { themeKey } from './keys'
const theme = inject(themeKey) // 沒提供時回 undefined,runtime 才知道
</script>
因為 provide 的是 reactive 物件,theme.mode 一變,所有用到它的後代自動更新——依賴追蹤又一次默默把事做完。
InheritedWidget 做同一件事,但每個環節都攤開在檯面上:
class ThemeScope extends InheritedWidget {
const ThemeScope({
super.key,
required this.theme,
required super.child,
});
final AppTheme theme;
// 慣例:提供 of() 靜態方法給後代取用
static AppTheme of(BuildContext context) {
final scope =
context.dependOnInheritedWidgetOfExactType<ThemeScope>();
assert(scope != null, 'ThemeScope not found in widget tree');
return scope!.theme;
}
// 更新時要不要通知依賴者?你說了算
@override
bool updateShouldNotify(ThemeScope oldWidget) =>
theme != oldWidget.theme;
}
// 任意深的後代
final theme = ThemeScope.of(context);
Text('hello', style: TextStyle(color: theme.accent));
呼叫 dependOnInheritedWidgetOfExactType 的當下,這個後代就被登記為依賴者;updateShouldNotify 回 true 時,只有依賴者被標記重建,中間層不動。也就是說,Vue 依賴追蹤自動做的精準更新,這裡由兩個顯式 API 合力完成——你多寫了 code,但更新邊界從黑盒變成你手上的旋鈕。
用過 Theme.of(context)、MediaQuery.of(context) 嗎?它們就是這個模式的官方實例。理解 InheritedWidget,等於同時理解了半個 Flutter 框架的資料下發機制,投資報酬率極高。
這個地基最近還在進化,方向值得 Vue 人會心一笑:MediaQuery.of(context) 訂閱的是整包 media 資訊,鍵盤彈出、亮暗切換都會觸發依賴者重建;Flutter 3.10 起官方把它拆成 MediaQuery.sizeOf、MediaQuery.platformBrightnessOf 等窄化入口,只訂閱單一切面。背後是 InheritedModel 的 aspect 機制——把「依賴整個物件」細化成「依賴某個投影」。用 Vue 的話說:框架終於學會把一顆大 reactive 物件拆成多個 computed 下發。方向一致,只是 Vue 靠依賴追蹤天生就有,Flutter 靠 API 設計一步一步補。
Day 3 我埋了一個伏筆,說 Waypoint Air 取色不走 Theme.of(context)。今天把數字給你。
先看我確實做對的部分。lib/theme/app_theme.dart:122-146 是一支正正經經的 ThemeData 工廠:
// lib/theme/app_theme.dart:122-146(節錄)
static ThemeData build() {
final base = ThemeData(
brightness: Brightness.dark,
useMaterial3: true,
// `body { background-color: hsl(220, 15%, 10%) }`
scaffoldBackgroundColor: AppColors.slate950,
colorScheme: const ColorScheme.dark(
primary: AppColors.emeraldGreen,
secondary: AppColors.vibrantOrange,
surface: AppColors.slate900,
),
);
return base.copyWith(
textTheme: GoogleFonts.interTextTheme(base.textTheme).apply(
bodyColor: AppColors.gray100,
),
);
}
注意那幾行註解——它們直接把對應的 CSS 抄在旁邊,這是整個移植專案的習慣。而 main.dart:20 也老老實實把它注入了:theme: AppTheme.build()。
到這裡為止,教科書該做的我都做了:色票集中、主題注入 MaterialApp、InheritedWidget 那條下發管線建立完成。
然後是實測數字(2026-08-06 跑於 lib/):
grep -ro 'Theme\.of(' lib | wc -l # 0
grep -ro 'AppColors\.' lib | wc -l # 246
零次。 全專案沒有任何一個地方透過 Theme.of(context) 取值,246 個取色點全部直接讀靜態常數。
管線建好了,沒有人走。而我不是不知道正解——我在 app_theme.dart 裡把 ThemeData 建得好好的,卻在每一個 widget 裡寫 AppColors.emeraldGreen。
為什麼?因為我搬的是 Tailwind 的心智:bg-emerald-600 就是一個名字對一個顏色,不經過任何查詢。AppColors.emeraldGreen 是同一件事的 Dart 版,寫起來手感一模一樣。而 Theme.of(context).colorScheme.primary 要多打三十個字,還要求你先想清楚「這個顏色在語意上算 primary 還是 secondary」。
代價很具體:哪天要加淺色模式,我得改 246 個引用點;而如果當初走 Theme.of,我只要改 AppTheme.build() 裡的一個 ColorScheme。
我把這件事留在原地沒改,因為它比任何正面示範都說明問題:這條地基之所以在實務上常被繞過,不是因為大家不懂它,是因為靜態常數在絕大多數情況下真的比較好寫。 主題查詢的價值要等到「有第二套主題」那天才兌現,而多數專案永遠等不到那天——所以那筆投資看起來永遠不划算,直到它突然變得很划算。
坑一:失敗模式不同。 Vue 的 inject 拿不到回 undefined,錯誤在後續使用時才炸,離案發現場很遠;Dart 這邊 null safety 逼你當場面對 null——用 ! 就寫 assert 講清楚,或提供 maybeOf 給合法的「可能沒有」場景。同一類錯誤,Vue 是 runtime 考古題,Dart 是編譯期填空題。agent 寫錯時,後者的修正訊號快得多——獨家論點又 +1。症狀:Vue 那邊 inject 拿到 undefined,畫面上是某個地方顯示 undefined 或整塊空白,你得往上找三層才知道是誰忘了 provide。
坑二:InheritedWidget 本身是不可變的。 它只負責「下發+通知」,不負責「持有可變狀態」。要做到 Vue 那種「provide 一個 reactive 物件」,得在上面套一個 StatefulWidget,setState 時重建 InheritedWidget——這坨樣板正是 provider 套件替你包掉的東西。所以實務上你幾乎不會手寫 InheritedWidget,但不懂它,就看不懂 provider/Riverpod 的文件在講什麼。症狀:你照著文件手寫了一個 InheritedWidget,值改了畫面卻不動——因為你改的是它持有的那個物件的內容,而 InheritedWidget 本身沒有被重建,updateShouldNotify 根本沒被呼叫。
坑三:of(context) 的 context 作用域。 在 dialog、新 route 裡呼叫 of(context),那個 context 可能已經不在你的 scope 底下,直接 assert 炸掉。Vue 的 inject 沿元件樹解析,portal/Teleport 也不太踩雷;Flutter 的 context 就是 widget tree 上的位置,跨樹就是拿不到,沒有商量。修法固定兩招:進 dialog 前先在外層 context 把值取好、用參數帶進去;或接受「dialog 是新的子樹」的世界觀,把 scope 提供到 Navigator 之上(provider 套件的慣用解法;Riverpod 的 ProviderScope 天生蓋在 route 之外,所以免疫)。agent 踩這個坑的機率極高——錯誤只在打開 dialog 那一刻才炸,靜態分析看不出來,規格裡直接載明「dialog 內禁用 of(context) 取 scope」最省事。症狀:整個 App 跑得好好的,使用者點開某個確認視窗,畫面直接紅屏,訊息說在 widget tree 上找不到你的 scope。
坑四:地位差異的實務後果。 Vue 人會把 provide/inject 當「小範圍偷懶」用;在 Flutter 請倒過來想——scope 化的下發是常態,全域單例才是例外。元件庫、多主題、多租戶場景,InheritedWidget 式的分層下發是正解,硬套一顆全域 store 反而彆扭。症狀:你把主題塞進一顆全域 store,然後發現「同一頁裡讓某個區塊用另一套配色」這個需求做不出來,因為全域只有一份。
一句話:provide/inject 在 Vue 是逃生門、InheritedWidget 在 Flutter 是地基,同一個模式的地位差異決定了兩邊完全不同的使用直覺。明天 Day 19〈狀態管理選型結論:什麼情境選什麼〉,模組四收官——setState、Riverpod、Bloc 全部上桌,給出可辯護的決策樹。
如果你卡在語法
深入原理
InheritedWidget
InheritedModel(aspect 機制)