模組一|心智模型轉換(Day 1–5)
先備:Day 2(widget tree 與第一段 Dart 語法)、Day 4(Row/Column 的 constraint 模型)。第一次讀可以跳過本篇 Flutter code 的細節,直接看「差異與坑」。
我在 Vue 專案裡把 @change 打成 @chnage,畫面沒反應、console 一片乾淨,我瞪著螢幕二十分鐘才發現是拼字。同一個錯搬到 Flutter,flutter analyze 一秒回我 The named parameter 'onChangd' isn't defined,還附上「你是不是要打 onChanged」。「props down, events up」這句話我對新人講過上百次,它在 Flutter 一字不改地成立——會變的只有拼法,以及誰來執法。模組一的最後一天,把元件的公開 API 對齊,順便回收 Day 2 欠下的 slot。
結論先講:單向資料流的憲法兩邊共用,props 換成建構子的具名參數、emit 換成 callback 欄位;差別在於 Vue 的元件介面靠字串與約定維持,Flutter 的元件介面由編譯器管。
先用一顆最小的受控元件把「介面」的形狀畫出來:一個數量選擇器,狀態放在父層,子元件只負責顯示目前的數字,以及在按下加減時把「我想變成幾」上報回去。
先是 QuantityPicker.vue 的 script 區塊,它只做宣告介面這一件事——兩個 props,一個 change 事件:
const props = defineProps({
value: { type: Number, required: true },
max: { type: Number, default: 99 },
})
const emit = defineEmits(['change'])
剩下的 template:
<template>
<button @click="emit('change', props.value - 1)">−</button>
<span>{{ props.value }}</span>
<button :disabled="props.value >= props.max" @click="emit('change', props.value + 1)">+</button>
</template>
父層用起來是 <QuantityPicker :value="qty" @change="qty = $event" />,或者直接上 v-model 語法糖。一顆 Vue 元件的公開介面就這三件事:props 宣告(帶型別與預設值)、emits 宣告(事件名字串)、slot(內容分發,這顆沒用到,等一下在「差異與坑」處理)。
同一顆選擇器搬過去。這裡要解決的問題是:Flutter 沒有 defineProps、沒有 defineEmits、也沒有 <template> 可以掛事件,那元件的公開介面該寫在哪裡?答案是只有一個地方——建構子簽名。
import 'package:flutter/material.dart';
class QuantityPicker extends StatelessWidget {
const QuantityPicker({
super.key,
required this.value,
required this.onChanged,
this.max = 99,
});
final int value;
final int max;
final ValueChanged<int> onChanged; // = defineEmits(['change']) 的型別版
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(onPressed: () => onChanged(value - 1), child: const Text('−')),
Text('$value'),
// callback 傳 null 就是停用,這是 Flutter 的慣例
TextButton(
onPressed: value >= max ? null : () => onChanged(value + 1),
child: const Text('+'),
),
],
);
}
}
defineProps:required this.value 對應 required: true、this.max = 99 對應 default: 99,型別則寫在第 11–13 行的欄位上。ValueChanged<int> 是 void Function(int) 的別名,也就是 defineEmits(['change']) 的型別化身;第 20、24 行呼叫它 = emit('change', payload),子元件自己一步都不動 value。:disabled 這種屬性——Flutter 用「callback 傳 null」表示停用,按鈕的漣漪與游標樣式會一起關掉。這段貼進 flutter create 出來的專案 lib/ 底下就能編譯。父層照樣持有狀態(StatefulWidget 的 setState,Day 7 細講),把 onChanged: (v) => setState(() => qty = v) 傳下來——資料下行、事件上行,憲法原封不動。

Vue 的 slot 到我這,就是往元件插槽塞 widget,一招解決。

我把元件接線換成型別插頭,插錯就插不進,不再靠字串賭運氣。
1. emit 是字串,callback 是型別——這是本系列獨家論點最漂亮的一個現場。 Vue 的 emit('change', payload) 事件名是字串,父層 @chnage 拼錯一個字母不會有任何錯誤,事件就這樣靜默消失(TS 專案用 defineEmits<{ change: [n: number] }>() 能把型別補回來,但那是選配,而且多數專案沒配好)。Flutter 的 onChanged 是建構子上的具名參數,型別是 ValueChanged<int>:名字拼錯、忘記傳、payload 型別對不上,三種情況全部編譯不過。對 AI agent 來說這一格差很大——「元件接錯線」這一整類 bug 從「跑起來點畫面才會發現」降級成「analyzer 跑一輪就修掉」,回饋迴圈短了一個數量級。症狀:@chnage 拼錯後畫面完全沒反應、console 全綠,你會先懷疑父層狀態、再懷疑框架,最後才發現是拼字。
2. required 加上 sound null safety,讓「忘傳 props」這種 bug 絕種。 Vue 的 required prop 忘了傳只是一行 console warning,元件照樣渲染,undefined 沿著資料流一路往下滲。Dart 這邊少傳一個 required 參數就是編譯錯誤,而且 null safety 保證 value 進到 build() 的時候絕不會是 null。介面契約由編譯器執行,不由 code review 執行。症狀:Vue 少傳一個 prop,畫面先顯示 NaN 或空白,真正的爆炸發生在三層外某個 .toFixed(),stack trace 指的位置跟錯誤源頭差了十萬八千里。
3. v-model 沒有對應的語法糖。 Flutter 一律手寫 value + onChanged 這對受控組合,官方元件(TextField、Checkbox、Slider)全都長這樣,沒有例外。囉嗦,但資料的流向永遠肉眼可見。習慣之後我反而覺得這是 v-model 把雙向綁定藏起來之前的原貌,各有所愛。症狀:從 Vue 搬過來的第一張表單,行數大約翻倍,你會一直懷疑自己是不是漏掉了某個 API。
4. slot 的答案是「把 widget 當參數傳」。 預設 slot 對應 child(單一)或 children(多個);具名 slot 就是多開幾個 widget 型參數(看看 ListTile 的 title:、trailing:);scoped slot——子元件把資料回拋給父層、由父層決定怎麼渲染——對應 builder 參數 Widget Function(BuildContext, T),ListView.builder 就是這個模式。Waypoint Air 兩邊都有同一顆清單進場動畫元件,Vue 版收 items(interface Props { items: any[] }),再用 scoped slot 把每一筆丟回給呼叫端:
<!-- src/components/StaggerList.vue:9-19 -->
<template>
<TransitionGroup tag="div" name="stagger" class="stagger-list-container">
<div
v-for="(item, i) in items"
:key="item.id || i"
class="stagger-item"
:style="{ transitionDelay: `${i * 60}ms` }"
>
<slot :item="item" :index="i" />
</div>
</TransitionGroup>
Flutter 版的簽名把這個關係反了過來:lib/widgets/stagger_list.dart:10-20 收的是 required this.children(型別 List<Widget>),迴圈搬到呼叫端去跑(lib/views/flight_list_view.dart:113-117 的 for (final flight in mockFlights) _flightCard(flight))。同一顆元件,Vue 拿到的是資料、Flutter 拿到的是成品。想保留「元件自己掌管迴圈」的寫法也可以,把參數換成 itemBuilder 就是 ListView.builder 的形狀——差別在於 Flutter 這邊由你選,Vue 那邊由語法決定。另一顆 SpotlightCard 是更單純的樣本:SpotlightCard.vue 的預設 slot 到了 lib/widgets/spotlight_card.dart:13 就是一個 final Widget child;,withDefaults(defineProps()) 的那串預設值則變成建構子的參數預設值(:23-31)。Vue 需要一套專用模板語法(<slot>、具名 slot、scoped slot)才辦得到的事,Flutter 全部用「參數的型別是 widget 或函式」一招解決——概念更少,泛用性更高,代價是你得自己想得到這一招。症狀:你照 Vue 的直覺去翻 Flutter 文件找 <slot> 的等價語法,關鍵字怎麼查都查不到——因為它從來沒被做成語法,它只是參數。
5. Vue 用 CSS 宣告時間,Flutter 要你自己算。 同一個 stagger 進場效果,Vue 那邊靠 transitionDelay 一行 inline style 交代完,剩下的交給瀏覽器的 transition 引擎;Flutter 沒有這條引擎,stagger_list.dart 只好開一個 AnimationController,把「第 i 個項目此刻該走到動畫的百分之幾」手算出來:
// lib/widgets/stagger_list.dart:69-83
Widget _item(int index, double elapsed) {
// `transition-delay: index * 60ms`
final local =
((elapsed - index * widget.staggerMs) / widget.duration.inMilliseconds)
.clamp(0.0, 1.0);
final v = Motion.outQuart.transform(local);
return Opacity(
opacity: v,
child: Transform.translate(
// `.stagger-enter-from { transform: translateY(20px) }`
offset: Offset(0, 20 * (1 - v)),
child: widget.children[index],
),
);
}
elapsed - index * 60 是「這一項的動畫已經開跑幾毫秒」,除以總時長得到 0–1 的進度,clamp 把還沒輪到的項目壓在 0,Motion.outQuart 則是 cubic-bezier(0.25, 1, 0.5, 1) 的 Dart 版。Vue 那 11 行模板換來這 15 行程式碼,畫面行為一模一樣——這就是 Day 1 那個 2.7 倍膨脹的微觀樣本——但一顆動畫元件推不出全站結論,我拿它當樣本而不是當證明。症狀:你把 transitionDelay: ${i * 60}ms 貼進 Flutter,找不到任何屬性接得住它,只能開 AnimationController 從時間軸最底層重寫一次。
五天下來的總帳長同一個樣:Vue 給你更甜的糖與更多專用語法,Flutter 把一切收斂成「就是 Dart 程式碼」,用型別系統當守門員。 人寫起來 Vue 舒服,agent 寫起來 Flutter 穩定,這個張力會貫穿後面 25 天。五天過完,我心裡那份「可以整組搬過去」的清單其實很短:UI = f(state)、元件是封裝與複用的單位、flex 的主軸交叉軸直覺、props down / events up。剩下的都是要丟的——選擇器與 cascade、二維 Grid 與 % 尺寸、SFC 那個「結構樣式行為分屬三種語言」的反射,還有最容易忘記自己有的那一條:介面契約靠命名約定與 code review 維持。前三條你會在轉換時主動想起來,最後一條不會,因為它從來沒被寫下來過。
一句話收攏五天:宣告式 UI 那一層可以整組搬過去,靠瀏覽器實作撐著的那一層——選擇器、cascade、Grid、字串事件名——全部得丟。
一句話:props/emit 換成建構子/callback,單向資料流的憲法沒變,換掉的是執法者——從 console warning 換成編譯器。
明天進入模組二「響應式對照」,先回自家地盤盤點武器:Day 6——Vue reactivity 核心:ref/reactive/computed 原理。那是理解後面 setState、Provider、Riverpod、Bloc 各補了哪個洞的座標系。
如果你卡在語法
深入原理
ValueChanged<T>