模組二|響應式對照(Day 6–10)
先備:Day 6(依賴追蹤)、Day 7(setState 與 rebuild 粒度)。第一次讀可跳過本篇 Flutter code 的細節,先看「差異與坑」。
Day 7 結尾那句「傳三層你就會受不了」不是修辭,是我實際做過的事:中間兩層 widget 完全用不到那個 callback,卻得逐層宣告、逐層轉發,改一個參數名要動四個檔案。Flutter 社群對這件事的標準答案是一行指令 flutter pub add provider——套件名字跟 Vue 的 provide 撞得一模一樣。而我第一次把它跑起來,畫面上迎接我的是這行紅字:Error: Could not find the correct Provider<CartModel> above this CartBadge Widget。
結論先講:Provider 把 Vue provide/inject 的跨層注入補上了,查找還從字串升級成型別;但「改了就自動更新」那一半,帳單照樣要你自己付。
provide/inject 解決的是「跨層級傳遞」:祖先提供、任意深度的後代注入,中間層完全不用經手。這段要示範的是一個購物車——祖先建立一個 reactive 的 cart 物件並提供出去,後代只要拿件數。
// 祖先元件的 script setup 區塊
import { provide, reactive } from 'vue'
const cart = reactive({ items: [], get count() { return this.items.length } })
provide('cart', cart)
後代把同一份 cart 拿回來。這一段的重點在最後兩行註解:我沒有寫任何訂閱程式碼,響應性是白送的。
// 任意深度的後代,同樣是 script setup 區塊
import { inject } from 'vue'
const cart = inject('cart')
// template 讀 cart.count,items 一變自動更新,
// 不用訂閱、不用通知,Day 6 的 Proxy 追蹤全程代勞
注意兩件事,等下對照用:注入是靠字串或 Symbol key 查找(打錯字執行期才知道,而且只是靜靜拿到 undefined);更新傳播是自動的(讀了就被追蹤)。
先講一件會影響你怎麼讀這一篇的事:Waypoint Air 實際用的是 Riverpod 3——lib/store/booking_store.dart:175 那三行 NotifierProvider 才是 demo 的真實狀態層。本篇講 provider 套件是為了理解演進脈絡,Riverpod 的每一個設計都是在回應 provider 的某個痛點;跳過這一站,明天那些設計會看起來像沒來由的潔癖。選型理由見 Day 19。
Flutter 原生就有跨層級注入的機制,叫 InheritedWidget(往下傳資料的底層機制,Day 18 會拆開講)。它能做到的事跟 provide 一樣,但裸寫的樣板碼多到勸退,所以官方文件直接推薦社群套件 provider——它就是 InheritedWidget 的人體工學包裝。
慣用組合是三件套:ChangeNotifier(可被監聽的模型)、ChangeNotifierProvider(掛到樹上)、context.watch(訂閱)。下面這段要解決的問題跟 Vue 那邊完全相同:購物車模型放在樹的上游,深處的徽章顯示件數,按鈕加一筆。flutter pub add provider 之後整段貼進 lib/main.dart 就能跑:
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CartModel extends ChangeNotifier {
final List<String> _items = [];
int get count => _items.length;
void add(String item) {
_items.add(item);
notifyListeners(); // 忘了這行,全世界都不會知道你變了
}
}
void main() => runApp(
ChangeNotifierProvider(
create: (_) => CartModel(),
child: const MaterialApp(home: Scaffold(body: Center(child: CartBadge()))),
),
);
class CartBadge extends StatelessWidget {
const CartBadge({super.key});
@override
Widget build(BuildContext context) {
final cart = context.watch<CartModel>(); // 訂閱:cart 通知時本 widget 重建
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('購物車:${cart.count}'),
// 只呼叫方法、不需要跟著重繪的地方用 read
ElevatedButton(
onPressed: () => context.read<CartModel>().add('foo'),
child: const Text('加一件'),
),
],
);
}
}
逐行對照 Vue 那兩段:
reactive({ items: [] })。差別在第 10 行:_items.add() 改完之後,得自己喊一聲 notifyListeners(),沒有 Proxy 幫你在 setter 上攔截。provide('cart', cart)。差別在於它本身是一顆 widget,掛在樹上,而掛的位置決定了可見範圍——這正是開場那行紅字的成因。inject('cart')。這裡的尖括號是泛型方法 <T>()(尖括號裡是型別參數,告訴編譯器你要拿哪一種資料),查找靠它,不靠字串。context.read<CartModel>() 拿的是同一顆模型,但明講「我不訂閱」。watch 與 read 的取捨是下一段第一個坑。這裡就是本系列獨家論點最乾淨的一次現身:watch<CartModel>() 在編譯期綁定型別,agent 把模型名字寫錯、或者拿了一個根本沒掛上去的型別,flutter analyze 當場攔下來;Vue 的 inject('cart') 字串打成 inject('crat'),agent 產出的 code 照樣通過 lint、照樣跑起來,錯誤要等到有人點開那個頁面才現形。
實務上 app 不會只有一個模型,MultiProvider 讓你把一疊 provider 攤平掛在根部,免去巢狀縮排地獄。另一個常被忽略的細節是兩種建構方式:create: (_) => CartModel() 由 provider 負責建立與銷毀(dispose 自動呼叫),ChangeNotifierProvider.value 則只是轉發現成物件、生命週期你自己管。方向用錯,輕則物件洩漏,重則對著已 dispose 的模型呼叫 notifyListeners 直接丟例外——這個坑嚴重到官方文件用整段粗體警告。
坑一:watch vs read 用錯位置。watch 只能在 build 裡用(它要登記訂閱關係),寫在事件 handler 裡會炸;反過來,onPressed 裡用 watch 雖然某些寫法不炸,卻埋下多餘重建。規則背起來:要跟著變就 watch、只是叫它做事就 read。這是 code review Flutter 新手(和 agent 初稿)最常抓到的錯,沒有之一。症狀:onPressed 裡寫了 watch,debug 模式丟出一長串 Tried to listen to a value exposed with provider, from outside of the widget tree,訊息長到你會先以為是套件壞了。
坑二:忘了 notifyListeners()。Day 6、Day 7 的老朋友又來了:資料改了、畫面不動、零錯誤。Vue 的 Proxy 在 setter 攔截時自動 trigger,ChangeNotifier 的 setter 是你寫的普通 Dart 方法,通知這件事永遠是你的責任。症狀:按鈕按十下,print(cart.count) 老實印出 10,畫面上的數字從頭到尾停在 0。
坑三:ProviderNotFoundException。Provider 是沿著 widget 樹往上找的,掛的位置比用的位置低、或根本忘了掛,執行期直接丟例外——也就是開場那一行紅字。比 Vue inject 拿到 undefined 的靜默失敗好一點,至少它會大聲死給你看,而且錯誤訊息會直接點名是哪個 widget 找不到哪個型別。症狀:把 ChangeNotifierProvider 包在 MaterialApp 裡面而不是外面,home 底下的頁面全部拿不到模型,整個 App 開場就紅屏。
坑四:通知粒度是「整顆模型」。notifyListeners() 沒有參數,不管你改了哪個欄位,所有聽眾全部重建。模型一大,這就是效能陷阱;套件給的解法是 Selector 或 context.select((CartModel c) => c.count) 圈出「只聽這個欄位」,但那是又一層要手動設計的粒度。對照 Day 6:Vue 的 Proxy 幫你做到欄位級追蹤,還不用寫一個字,在這裡顯得格外奢侈。症狀:改了模型裡一個跟畫面無關的欄位(例如 lastSyncedAt),DevTools 的 Performance 面板上整頁 widget 一起閃紅。
那它像 Pinia 嗎? 有 Vue 同事這樣問過我。不像——Pinia 的 store 是全域單例,不掛在元件樹上;Provider 的模型住在樹裡,掛的位置決定可見範圍。「掛在樹上」讓區域狀態(例如單一頁面的表單模型)有天然的生命週期,隨頁面掛載、隨頁面銷毀;代價是全域狀態得一路掛到根部才安心。Flutter 生態對標 Pinia 的是明天的 Riverpod:provider 定義搬出元件樹、依賴追蹤自動化,那兩個擺在一起才叫像。把 Provider 當成「Flutter 版 provide/inject」是準的,當成「Flutter 版 Pinia」則會期待落空。
這一篇讓我對 Flutter Web 的懷疑減少了一格,理由很窄:context.watch<CartModel>() 的型別在編譯期綁死、找不到 provider 會在執行期指名道姓地爆炸——我在 Vue 那邊靠字串 inject 拿到 undefined、再往下滲三層才炸的那一整類 bug,在這裡直接絕種。懷疑沒有歸零:手動 notifyListeners() 換來的靜默失敗仍在,只是換了一種形狀,而且這一種 analyzer 抓不到。
一句話:Provider 把 Vue provide/inject 的「跨層注入」用型別安全的方式補上了,但「改了就自動更新」仍然要靠手動 notify + 手動 watch 兩頭湊。
下一篇 Day 9〈Riverpod vs Pinia:對照著手,Provider 語法 vs 組合式風格〉——Flutter 生態把依賴追蹤真正撿回來的那個方案,也是 Waypoint Air 最後選定的那一個,跟 Pinia 擺在一起看驚人地像。
如果你卡在語法
深入原理
Selector 與 context.select
InheritedWidget