iT邦幫忙

2026 iThome 鐵人賽

DAY 3
0
Modern Web

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

Day 3|CSS 選擇器 vs Flutter 樣式系統(BoxDecoration、TextStyle)

  • 分享至 

  • xImage
  •  

模組一|心智模型轉換(Day 1–5)

先備:沒有。Day 1(Flutter Web 把整個畫面畫進一塊 canvas、DOM 只剩一個節點)與 Day 2(widget tree 怎麼讀)沒讀過也不影響這篇。第一次讀可以先跳到「差異與坑」,設計 token 搬家時真正會壞掉的地方都在那一段。

這個系列在做一件事:用 Vue 前端工程師既有的心智模型,一項一項去對照 Flutter Web——寫給寫過 1–3 年 Vue、零 Flutter/Dart 經驗的前端工程師看。實驗場是一個訂票 App(Waypoint Air),我把它的 Vue 版本整套搬去 Flutter Web,邊搬邊記哪裡對得上、哪裡對不上。

這篇處理的是樣式:你在 CSS 裡寫熟的那一套,搬過去之後對應到什麼。 值得往下讀的理由是——答案不是「換個語法寫同一件事」。有一批東西在那邊根本沒有等價物,還有一批看起來可以複製貼上、貼過去卻會安靜地壞掉。

四個名詞先用 Vue/CSS 的話講清楚,後面整篇都在用:Widget 是 Flutter 的畫面元件,位置等於一個 .vue 檔;build 方法是這個元件描述自己長什麼樣的地方,位置等於 SFC 的 <template>,差別在它是用 Dart 程式寫的、要重畫就整段重跑;BoxDecoration 是把 CSS 的 backgroundborderborder-radiusbox-shadow 打包起來的一個物件;TextStyle 則對應 font-sizecolorletter-spacing 那一群文字屬性。

我寫 CSS 的年資跟寫 Vue 一樣長,cascade、specificity、繼承、BEM 命名,這些直覺深到不必經過大腦——然後 Flutter 告訴我:這裡沒有選擇器。 連「宣告一條規則、讓引擎去匹配元素」這個模型都不存在。把訂票 App 的樣式搬過去的第一天,我就吃到 Cannot provide both a color and a decorationContainer(Flutter 的萬用盒子,位置接近一個 <div>)的 colordecoration 只能擇一,畫面當場停掉。第二天我量了行數,同一套設計 token,Vue 端是 33 行的 index.css,Flutter 端是 285 行的 lib/theme/。第一天我覺得這是殘廢,第三天我開始懷疑:過去十年我到底花了多少時間在跟 cascade 打架?

結論先講:CSS 把樣式當規則、交給引擎匹配、由 cascade 裁決;Flutter 把樣式當建構子參數,衝突當場報錯。彈性換確定性——代價是設計 token 搬家要重新換算單位,複製貼上一定會壞。

Vue 怎麼做

先立一個兩邊都要做的題目:一顆按鈕元件,預設是灰框透明底,傳 primary 就切成綠底白字。SFC 的解法是宣告兩條 class 規則,再用 :class 決定掛哪一條。

先是 script 區塊,只做一件事——宣告這顆按鈕收一個 primary 布林 prop:

defineProps({ primary: { type: Boolean, default: false } })

其餘兩個區塊:

<template>
  <button class="btn" :class="{ 'btn--primary': primary }">
    <slot />
  </button>
</template>
<style scoped>
.btn {
  padding: 8px 16px;
  border: 1px solid #ccc;
  font-size: 14px;
  line-height: 20px;
}
.btn--primary {
  background: var(--color-emerald-green);
}
</style>

元件只決定「要不要掛上 btn--primary」,長什麼樣寫在另一個地方;兩條規則同時命中時由 specificity 裁決誰贏;var(--color-emerald-green) 是全域 token,這顆按鈕不擁有它、只是引用;scoped 在背後幫每個節點加 data-v-xxx 屬性,把 CSS 的全域天性關進籠子。心智模型可以壓成一句:宣告規則,讓引擎去匹配。 全站一致性靠 design token 與全域 stylesheet 撐著。

Flutter Web 怎麼做

https://ithelp.ithome.com.tw/upload/images/20260808/20183479RLwBO8oTBG.png
同一顆按鈕、同樣兩種樣態。Flutter 沒有 class 可以掛,所以樣式必須在 build() 當場算出來——盒子交給 BoxDecoration(把背景、邊框、圓角、陰影包成一個物件),文字交給 TextStyle,條件切換就是普通的三元運算。程式碼裡的 StatelessWidget 是「沒有自己內部狀態的元件」,只吃外面傳進來的參數,等於一個只有 props、沒有 ref 的 Vue 元件;Text 就是一個純文字節點:

class AppButton extends StatelessWidget {
  const AppButton({super.key, required this.label, this.primary = false});
  final String label;
  final bool primary;
  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      decoration: BoxDecoration(
        color: primary ? const Color(0xFF0F8A4D) : null,
        border: primary ? null : Border.all(color: const Color(0xFFCCCCCC)),
      ),
      child: Text(
        label,
        style: TextStyle(
          fontSize: 14,
          height: 20 / 14, // CSS 寫 line-height: 20px,這裡要寫成倍率
          color: primary ? Colors.white : const Color(0xFFD1D5DC),
        ),
      ),
    );
  }
}
  • padding 收的不是字串 "8px 16px",是 EdgeInsets 物件(上下左右內距的物件寫法);decoration 則把 CSS 的 backgroundborderborder-radius 收進同一個物件。
  • primary ? … : null 就是 :class 的替代品——樣式邏輯就是程式邏輯,沒有第二套語法。
  • height: 20 / 14 是全篇最重要的一行:CSS 的 line-height: 20px 到了 Flutter 變成「行高是字級的幾倍」,所以要拿 20 去除以 14。單位制換了,這是設計 token 搬家真正會壞掉的地方,第 2 條坑會算給你看。

名詞對齊表放這裡,後面幾天會一直用到:

CSS Flutter
background / border / border-radius / box-shadow BoxDecoration 的欄位
font-size / color / letter-spacing TextStyle
line-height: 20px height: 20 / 14(倍率,不是像素)

表格外還有兩件事:CSS 全域 stylesheet 的角色由 ThemeData 接手(一包全站共用的顏色與字型設定,位置接近你 index.css 裡那組 CSS variable),注入 MaterialApp(theme:),子孫 widget 用 Theme.of(context) 往上取值——這條「往上取值」的管線底層叫 InheritedWidget,先當成 Vue 的 provide / inject:上層放一份值,底下任意深度的子元件直接拿,不必一層層傳 props。Day 8 會第一次借它解釋跨層級傳值,Day 18 專篇拆開;CSS 的文字繼承則對到 DefaultTextStyle,把 cascade 中「繼承」那一小塊變成顯式的一層 widget。這裡順便招認一件事:Waypoint Air 的 Flutter 端雖然在 main.dart:20 老實注入了 AppTheme.build(),全專案卻一次都沒有呼叫過 Theme.of(context)——取色一律直接讀靜態常數。管線建好了,沒人走。我把 Tailwind 的 utility 心智原封不動搬了過來,代價要等哪天想加淺色模式才會出現,Day 18 我把引用點數給你看。

差異與坑

1. 沒有 cascade,也就沒有 specificity war。 沒人能在三個月後用一條 !important 從全域炸掉你的元件——因為根本沒有全域,樣式來源只有建構子參數這一條。這就是 Day 1 說的「單一設計系統強制力」——那天的說法是:Flutter 沒有全域樣式表可以插隊,全站視覺只能從同一套元件與常數長出來。它來自語言層面沒有逃生門,跟團隊紀律好不好無關。代價是彈性:想從外面微調某顆深層元件,你只能走它公開的參數或 Theme,做不到 ::v-deep 那種穿刺——我認為這是 feature,但你要有心理準備。症狀:設計師說「這頁的卡片圓角要小 2px」,你發現沒有任何地方可以從外面蓋掉,只能回去改元件、加一個參數、再改所有呼叫端。

2. 設計 token 不能複製貼上,因為單位制換了。 Vue 端全部的設計 token 就是這 5 行,Tailwind v4 會把它編成 CSS variable,順便生出 bg-vibrant-orangetext-emerald-green 這類 utility:

/* flight-booking-vue/src/index.css:5-9 */
@theme {
  --color-vibrant-orange: hsl(25, 100%, 55%);
  --color-emerald-green: hsl(150, 80%, 30%);
  --font-sans: 'Inter', 'Outfit', sans-serif;
}

Flutter 端沒有 hsl()、也沒有 utility 產生器,同樣兩個色票得先換算成 sRGB hex 再寫成常數;字級更麻煩,Tailwind 每個字級都綁一個行高(text-sm 是 14/20),搬到 Flutter 要拆成兩個值,而且行高得先除過:

// flight-booking-flutter/lib/theme/app_colors.dart:13-25(節錄)
static const vibrantOrange = Color(0xFFFF791A); // hsl(25, 100%, 55%)
static const emeraldGreen = Color(0xFF0F8A4D);  // hsl(150, 80%, 30%)

// flight-booking-flutter/lib/theme/app_theme.dart:15-34(節錄)
class T {
  static const double sm = 14;         // text-sm   14/20
  static const double hSm = 20 / 14;   // Flutter 的 height 是倍率,Tailwind 的 line-height 是絕對值
  static double wide(double fontSize) => fontSize * 0.025; // tracking-wide 的 em → px
}

hSm = 20 / 14 那個除法就是整條坑的證據:兩邊描述的是同一個視覺結果,但一邊的單位是像素、另一邊是倍率,所以 token 搬家是換算,複製貼上救不了。tracking-wide 更誇張,Tailwind 用 em、Flutter 的 letterSpacing 用邏輯像素,只好寫成一個函式。症狀:色票用線上工具轉一轉貼過去,單看沒問題,跟 Vue 版並排才發現差一階;字級照抄 Tailwind 數字,行距整個垮掉,Text 上下多出幾 px 你查不出哪來的。

3. Containercolordecoration 不能同時給。 新手第一週幾乎必踩。同時給的話 Flutter 直接 assert 中斷,訊息還明確叫你把 color 移進 BoxDecoration。注意這件事的性質:CSS 裡互相衝突的宣告會被 cascade 靜默吃掉一條,畫面錯了要自己瞪 DevTools;Flutter 選擇當場爆炸。對 AI agent 工作流這是禮物——Day 1 的獨家論點(Flutter 的強型別加上當場報錯,等於免費幫 agent 準備好一條回饋迴圈)在這裡兌現第二次:爆炸訊息本身就是下一輪修正的輸入,比「畫面默默不對」好 debug 一百倍。症狀:紅色錯誤畫面加上 Cannot provide both a color and a decoration,app 停在那一幀,而不是給你一個顏色詭異的方塊。

4. easing 曲線可以整條搬過去,時間與長度單位不行。 這是這趟搬家少數的好消息。CSS 的 cubic-bezier() 四個控制點,跟 Dart 的 Cubic() 是同一組參數、同一條曲線,一對一直接對映:

// flight-booking-flutter/lib/theme/motion.dart:9-25(節錄)
static const tw = Cubic(0.4, 0.0, 0.2, 1.0);         // Tailwind 預設 timing function
static const outQuart = Cubic(0.25, 1.0, 0.5, 1.0);  // AnimatedCheckmark、StaggerList
static const backOut = Cubic(0.34, 1.56, 0.64, 1.0); // MorphIcon,第二點超過 1 會過衝

每一條旁邊都標了來源元件,因為這份檔案是逐個 Vue 元件抄過來的。可移植的是數學,卡住的永遠是單位制:顏色、字級、行高、字距全部要換算,貝茲曲線不用。症狀:動畫節奏對了、手感卻怪,八成不是曲線抄錯,是 duration 或位移距離的單位沒對齊。

5. Tailwind 的 variant(hover: / active: / disabled:)在 Flutter 沒有等價物。 CSS 的偽類是同一條規則的另一個狀態分支,寫起來只是多一個前綴;Flutter 這邊,滑鼠在不在上面、有沒有被按住,是你要自己持有的狀態。Waypoint Air 為此在 lib/widgets/shell.dart:27-50 手工做了一顆 PressableMouseRegion(滑鼠進出偵測,等於 mouseenter / mouseleave)的 onEnter / onExit_hoveringGestureDetector(點擊與手勢偵測)的 onTapDown / onTapUp / onTapCancel_pressed,再把這兩個布林用 builder 交給呼叫端決定怎麼變。一行 hover:bg-emerald-600 換來 24 行 widget。標準做法還有 WidgetStateProperty(把「每個互動狀態各給一個值」包成一個物件的官方機制),但客製視覺一多,多數人還是會走回手工這條路。至於 @media,對應的是 MediaQueryLayoutBuilder,明天講版面時一起處理。症狀:hover 效果在 Vue 版有、Flutter 版沒有,而且零錯誤訊息——你根本沒寫那段狀態,Flutter 也不覺得少了什麼。

小結與下一篇

一句話:CSS 是「宣告規則、引擎匹配、cascade 裁決」,Flutter 是「樣式即參數、衝突即報錯」——彈性換確定性,而確定性恰好是 agent 最需要的東西。

還有一個副作用值得記著:CSS 的復用單位是 class,Flutter 的復用單位是 widget 和常數。想復用 .btn 的視覺就抽一顆 AppButton,想復用一組色票就抽 AppColorsThemeExtension(把自訂設計 token 掛進 Theme 的官方擴充點)。Vue 把結構與樣式分開放,Flutter 讓樣式內聚進元件——這跟 Tailwind 之後的走向其實同一個方向,只是走得更絕。明天 Day 4 處理每個轉界者的第一個提問:justify-content: center 等於什麼?——Flexbox/Grid vs Row/Column/Stack,以及比名詞對照更重要的 constraint 模型。順帶一提,今天那顆手工 Pressable 只是開胃菜,Day 24 會看到更誇張的:CSS 三行的打勾動畫,Flutter 端配了一支 216 行的手寫 SVG path parser。

參考資料

如果你卡在語法

深入原理


上一篇
Day 2|SFC template vs widget tree:宣告式 UI 的兩種寫法
下一篇
Day 4|版面配置對照:Flexbox/Grid vs Row/Column/Stack
系列文
Vue 前端工程師視角看 Flutter Web9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言