iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Modern Web

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

Day 4|版面配置對照:Flexbox/Grid vs Row/Column/Stack

  • 分享至 

  • xImage
  •  

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

先備:Day 2(widget tree 怎麼讀)、Day 3(樣式寫在哪)。第一次讀可以略過兩段簡例的細節,直接跳到「差異與坑」的第一條——那組真實 code 才是本篇的重點。

Waypoint Air 有一頁「語言與貨幣」設定,畫面很單純:兩張卡片,每張卡片裡四個選項排成兩欄。Vue 端 13 行就收工,grid grid-cols-2 gap-3 配一個 v-forLanguageSettingsView.vue:38-50)。Flutter 端同一塊畫面,我在 diff 裡看到這一行:for (var row = 0; row * 2 < options.length; row++)。我盯著它看了大概三十秒,才接受一件事:要在 Flutter 排一個兩欄等高清單,你真的得自己算 row index。

結論先講:justify-contentmainAxisAlignment 這種一維對照是查表等級,一個下午背完就能動工;要付代價的是 CSS Grid——Flutter 沒有等價物,二維版面得用一維工具手工湊。

Vue 怎麼做

Vue 本身不管版面,版面是 CSS 的事。一條典型的 toolbar——左邊 logo、中間選單撐開、右邊按鈕靠右——flexbox 十秒寫完:

<template>
  <div class="toolbar">
    <img class="logo" src="/logo.svg" alt="logo" />
    <nav class="menu"><!-- 選單 --></nav>
    <button class="cta">登入</button>
  </div>
</template>

<style scoped>
.toolbar { display: flex; align-items: center; gap: 16px; }
.menu { flex: 1; } /* 撐開剩餘空間,把 cta 推到最右 */
</style>

二維版面交給 Grid(display: grid + grid-template-columns),絕對定位交給 position: absolute。CSS 版面的隱含哲學是:內容決定尺寸,元素在正常流裡自己找位置,你用規則去「修正」這個流。

Flutter Web 怎麼做

Flutter 把上面那三件事拆給三顆 widget:Row/Column 對應 flexbox 的兩個方向、Stack + Positioned 對應絕對定位、GridView 對應(部分的)Grid。同一條 toolbar 換成 widget:

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

  @override
  Widget build(BuildContext context) {
    return Row(
      crossAxisAlignment: CrossAxisAlignment.center, // align-items: center
      spacing: 16,                                   // gap: 16px(Flutter 3.27+)
      children: [
        const Logo(),
        const Expanded(child: Menu()),               // flex: 1
        ElevatedButton(onPressed: () {}, child: const Text('登入')),
      ],
    );
  }
}

結構跟 Vue 版是同一個形狀,只是 class 名變成建構子參數。名詞對照表,背完這七條就能寫出九成的一維版面,剩下那一成就是接下來要講的:

CSS(flex 容器) Flutter
flex-direction: row / column Row / Column(直接是兩顆不同 widget)
justify-content mainAxisAlignment
align-items crossAxisAlignment
flex: 1 子項包 Expanded(可壓縮版是 Flexible
gap spacing 參數或手放 SizedBox
position: absolute + top/left Stack + Positioned(top:, left:)
@media 斷點 MediaQuery / LayoutBuilder

差異與坑

https://ithelp.ithome.com.tw/upload/images/20260810/20183479UL5eZc02RT.png
我把無邊界的高度塞進 Column,換來一整片紅畫面。

https://ithelp.ithome.com.tw/upload/images/20260809/201834797Wig50ryC1.png
尺寸不是我天生就有的,是跟父層一來一往談判出來的。

坑一:CSS Grid 沒有 Flutter 等價物,二維版面要手工湊。 這是全系列我覺得最有說服力的一組對照,所以直接上真實 code。需求是「四個選項排成兩欄、兩兩等高、格間留 12px、選中的換邊框顏色」。Vue 端這樣寫(LanguageSettingsView.vue:38-50):

<div class="grid grid-cols-2 gap-3">
  <button
    v-for="lang in languages"
    :key="lang"
    class="p-3.5 rounded-xl border text-center font-bold text-sm transition-all"
    :class="tempLang === lang
      ? 'bg-slate-950 border-emerald-green text-white shadow-lg'
      : 'bg-slate-900 border-slate-800/60 text-gray-400 hover:border-slate-700'"
    @click="tempLang = lang"
  >
    {{ lang }}
  </button>
</div>

grid-cols-2 出欄數、gap-3 出間距、等高是 Grid 預設行為,項目奇數個也不用管——瀏覽器自己補空位。整段沒有一行在描述「第幾個要放哪裡」。

Flutter 端做同一件事(language_settings_view.dart:152-170,節錄)。注意 code 裡的註解是 AI agent 自己留的,它把原始的 Tailwind class 貼在旁邊當對照錨點:

// `grid grid-cols-2 gap-3`
for (var row = 0; row * 2 < options.length; row++) ...[
  if (row > 0) const SizedBox(height: 12),
  IntrinsicHeight(
    child: Row(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        Expanded(child: _option(options[row * 2], selected, onSelect)),
        const SizedBox(width: 12),
        Expanded(
          child: row * 2 + 1 < options.length
              ? _option(options[row * 2 + 1], selected, onSelect)
              : const SizedBox.shrink(),
        ),
      ],
    ),
  ),
],

逐行導覽,四件 Vue 端完全不必做的事:

  1. 算 row indexrow * 2row * 2 + 1 是自己把一維陣列切成二維。...[] 是 Dart 的 collection-for 加展開,意思是「把迴圈產出的這串 widget 攤平塞進 children」。
  2. 手補 gapSizedBox(height: 12) 是列間距、SizedBox(width: 12) 是欄間距,兩個方向要分開放,而且列間距得配 if (row > 0) 才不會多出頭一條。
  3. 手動等高IntrinsicHeight + crossAxisAlignment.stretch 才能讓「繁體中文」和「English」兩格一樣高。代價是 IntrinsicHeight 會多跑一次量測 pass,官方文件自己標了效能警語。
  4. 處理奇數項SizedBox.shrink() 補位,否則最後一列只剩一格會被 Expanded 撐成滿版。

13 行對 19 行,行數差距其實不大——真正的落差在腦子裡。Vue 那 13 行沒有一行在算位置,Flutter 這 19 行有一半在算位置。而 GridView 救不了這個場景:它本質上是可捲動的等格清單,適合商品牆、相簿,塞進一個已經在捲動的 Column 裡就會撞上坑三。真要不規則二維版面,只剩 TableWrap、或自訂 RenderObject——這局我判 Web 贏,而且贏得不勉強。症狀:需求從兩欄改成三欄,Vue 端把 grid-cols-2 改成 grid-cols-3 就收工;Flutter 端要同時改兩處 row * 2 算式和補位條件,漏改一處畫面不會報錯,只會安靜地少一格。

坑二:版面的物理法則換了,官方那句口訣值得抄在牆上——「Constraints go down. Sizes go up. Parent sets position.」 父層往下傳「你最小多大、最大多大」的約束,子層在約束內回報自己的尺寸,位置由父層決定。CSS 是內容驅動、流式的;Flutter 是約束驅動、談判式的。你在 CSS 養成的直覺「元素自然就有大小」在這裡不成立——尺寸永遠是約束談判的結果,坑一那個 Expanded 之所以必要,就是因為得先告訴父層「剩下的空間我要」。症狀:你寫 Container(width: 300),量出來卻是滿版寬——父層給的是緊約束(min == max),你設的寬度被無視,而且完全沒有警告。

坑三:新手必撞的 unbounded constraints。Column 裡直接放 ListView,或 Row 裡放沒有寬度上限的東西,立刻紅畫面:Vertical viewport was given unbounded height。原因是 Column 給子項的高度約束是無限,而 ListView 需要有限高度才算得出 viewport。解法通常是包 Expanded。這個錯誤的「爆法」值得注意:debug 模式紅畫面 + 一大段說明 + 指向確切的 widget——又是那個模式,Flutter 選擇大聲失敗。我把這段訊息原封不動貼回給 agent,下一輪它就自己包好 Expanded 了;換成 CSS 的沉默塌陷(高度變 0、畫面消失),agent 連錯誤訊號都收不到。症狀:紅底黃字爆滿整個畫面,訊息第一行就是 Vertical viewport was given unbounded height,底下附一段講 Expanded 的說明。

坑四:RWD 的習慣要轉向。 沒有全域斷點 stylesheet,取而代之的是 MediaQuery.sizeOf(context) 拿螢幕尺寸做頁面級分支、LayoutBuilder父層約束做元件級分支。後者其實比 media query 更合理——元件響應的是自己拿到的空間,不是整個視窗——CSS 一直到 container query 出現才補上這件事。症狀:視窗從桌機縮到手機寬度,版面毫無反應,因為你根本沒寫任何分支,Flutter 不會幫你套任何預設斷點。

這一篇讓我對 Flutter Web 的懷疑增加了一格,但增加的幅度比我原本預期小。 增加是因為「沒有 Grid」是結構性的缺口,不是套件能補的;比預期小是因為那 19 行是一次性成本——抽成 _section() 之後,語言和貨幣兩張卡片共用同一份,全站再也沒有第三處要寫。它是稅,不是牆。要注意的是這個判斷有前提:Waypoint Air 是我拿來練手的專案,版面只有兩欄清單這種等級。真的要在 Flutter 排一個 dashboard 骨架,稅率會不會跳一級,我目前沒有證據。

小結與下一篇

一句話:flexbox 到 Row/Column 是名詞翻譯的等級,一天記完;但 CSS Grid 到 Flutter 是「沒有對應物、只能用一維工具湊二維」,而版面的物理法則也從「內容決定尺寸」換成「約束談判尺寸」——名詞背得完,法則要撞幾次牆才會進身體。

明天 Day 5 是模組一的收尾:元件 API 怎麼設計——Vue 的 props/emit 對上 Flutter 的建構子/callback,順便回收 Day 2 欠的 slot 對照。坑一那個 _option() 到時候會再出現一次,因為它正好是「把重複版面抽成元件」的教科書案例。

參考資料

如果你卡在語法

深入原理


上一篇
Day 3|CSS 選擇器 vs Flutter 樣式系統(BoxDecoration、TextStyle)
下一篇
Day 5|元件化思維:Vue 元件 props/emit vs Flutter Widget 建構子/callback
系列文
Vue 前端工程師視角看 Flutter Web9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言