iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Modern Web

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

Day 7|Flutter setState:最原始的重繪機制與痛點

  • 分享至 

  • xImage
  •  

模組二|響應式對照(Day 6–10)

先備:Day 2(widget tree 與第一段 Dart 語法)、Day 6(Vue 的 track/trigger)。第一次讀可跳過本篇 Flutter code 的細節,先看「差異與坑」。

我在 Flutter 寫的第一個計數器,_count++ 改了、畫面不動、console 一片乾淨,我瞪著螢幕十分鐘才想起來要包 setState。後來我數了自己那支 Waypoint Air demo:整個專案的狀態管理早就交給 Riverpod 3,setState 還是出現 48 次、散在 15 個檔案裡(grep -ro setState lib | wc -l)。寫 Vue 十年,我從來不需要告訴框架「這裡要重畫」;Flutter 的第一課就是:要,而且每次都要。

結論先講:setState 不追蹤依賴,它只把當前 element 標記為 dirty,下一個畫面節拍才重跑整支 build()。粒度就是「這顆 State 的整個 build」。

Vue 怎麼做

先把對照組擺出來,用全系列最小的例子:一個計數器,按鈕加一,畫面顯示按了幾下。這裡要觀察的不是語法,是「狀態變了之後,誰負責決定畫面哪裡要動」。

script 區塊只有三行——宣告一個 ref,再給一個把它加一的函式:

import { ref } from 'vue'

const count = ref(0)
function increment() {
  count.value++   // 改完就結束,剩下的 Vue 自己處理
}

template 就是把 count 印出來,按鈕接上 increment

<template>
  <p>你按了 {{ count }} 下</p>
  <button @click="increment">+1</button>
</template>

count.value++ 之後發生的事,Day 6 拆過:trigger 找出讀過 count 的 render effect,排進更新佇列。真正省事的其實是編譯期那一半——Vue 3 的 template 編譯器早就分析出「這棵 DOM 只有 {{ count }} 這個文字節點會變」,patch 的時候帶著 patch flag 直奔那個節點。更新範圍是編譯期算好的、精確到節點,所以「哪裡要重畫」這個問題根本不會浮現在你腦子裡。

Flutter Web 怎麼做

先補地基:Widget、Element、RenderObject 三棵樹

Flutter 的每一次畫面更新都同時牽動三棵樹。Vue 沒有完全對應的東西,但拆開來一點都不難:

  • widget 樹:你寫的那些 TextColumnPadding,全是不可變的輕量設定物件,講白了就是設計圖。每次重建都整批 new 一份新的,用完就丟。
  • element 樹:框架實際掛在樹上的實例。一個 Element(widget 是設計圖,element 是框架照著圖掛上去的那個實例)對應 widget 樹上的一個位置,而且活得比 widget 久——同一個位置的 widget 換過十次,element 可能從頭到尾都是同一顆。
  • RenderObject 樹:負責量測(layout)與繪製(paint)的那一層,最貴,所以框架最想少碰它。

串起來就是:你丟出新的設計圖,element 拿新舊兩張圖比對,只有真的變了的部分才往下通知 RenderObject 重新量測或重畫。這也解釋了狀態該掛在哪——設計圖每幀都在換,狀態不能跟著一起被丟掉,所以它掛在 element 那一側。

有了三棵樹,Flutter 那個「為什麼要寫兩個 class」的怪設計就有答案了。StatefulWidget(會持有狀態的 widget,本身仍然不可變,每次重建都是新的一顆)只負責交代一件事:我的狀態物件怎麼生;狀態本體住在 State<T>(跟著 element 活的狀態容器,widget 重建時不會被丟掉)裡面。要是狀態也住在 widget 上,你按一下按鈕、下一幀計數就歸零了——拆成兩個 class 是為了讓兩者的壽命脫鉤:設計圖歸設計圖,狀態掛在活得比較久的那一側。(Day 5 提過 StatefulWidget 但沒展開,就是這件事。)

這段解決的問題:把上面那個 Vue 計數器一比一搬過來,而且是可以直接貼進 flutter create 出來的 lib/main.dartflutter run -d chrome 就會動的完整版。

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: CounterPage()));

class CounterPage extends StatefulWidget {
  const CounterPage({super.key});

  @override
  State<CounterPage> createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  int _count = 0;

  void _increment() {
    setState(() => _count++);   // 改在 setState 外面?資料照樣會變,畫面不會
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text('你按了 $_count 下'),
          ElevatedButton(onPressed: _increment, child: const Text('+1')),
        ],
      ),
    );
  }
}

逐行導覽:

  • 第 5–10 行:CounterPage 是設計圖,createState() 只交代狀態物件怎麼生,它自己一個欄位都不存。
  • 第 12 行:_CounterPageState_ 前綴(Dart 的 library-private:底線開頭只有同檔案看得到),_count_increment 同理,Dart 沒有 private 關鍵字。
  • 第 15–17 行:setState 收一個 closure,你在裡面改欄位;第 19–31 行的 build() 每次都把整棵 subtree 從頭 new 一遍,讀到 _count 就產生新的設計圖。

setState 本人做的事少得驚人:執行你給的 closure,然後呼叫 markNeedsBuild()——把這個 element 標記為 dirty,排進框架的髒清單。真正的重建發生在下一個 vsync(螢幕更新的節拍,60Hz 就是每 16.7ms 一次):框架掃過髒清單,把每一顆髒 element 的 build() 重跑一次。沒有 track、沒有 trigger,粒度就是「這顆 State 的整支 build 方法」。Vue 人第一反應通常是「整支 build 重跑?瘋了吧」,這裡要幫 Flutter 說句公道話:重跑 build 產生的是一批輕量設計圖物件,element 樹會拿新舊 widget 逐一比對(型別與 key 相同就複用同一顆 element),底下的 RenderObject 只有真的變了才重新 layout/paint。概念上很接近 virtual DOM:重跑 render 便宜,真正碰畫面的那層有緩衝。但便宜不等於免費——build 裡塞同步重運算、或每幀 new 出大物件,成本會實打實累積,而且 Flutter 不會警告你。

差異與坑

先講一件我改寫這篇時才確認的事。Waypoint Air 的乘客人數頁要解決的問題是:使用者在頁面上調人數、選艙等,但按下確認之前都不該污染全域 store。Vue 端的解法是兩個純本地的 ref

// PassengerView.vue:15-16
const tempPassengers = ref(store.passengers)
const tempCabin = ref(store.cabinClass)

Flutter 端面對同一個問題、選了同一個層級的解法——即使這支 App 的全域狀態早就交給 Riverpod 3,頁面級的草稿值還是走 setState

// passenger_view.dart:24-44(節錄)
class _PassengerViewState extends ConsumerState<PassengerView> {
  late int _tempPassengers;                      // 頁面級草稿,不進全域 store

  @override
  void initState() {
    super.initState();
    final booking = ref.read(bookingProvider);   // 從 Riverpod 抄一份現值當草稿
    _tempPassengers = booking.passengers;
  }

  void _increment() {
    if (_tempPassengers < 9) setState(() => _tempPassengers++);   // 只驚動這一頁
  }
}

這段真實 code 有四個東西是你目前還沒見過的,先各給一句就好,別停下來查:

  • ConsumerState<T> = Riverpod 版的 State<T>,多附一個 ref 取水口,Day 9 才正式登場。
  • ref.read(...) = Riverpod 的取值方式,跟 Day 8 的 context.read 同一個語意(只取一次、不訂閱),差別是它不靠 widget 樹。
  • late(宣告時先不給值,但保證第一次讀取前一定會指派)——JS 沒有對應物,它是拿來哄編譯器的:_tempPassengersint 不能是 null,可是真正的初值要等 initState 才拿得到。
  • initState() = Vue 的 onMounted,widget 掛上樹時跑一次;第一行的 super.initState() 是規矩,漏了框架自己的初始化不會跑。

按下確認才把草稿寫回 Riverpod。同樣的分工也出現在 language_settings_view.dart:89(選語言)和 home_view.dart:57(顯示搜尋錯誤)。全專案 48 次、15 個檔案——setState 是常駐工具,不是入門教材裡用完就丟的玩具。以下五個坑,我全部踩過。

坑一:忘了包 setState_count++ 直接寫,資料改了、畫面不動、零錯誤訊息,跟 Day 6 那句「沒有人在聽」是同一種靜默失敗,只是這次沒人在聽的原因換成「沒人標髒」。症狀:點按鈕完全沒反應,但你在 debugger 停下來看變數,值已經是新的了——所以你會先去懷疑按鈕沒接上。

坑二:await 之後的 setState。await 回來時,widget 可能已經被移出樹了,對著死掉的 State 呼叫 setState 會直接丟例外。標準解法是在 await 之後、setState 之前插一行 if (!mounted) return;。這條不是教科書潔癖,home_view.dart:60 就有一模一樣的一行:那裡用 Timer 延遲清掉搜尋錯誤訊息,使用者在計時器燒完前跳頁,mounted 就是唯一的保險。Vue 沒有這個問題,元件卸載後 effect 自動停止,那是自動訂閱送的贈品;來到手動世界就得自己付這筆帳。症狀:setState() called after dispose() 的紅字堆滿 console,通常只在切頁切很快或網路很慢的時候才重現。

坑三:state 放太高。粒度既然是整支 build,把狀態放在頁面頂層,點一下按鈕就等於整頁重建。Flutter 的效能守則因此跟 Vue 相反:Vue 幾乎不用想更新範圍,Flutter 要刻意把 setState 往葉子推,把不會變的 subtree 抽成 const widget(const 那顆連 new 都省,element 直接複用)。好消息是這件事可以量化:DevTools 的 Performance 面板會標出每一幀有哪些 widget 重建,抓「誰被無辜重建」比在 Vue devtools 裡追 render 原因直觀得多。症狀:功能都對,但滑動或打字時掉幀,Performance 面板一開,一幀跑掉幾百顆 widget 的 build。

坑四:在 build 裡呼叫 setState。Vue 也有類似禁忌——render 過程改 reactive state 會觸發無限更新迴圈,等 devtools 警告你的時候畫面通常已經卡住了。Flutter 的態度更直接:debug 模式當場丟 assertion。build 必須是純函式:只讀 state、回傳 widget、不做副作用。紀律跟 Vue 的 render function 一模一樣,差別在 Flutter 把錯誤糊你臉上。症狀:setState() or markNeedsBuild() called during build. 一整面紅底黃字,畫面停在那一幀不動。

坑五:跨元件共享setState 只管自己這顆 State。兩個兄弟元件要共享狀態,唯一的路是把 state 提升到共同祖先,再用建構子往下傳、callback 往上傳(Day 5 那條「props down, events up」的憲法原封不動)——React 人熟悉的 lifting state up,Vue 人則會想起沒有 provide/inject 可用的黑暗時代。傳個三層你就會受不了,而這個「受不了」正是明天主角存在的理由。症狀:同一個 callback 在三個中介 widget 的建構子裡原封不動轉發,改一個參數要動四個檔案。

回扣本系列的 agent 論點:忘了包 setState 是執行期靜默失敗,型別系統救不了——但它的錯誤模式極其固定,我讓 AI agent 寫 Flutter 時,只要在 prompt 裡立一條規矩,或事後把「畫面沒更新」這個觀察餵回去,修正率幾乎是百分之百。反過來,setState 周邊那些會編譯不過的錯(欄位名打錯、State<T> 的型別參數對不上、_ 前綴的東西被跨檔案存取),agent 在 flutter analyze 那關就自己收斂了。強型別的具體價值在這裡:它把 agent 的錯誤面積壓縮到只剩語意層那一小塊。

小結與下一篇

一句話:setState 把 Vue 幫你自動做掉的「找出誰該更新」變成你的手動責任——標髒、等節拍、重跑整支 build,小元件夠用,一跨元件就開始傳 callback 傳到懷疑人生。

下一篇 Day 8〈Provider:依賴注入式狀態管理初探〉——Flutter 社群對「傳三層」的標準答案,名字跟 Vue 的 provide 像到不能再像。

參考資料

如果你卡在語法

深入原理


上一篇
Day 6|Vue reactivity 核心:ref/reactive/computed 原理
下一篇
Day 8|Provider:依賴注入式狀態管理初探
系列文
Vue 前端工程師視角看 Flutter Web9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言