iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Modern Web

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

Day 2|SFC template vs widget tree:宣告式 UI 的兩種寫法

  • 分享至 

  • xImage
  •  

模組一|心智模型轉換(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 警告變成編譯期錯誤。

Vue 怎麼做

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」這層糖衣做得夠厚,厚到我們忘了它的存在。

Flutter Web 怎麼做

窗仔把同一顆 render function 方塊,一邊裹糖衣一邊直接交出

我把同一份 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 特性,加上第三行一個,逐項拆:

  1. const 建構子(編譯期就能算出的常數物件,Flutter 靠它跳過重建)——值在編譯期就定了,框架重建時能直接判定「這棵子樹沒變」而整段略過。
  2. super.key(=Vue 的 :key,框架用它在重建時對齊新舊 widget)——把外面傳進來的 key 原封不動轉發給父類別的簡寫,Day 7 講重建機制時會再撞一次。
  3. required(具名參數的必填標記,漏傳編譯不過)——Dart 的具名參數預設可省略,加上它就變強制,呼叫端漏傳是編譯錯誤,不會拖到執行期才丟你一個 undefined
  4. this.products——「把這個參數的值直接指派給同名欄位」的簡寫,省掉建構子大括號裡那句 products = products
  5. 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 用 $ 包住表達式,所以要印錢字號本身得跳脫。
  • forif 直接寫在 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 的 AppColorsAppTheme 兩個檔就是這樣長出來的。症狀:改了一處顏色,另外三個畫面沒跟著變,因為當初是複製貼上那串 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 的世界。

參考資料

如果你卡在語法

深入原理


上一篇
Day 1|為什麼 Vue 前端要認識 Flutter Web?系列開篇與學習地圖
下一篇
Day 3|CSS 選擇器 vs Flutter 樣式系統(BoxDecoration、TextStyle)
系列文
Vue 前端工程師視角看 Flutter Web8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言