模組一|心智模型轉換(Day 1–5)
先備:Day 1(系列前提與 Waypoint Air 兩個對照 repo)。零 Dart 基礎可以直接讀下去,本篇每一行 Dart 都會拆開;真的卡住就先跳到「差異與坑」,那裡有同一張卡片 33 行 vs 169 行的實物對照。
拿到 AI agent 生出來的第一個 Flutter 專案,我做的第一件事是 grep -r "<template>" lib/——零命中。再數一次:find lib -name '*.html' 零個、*.css 零個,整個 lib/ 底下只有 .dart。畫面在哪?在一段一段互相包起來的 Dart 建構子裡。寫了十年 SFC 的人看到這個會有一瞬間的暈眩,我暈了大概兩分鐘才看懂:兩邊表達的是同一件事——UI = f(state)——差別在一個把結構寫成標記語言,另一個把結構直接寫成程式碼。
結論先講:SFC 和 widget tree 的落差不在語法,在「誰負責排版與樣式」。Vue 外包給 CSS,所以 template 很平;Flutter 沒有 CSS 可以外包,全部塞回同一棵樹。這個決定讓同一張卡片從 33 行漲到 169 行,也讓拼錯的變數從 runtime 警告變成編譯期錯誤。
SFC 的三段式已經是肌肉記憶:<template> 放結構、script setup 區塊放邏輯、<style scoped> 放樣式。我們要做的東西兩邊都一樣:一份商品清單,每列顯示名稱與價格,標了 hot 的那幾筆多掛一個紅色 HOT 標籤。控制流交給 directive,資料綁定交給 mustache:
// script setup 區塊,實際檔案裡它包在 script setup 標籤內
import { ref } from 'vue'
const products = ref([
{ id: 1, name: 'Keyboard', price: 2990, hot: true },
{ id: 2, name: 'Mouse', price: 990, hot: false },
])
<template>
<ul>
<li v-for="p in products" :key="p.id">
<span>{{ p.name }}</span>
<strong>NT$ {{ p.price }}</strong>
<span v-if="p.hot" class="badge">HOT</span>
</li>
</ul>
</template>
<style scoped>.badge { color: red; }</style>
有一件事值得先提醒自己:template 並非 HTML,它是會被編譯成 render function 的 DSL。v-if 最後變成三元運算、v-for 變成 map。也就是說,Vue 開發者其實一直在寫 render function,只是 Vue 把「看起來像 HTML」這層糖衣做得夠厚,厚到我們忘了它的存在。

我把同一份 UI,一邊裹上 template 糖衣,一邊直接交出程式碼。
Flutter 沒有糖衣:你直接寫 render function。同一份商品清單,Dart 得先宣告一個元件——在 Flutter 叫 widget——並定義它從外面收什麼資料。先看外殼這幾行,它是全系列第一段 Dart,也是最容易讓人當場關掉分頁的一段:
class ProductList extends StatelessWidget {
const ProductList({super.key, required this.products});
final List<Product> products; // build() 見下一段
}
第二行一行塞了四個 Dart 特性,加上第三行一個,逐項拆:
const 建構子(編譯期就能算出的常數物件,Flutter 靠它跳過重建)——值在編譯期就定了,框架重建時能直接判定「這棵子樹沒變」而整段略過。super.key(=Vue 的 :key,框架用它在重建時對齊新舊 widget)——把外面傳進來的 key 原封不動轉發給父類別的簡寫,Day 7 講重建機制時會再撞一次。required(具名參數的必填標記,漏傳編譯不過)——Dart 的具名參數預設可省略,加上它就變強制,呼叫端漏傳是編譯錯誤,不會拖到執行期才丟你一個 undefined。this.products——「把這個參數的值直接指派給同名欄位」的簡寫,省掉建構子大括號裡那句 products = products。final(=JS 的 const:只能指派一次)——widget 的欄位一律 final,這是硬性規定:widget 是不可變的設計圖,要改內容就換一張新的。外殼有了,接著是真正產生畫面的 build()。條件與列表渲染這裡不靠 directive,靠 Dart 語言本身的 collection if 與 collection for:
@override
Widget build(BuildContext context) {
return Column(
children: [
for (final p in products)
Row(children: [
Text(p.name),
Text('NT\$ ${p.price}'),
if (p.hot) const Text('HOT', style: TextStyle(color: Colors.red)),
]),
],
);
}
逐行導覽:
@override(宣告「我在覆寫父類方法」,寫錯名字編譯器會抓)——把 build 打成 bulid 就編譯不過,省下一輪「畫面為什麼空白」的偵錯。build() 都收一個 BuildContext(這個 widget 在樹上的位置憑證,用來往上找 Theme、Navigator 等資源)。今天用不到它,但 Day 8 起會天天用。'NT\$ ${p.price}' 是字串插值(=JS 的 template literal,\$ 是跳脫錢字號);Dart 用 $ 包住表達式,所以要印錢字號本身得跳脫。for 與 if 直接寫在 children: 這個 list 字面值裡,展開或跳過其中的元素——這就是 collection for / collection if;Vue 那邊由 v-for / v-if 兩個 directive 代勞,Dart 這邊語言本身就給了。上面兩段還缺 Product 這個型別和進入點,貼上編譯不過。補完這段,把三段依序貼進 flutter create 出來的 lib/main.dart(覆蓋原本內容),flutter run -d chrome 就會看到那份清單跑在瀏覽器裡——這是你今天可以親手驗證的最小完整範例:
import 'package:flutter/material.dart';
class Product { // build() 用到的資料型別
const Product(this.name, this.price, {this.hot = false});
final String name;
final int price;
final bool hot;
}
void main() => runApp(const MaterialApp(
home: Scaffold(body: ProductList(products: [
Product('Keyboard', 2990, hot: true), Product('Mouse', 990),
]))));
最後一行的 void main() => runApp(...) 順便交代一個之後每天都會看到的符號:Dart 的 =>(單表達式函式體:函式身體只有一個運算式時的縮寫,=> x 就是 { return x; }),它跟 JS 的箭頭函式沒有血緣關係——不建立新函式、不牽涉 this 綁定,純粹省掉大括號。逐項對齊如下。再加上 Flutter 的口號「everything is a widget」:Padding 是 widget、Center 是 widget、連透明度都是 Opacity widget——它只是把 CSS 屬性物件化,塞回同一棵樹裡。
| Vue | Flutter |
|---|---|
<template> 裡的巢狀標籤 |
build() 裡的巢狀建構子 |
v-for="p in products" |
for (final p in products) |
v-if="p.hot" |
collection if (p.hot) |
{{ p.name }} |
Text(p.name) 與 '${p.price}' |
class="badge" + CSS |
style: TextStyle(...) 建構子參數 |
:key="p.id" |
key: ValueKey(p.id)(同樣用於 diff 對齊) |

拼錯一個字,我這道編譯閘門當場擋下,總比畫面默默空白好。
1. 錯誤爆炸的時機不同——這是我最在意的差異。 template 裡把 products 拼成 product,Vue 給你的是 runtime 的 console warning 加一片空白畫面;有 TS + Volar 能救一部分,但那終究是外掛在 DSL 上的型別層。Dart 這邊,變數拼錯、型別不合、collection if 少個逗號,flutter analyze 和編譯器直接擋下,畫面根本跑不起來。對人類這是體感嚴格;對 AI agent,這是更快更確定的回饋迴圈——Day 1 講的獨家論點,在對照的第一天就兌現了第一次。症狀:Vue 端你會盯著空白畫面翻 console 找那行灰色 warning;Flutter 端終端機直接紅字指到行號,畫面停在上一版不更新。
2. 巢狀深度會嚇到你,而且是數字上的。 拿 Waypoint Air 的航班卡實測:Vue 端 FlightListView.vue:67-99 用 33 行 template 畫完整張卡,Flutter 端 flight_list_view.dart:176-344 的 _flightCard() 用了 169 行。同一張卡、同樣的視覺,5 倍。看局部最清楚——Vue(節錄,FlightListView.vue:73-77):
<div class="flex items-center space-x-2">
<div class="w-7 h-7 rounded-lg bg-emerald-950/60 border border-emerald-900/30 flex items-center justify-center text-[10px] font-black text-emerald-400">
{{ item.logoLetter }}
</div>
<span class="font-extrabold text-sm text-gray-200">{{ item.carrier }}</span>
</div>
同一段在 Flutter(節錄,flight_list_view.dart:211-233)。code 裡的註解是 agent 自己留的,直接標出對應的 Tailwind class:
Container(
// `w-7 h-7 rounded-lg bg-emerald-950/60`
width: 28,
height: 28,
decoration: BoxDecoration(
color: AppColors.emerald950.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(8),
border: Border.all(/* 再 1 行指定 emerald900 alpha 0.3 */),
),
child: Text(flight.logoLetter, style: /* 再 5 行設定字級、字重、顏色 */),
),
// `space-x-2`
const SizedBox(width: 8),
Vue 那 5 行裡,space-x-2 一個 class 就處理掉所有子元素的間距;Flutter 得手動插 SizedBox(width: 8) 當墊片,而且每一道間隙都要插一次。Vue template 之所以平坦,是因為佈局和樣式被 CSS 拿走了;Flutter 把它們全部塞回樹裡,五六層縮排是日常。活下去的關鍵是每個參數後面都留 trailing comma,讓 dart format 自動排版——這也是 agent 產 code 品質穩定的原因之一:排版沒有自由度,就沒有爭議。症狀:想把卡片 padding 從 20 改成 24,你得在 169 行裡捲動找出那個 EdgeInsets.all(20),而不是掃一眼 class 字串。
3. 沒有 scoped 的煩惱,因為沒有全域可以洩漏。 SFC 需要 scoped 是因為 CSS 天生全域;Flutter 的樣式是建構子參數,作用域就是那個 widget 本身,隔離是預設值、不是選項。代價寫在上一點裡:想共用一組樣式,你得自己抽常數或抽 widget,沒有 class 名可以到處貼——Waypoint Air 的 AppColors 與 AppTheme 兩個檔就是這樣長出來的。症狀:改了一處顏色,另外三個畫面沒跟著變,因為當初是複製貼上那串 Color(0xFF...),沒有共用同一個常數。
4. slot 的對應先欠著。 <slot> 在 Flutter 對應 child / children / builder 參數,航班卡那段 Pressable(builder: (context, hovering, pressed) => ...) 就是 builder 形式的 slot;這牽涉元件 API 設計,Day 5 專門處理。症狀:你想做一個「外殼固定、內容由外面決定」的容器,翻遍 widget 文件找不到 slot 這個字,因為它叫 child。
這一篇讓我對 Flutter Web 的懷疑增加了一點,因為那 136 行的差額裡有很大一塊只是在把 Tailwind class 字串翻譯成建構子參數和 SizedBox 墊片——多寫的行數沒有換到任何新功能,只換到「不用管 CSS cascade」。這筆交易划不划算,Day 3 開始有得吵。
一句話:SFC template 和 widget tree 是同一顆宣告式 UI 的兩種序列化格式——一個編譯成 render function,一個直接就是 render function;行數差距來自 Flutter 沒有 CSS 可以外包排版。 明天 Day 3 進入十年直覺歸零最徹底的一區:CSS 選擇器 vs Flutter 樣式系統(BoxDecoration、TextStyle)——一個沒有 cascade、沒有 specificity、也沒有 !important 的世界。
如果你卡在語法
required、super 參數)
深入原理