iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Modern Web

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

Day 6|Vue reactivity 核心:ref/reactive/computed 原理

  • 分享至 

  • xImage
  •  

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

先備:Day 2(第一段 Dart 語法、具名參數與 super.key)、Day 5(建構子就是元件的公開介面)。第一次讀可以跳過本篇 Flutter code 的細節,直接看「差異與坑」。

上週我在一個 Vue 專案裡寫下 const { qty } = reactive(cart),畫面就此凍住:vue-tsc 零錯誤、ESLint 全綠、console 一片乾淨,console.log(cart.qty) 印出來確實是新的 5。我花了四十分鐘懷疑 Pinia、懷疑 v-if、懷疑瀏覽器快取,最後才想起兇手是解構那一行。十年來我把「count.value++ 畫面就會動」當空氣,而接下來五天要去一個沒有這種空氣的地方——Flutter 沒有響應式系統,不是比較弱,是整層都不存在。所以模組二的第一篇,我要先把 Vue 這套魔法拆開,講清楚它到底自動幫我們做了什麼。座標系畫準了,之後看 setState、Provider、Riverpod、Bloc,才知道每一套各補了哪個洞。

結論先講:Vue 的響應式是「你讀了什麼,框架就記下什麼」,依賴關係在執行期自動收集;Flutter 沒有這一層,你得親手把要更新的範圍圈出來,圈漏了不會報錯。

Vue 怎麼做

https://ithelp.ithome.com.tw/upload/images/20260811/20183479IrS2I4ckYZ.png

在 Vue 這邊,誰讀了什麼我自動牽好線,改一下全體通知。

Vue 3 的響應式核心只有三個角色:可追蹤的資料refreactive)、衍生值computed)、訂閱者(effect,元件的 render function 就是一個 effect)。下面這段要證明的事情是:兩個會變的數字、一個由它們算出來的小計、一段「小計變了就印一次」的副作用——全程沒有任何一行程式碼告訴 Vue「誰依賴誰」。

import { ref, computed, watchEffect } from 'vue'

const price = ref(100)
const qty = ref(2)
const total = computed(() => price.value * qty.value)

watchEffect(() => {
  console.log(`小計:${total.value}`)
})

qty.value = 3 // console 自動印出「小計:300」

依賴關係是在讀取的瞬間建立的。reactive 把物件包進 Proxy(ES6 API,可以攔截物件的讀寫,Vue 3 的響應式就靠它)——你以為直接在讀 cart.qty,其實中間站著一個攔截器:它在 get 被觸發時做 track(記下「現在正在跑的 effect 讀了我」),在 set 被觸發時做 trigger(把記下來的 effect 全部重跑)。ref 是同一件事的單值版,.value 的 getter/setter 就是那對攔截點,所以那四個字不能省;computed 則是帶快取的懶惰 effect——依賴沒變就回舊值,變了也要等下次真的被讀才重算。三個關鍵性質請記牢,後面四天都要拿來對照:

  1. 自動:依賴關係在執行期靠「誰讀了誰」收集,不用宣告、不用註冊。
  2. 細粒度:更新只波及真正讀過該資料的 effect,其他元件毫髮無傷。
  3. 執行期:這是把雙面刃——追蹤不到的時候它不會報錯,只是安靜地不追蹤。而第 3 點就是我開場那四十分鐘的成本。

同一份資料,讀法不同,一個活著、一個死了。這段要示範的就是那條分水嶺:

const cart = reactive({ qty: 2 })
const { qty } = cart // ❌ 這一行就是兇手
watchEffect(() => console.log('壞掉的:', qty))   // 只印一次 2,之後永遠沉默
watchEffect(() => console.log('好的:', cart.qty)) // 每次都重跑

cart.qty = 5

原因不在 reactive 少做了什麼,在於 Proxy 攔的是「對 cart 這個物件的存取動作」。cart.qty 每一次求值都會經過 get 攔截器,所以每次都被 track 到;而 const { qty } = cart 只在解構的那一瞬間走過一次 get,把數字 2 拷貝進一個普通的區域變數——之後你讀 qty,讀的是這個跟 cart 再無關係的區域變數,Proxy 當然攔不到,因為根本沒有人去碰 cart。要保住響應性只有兩條路:保留 cart.xxx 這條完整存取路徑,或用 toRefs(cart) 解構出一包 ref(交出去的是「位置」,不只是「值」)。漏寫 .value 是同一個機制的另一張臉:price 是 ref 物件、price.value 才是攔截點,讀錯了一樣安靜。

Flutter Web 怎麼做

https://ithelp.ithome.com.tw/upload/images/20260811/20183479Me4WkEaAJW.png

在 Flutter,我得親手把要聽的那塊圈起來,忘了圈就沒有人在聽。

Flutter 的世界觀同樣是 UI = f(state),但 f 的重跑沒有人幫你追蹤。你有兩條路:手動標記重繪範圍(setState,Day 7 主角),或讓資料變成「可被監聽的物件」、由 UI 明確訂閱。後者最小的原生單位是 ValueNotifier,長得意外地像 ref。下面這段要解決的問題是:把上面那個計數器原封不動搬過來,看「訂閱」被搬到了哪裡。整段完整可跑——flutter create 出來的專案,把 lib/main.dart 換掉就會動。

import 'package:flutter/material.dart';

final counter = ValueNotifier<int>(0); // = const counter = ref(0)

void main() => runApp(const DemoApp());

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ValueListenableBuilder<int>(
              valueListenable: counter,
              builder: (context, value, _) => Text('count: $value'),
            ),
            ElevatedButton(
              onPressed: () => counter.value++, // = count.value++
              child: const Text('+1'),
            ),
          ],
        ),
      ),
    );
  }
}
  • 第 3 行 ValueNotifier<int>(0)ref(0);第 22 行 counter.value++ 跟 Vue 幾乎同字,寫的人不會有違和感。
  • 第 17–20 行的 ValueListenableBuilder訂閱的邊界:只有它圈住的範圍會重建。你親手畫出「誰要聽 counter」,Vue 這一步則是隱形的,讀了就算訂了。
  • 第 19 行 builder 的第三個參數 _(Dart 慣例:這個參數用不到,取名底線表示故意忽略)是 child 快取插槽,這個範例用不到。
  • 第 21–24 行的按鈕在 builder 之外,按下去時它自己不重建——粒度由你圈的範圍決定,圈多大就重建多大。

忘了圈會怎樣?資料照改、畫面照舊,沒有 assertion、沒有 warning、flutter analyze 也不會有意見,因為你寫的每一行單看都合法。至於 computed?沒有原生等價物:Dart 的 getter 每次讀都重算、不快取,想要「依賴變了才重算」的衍生值,要嘛自己管髒標記,要嘛等 Day 9 的 Riverpod——它是 Flutter 生態少數真的做了依賴追蹤的方案。

差異與坑

坑一:Push vs Pull 的心智反轉。Vue 是「框架觀察你讀了什麼」(pull-based tracking),Flutter 是「你宣告誰該聽什麼」(push-based notify)。Vue 人來到 Flutter 的第一個坑百分之百是這個,而答案永遠是同一句——沒有人在聽。症狀:改了資料、畫面不動、沒有錯誤訊息,然後開始懷疑人生。

坑二:更新粒度要自己設計。Vue 的細粒度是免費的,甚至有一半在編譯期就算好了——template 編譯器會替節點標上 patch flag(Vue 3 編譯器內部的記號,標明這個節點只有哪一項會變),更新時直奔那個節點。Flutter 的粒度則完全取決於你把 builder 圈多大、notifier 拆多細:圈太大是無謂重建,拆太細是管理地獄。這個取捨在 Vue 裡根本不存在,卻是 Flutter 狀態管理百家爭鳴的根源——Day 7 到 Day 10 的四套方案,本質上都在回答「粒度誰來管」。症狀:一顆按鈕的 hover 效果讓整頁重建,DevTools 的 Performance 面板亮成一片,但畫面看起來完全正常,你不去量就不會發現。

坑三:批次更新的時機不同。Vue 的 trigger 不會立刻重跑 effect,而是排進 microtask 佇列去重合併,同一個 tick 連改十次資料,DOM 只更新一次,要拿更新後的 DOM 得等 nextTick()。Flutter 則以「幀」為單位:markNeedsBuild 只是標髒,真正的 build 發生在下一個 vsync。結論一樣(連改多次只重繪一次),等待的對象卻從 microtask 換成了 frame。症狀:counter.value++ 之後立刻讀 widget 的尺寸或位置會量到舊值;同樣的 code 在 widget test 裡漏掉 await tester.pump(),就會拿到「按鈕按了但畫面還沒變」的斷言失敗。

坑四:CSS 幫你宣告的動畫,到 Flutter 要手推。這一條用 Waypoint Air 兩邊的骨架屏(skeleton)證明。Vue 版的掃光效果是 13 行 CSS,靠一個偽元素蓋在上面平移:

/* src/components/SkeletonLoader.vue:44-56 */
.skeleton::after {
  content: '';
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(
    90deg,
    transparent,
    rgba(255, 255, 255, 0.08),
    transparent
  );
  animation: shimmer 1.6s ease-in-out infinite;
}

真正在動的只有最後一行:@keyframes shimmertranslateX-100% 推到 100%,中間每一幀的插值由瀏覽器負責。Flutter 沒有偽元素、沒有 @keyframes,於是同一個效果變成「自己開一條時間軸,再自己把漸層推過去」:

// lib/widgets/skeleton_loader.dart:36-41 —— 時間軸本人
_controller = AnimationController(
  vsync: this,
  duration: const Duration(milliseconds: 1600), // = shimmer 1.6s
)..repeat();                                    // = infinite

// lib/widgets/skeleton_loader.dart:88-97(節錄)—— 每幀手算位移
child: AnimatedBuilder(
  animation: _controller,
  builder: (context, _) {
    final t = Motion.easeInOut.transform(_controller.value); // = ease-in-out
    return FractionalTranslation(
      translation: Offset(-1 + 2 * t, 0), // = -100% → 100%

_controller.value 是 0→1 的原始進度,Motion.easeInOut 是 CSS 那條 timing function 的 Dart 版,-1 + 2 * t 把 0→1 映射成 -100%100%。CSS 一句 animation: 交代完的事,這裡拆成三段各自可見的算式;拆開來看得更清楚,但它確實是多寫的字。症狀:你在 Flutter 文件裡搜 ::after、搜 keyframes,一個結果都沒有,因為這兩個概念從來沒有被移植過。

回扣本系列的 agent 論點:Vue 響應式的經典錯誤——解構失去響應性、漏 .value、在 effect 外讀值——全是執行期靜默失敗,AI agent 寫錯了得靠人肉眼看畫面才知道,這種回饋 agent 自己吃不到。Dart 這邊「忘了圈 builder」雖然同樣靜默,但它旁邊那一整圈風險(型別對不上、null 誤用、API 誤拼)在編譯期就爆,analyzer 還會補刀。兩邊的靜默失敗面積不同,這件事會在 Day 9 Riverpod 的編譯期安全裡再放大檢視。

小結與下一篇

一句話:Vue 幫你自動追蹤「誰讀了什麼」,Flutter 把追蹤這件事整個還給你——模組二剩下的四天,就是看 Flutter 生態用幾種方式把它接回來。下一篇 Day 7〈Flutter setState:最原始的重繪機制與痛點〉,從最土法煉鋼的那條路開始:手動標記重繪,連 ValueNotifier 都不用。

參考資料

如果你卡在語法

深入原理


上一篇
Day 5|元件化思維:Vue 元件 props/emit vs Flutter Widget 建構子/callback
下一篇
Day 7|Flutter setState:最原始的重繪機制與痛點
系列文
Vue 前端工程師視角看 Flutter Web9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言