不會 Dart 也做得出 Flutter Web,但 Canvas 有四筆過路費要付
我不會 Dart。這半年我還是把一個訂票 App 做完了,跑在 Flutter Web 上。
這句話在三年前不成立。語言門檻被 AI 補掉之後,「不會寫」就不再是拒絕一個框架的理由,問題整個往上移了一層:這個專案到底該不該用 Flutter Web。
這篇要回答的就是這一題。中間會攤開同一個訂票流程的 Vue 跟 Flutter 真實 code,誠實列出 Canvas 要付的四筆過路費,最後給三個順序不能換的選型問題。
內容來自2026/7/28 天瓏二樓的 Flutter Meetup #36,我的 30 分鐘講題《Hot Reload 之後 — Flutter Web、Vue、React 的三種世界觀》。
台下有 Flutter 工程師、有講師、有各種軟體工程師,資深前端只有我一個。所以這場不可能是 Flutter 教學,論 Flutter 台下比我熟的人一大票。我能帶進去的是他們沒有的那個視角:一個每天在 DOM 裡討生活的人,第一次認真用 Flutter Web 是什麼感覺,又是在哪幾個地方撞到牆。
上個月同一個場地,我還是坐在後排抄筆記的那個人(Meetup #35 的整理在這裡)。這篇是講稿的完整版,把 30 分鐘裡被時間壓掉的東西補回來。
為什麼是我來講這個
我很早就想做 Flutter,卡住我的從來不是它難,是 Dart。我有正職,光把 JavaScript 寫好就佔滿了,沒有多餘心力再養一個語言。那本 Dart 的書買回來放著,一直沒拆。
現在不一樣了。語法交給 AI,我把十幾年的經驗留在該用的地方:驗收、微調、品味。冒煙測試、健全性測試也讓 AI 跑,我做最後那關的品保。開頭說的那個訂票 side project 就是這樣搬去 Flutter Web 的,專案叫 Waypoint Air。

這件事要先說明白,因為它決定了我今天能講什麼、不能講什麼。Waypoint Air 是拿來練手的,沒有上線。所以我談的是開發體感跟世界觀,談不了流量規模下的效能數據。沒有 bundle size 表格,沒有 Lighthouse 分數。想看那個的,這場不是。
兩個 demo 現場都開著,也放在這裡給大家自己玩:
同一個訂票流程,兩套實作。等下所有的 code 對照都是從這兩包直接挖出來的,不是簡化過的教學範例。
補記:寫這篇的時候,Flutter 那個連結是掛的
寫到這裡我自己點了一下,Flutter 版回我 404: NOT_FOUND,Vue 版好好的。
我第一個念頭是「果然 Flutter Web 部署有雷」,差點就把它寫進等下那份代價清單裡。還好先查了才寫。
查下去是這樣:Vercel 上專案還在,最新的 production 部署狀態是 Ready,但 vercel inspect 顯示它的 Aliases 是空的,沒有任何部署綁到那個網址上。再往下挖,問題出在我 Makefile 裡這行:
vercel deploy build/web --prod
Vercel CLI 會拿目錄名當專案名。所以我每次 make deploy,東西都被部署到一個叫 web 的專案去了,從來沒進過 flight-booking-flutter。難怪別名永遠掛不上。改成先進到輸出目錄、綁好專案再部署就正常了:
deploy: build
cd build/web && vercel deploy --prod --yes
我留著這段沒刪,因為它剛好是我等下要講的那件事的實例。這個坑不在 Flutter 的四筆過路費裡,也不在任何一份文件裡,它只是某個工具的某個預設行為,而我卡了一個晚上。等下講到「語言補得掉,生態補不掉」的時候,我說的就是這種東西:真正花掉你時間的,往往不是你不會寫,是沒人先幫你踩過。
(連結現在是通的。如果哪天又掛了,上面那支影片一樣能看,或者直接玩 Vue 版對照。)
現場我先放了一支 20 秒的 demo 影片,讓成品自己說話。搜尋介面、航班結果、選 BEST VALUE,全部是 Flutter Web 跑出來的畫面,而這整包東西是用後面要講的那套 agentic workflow 做完的:
Act 1:先講 Hot Reload,因為那道門檻沒了
舊的痛,前端一定有身體記憶
以前的 Flutter Web:改一行顏色,整頁重整。
你剛填好的表單、剛捲到的位置、剛點進去的第三層頁面,全部歸零。
前端習慣的 Vite HMR 是「改完馬上看到」,這裡是「改完重來一次」。那種感覺很像你辛苦把撲克牌城堡疊到第九層,手才剛放開,一陣風來就整座垮掉。
我在台上刻意在這段停久一點。這段講的不是效能數字,是開發流程被打斷時的那種身體記憶,而這個記憶每個前端都有。

三個版本,把石頭撬開
- Flutter 3.35(2025-08):Web 的 stateful hot reload 預設開啟,不再需要實驗性 flag。navigation stack、表單輸入、捲動位置,reload 後都保留。
- Flutter 3.44(Google I/O 2026):
--web-hot-reload這個 flag 直接被拿掉,因為已經是內建行為。同一版 WebAssembly(Wasm)正式 stable。 - 2026 roadmap:Wasm 要成為 Flutter Web 的預設編譯目標。
Wasm 這件事值得多解釋一句,因為現場有人不熟。它是一種低階的二進位格式,可以把 C、C++、Rust 這類語言編譯成瀏覽器能安全執行的模組,在沙箱裡以接近原生的速度跑。你可以想成:高效能的部分交給 Wasm,JavaScript 繼續負責跟網頁世界互動。兩個不互斥,是互補的。
對 Flutter 這種本來就不走 DOM、自己掌控渲染的框架,這代表 Web 上的執行底盤更成熟,也更接近它原本的運作方式。
重點是:這件事不是「實驗中」,是「已經預設」。
(Hot Reload 轉正這件事我在 Flutter Web Hot Reload 正式版那篇寫得更細,這裡不重複。)
熱重載到底做了什麼
現場我花了一分鐘專門講機制,因為不理解機制,「狀態為什麼保得住」就會變成魔法。
官方定義很直接:熱重載把更新後的原始碼檔案,注入到正在跑的 Dart 運行時(runtime)。運行時拿到新的欄位跟函數之後去更新對應的類別,接著 Flutter framework 自動重建 widget 樹,畫面就跟上了。
整條鏈是這樣:
改一行 → 注入 Dart Runtime → 更新類別 → 重建 Widget 樹 → 立刻看到
這條鏈裡沒有一步叫「重新啟動」,所以路由堆疊、表單輸入、捲動位置、彈窗開著還是關著,全部都在。對前端來說這就是 Vite HMR 的體感,只是它保的是 widget 狀態,不是模組。
(官方文件:https://docs.flutter.cn/tools/hot-reload/)

Agentic Hot Reload:迴圈換人按了
3.44 還多了一個東西叫 Agentic Hot Reload,這是我覺得最值得前端注意的一段。
原本這個迴圈是人按的:你改 code、按存檔、App 毫秒級更新,然後你用肉眼確認結果。
Agentic Hot Reload 把這個迴圈交給 AI 編碼代理人,而且不是「一次性生 code 就結束」。透過新的 Dart MCP server,代理人可以讀整個專案、就地改 code、對正在跑的 App 直接 hot reload,再把現場診斷拉回來:widget tree、render 效能、network log、exception,接著自己跑下一輪。
迴圈從「我改 → 我看」,變成「它改 → 它看 → 它再改 → 我最後 approve」。
厲害的地方不在 AI 會寫 code,在它第一次能對 running app 建立秒級的除錯回饋迴圈。這對前端的意義比對 mobile 更大,因為前端本來就活在瀏覽器的即時回饋裡。
實際用起來像這樣:我丟一句「修一下小尺寸平板橫向時訂單摘要的 overflow」,它改 widget tree、hot reload、看結果、發現 regression,再把那個也補掉。
角色也跟著變了。我不再是每塊磚都自己搬的泥水匠,比較像在旁邊驗收、下判斷、最後簽核的建築師。老實說我到現在還沒完全習慣這個位置,手很癢,常常忍不住自己去改那兩行。
Waypoint Air 就是這樣做完的。

所以「不好寫」這個理由,站不太住了
過去大家不選 Flutter Web,很多時候理由是「開發起來不順」。這個理由現在不成立了。那差別到底在哪?在世界觀。
Act 2:三種世界觀
先做一件事:同一個畫面,F12 打開是兩個世界
現場我開兩個 demo,各按一次 F12。
- Vue 跟 React 的產物是 DOM 節點。瀏覽器認得,DevTools 選得到,爬蟲讀得到。
- Flutter Web 的產物是一張畫布,你選不到任何一個 div。
左邊是元素,右邊是像素。
這一點決定了後面所有的優點跟代價,今天要講的一切都從這裡長出來。

現場是用 F12 演的,比較有戲劇性。但寫這篇的時候我補做了一個更冷酷的版本:用無頭瀏覽器對兩支 demo 各下一次「把這頁的文字抓出來」,就像爬蟲會做的那樣。
Vue 版回給我這些:
Waypoint Air Premium Flight 今天想飛往哪裡?東京 (NRT) 往返程 單程
出發地 台北 (TPE) 目的地 請選擇目的地航點 日程區間 2026-07-15 ➔ 2026-07-22
乘客 & 艙位等級 1 位乘客 · 經濟艙 搜尋精選航班
© 2026 WAYPOINT AIR · PREMIUM LUXURY EXPERIENCE
Flutter 版回給我這些:
flutter typography measurement
就這樣,一個字串,而且那還不是我寫的文案,是 Flutter 自己拿來量字體的內部節點。
兩邊畫面長得幾乎一樣,「今天想飛往哪裡?」「台北 (TPE)」「搜尋精選航班」在螢幕上都看得一清二楚。差別是左邊那些字是文字節點,右邊那些字是顏料。爬蟲、螢幕閱讀器、翻譯外掛、你按住滑鼠想反白複製的那個動作,走的全都是左邊那條路。
這就是等下第一筆過路費的樣子,它不抽象。
同一題,三個答案
三者都在解同一題:資料變了,畫面怎麼跟上。
| 機制 | 一句話 | |
|---|---|---|
| Vue | 響應式模板。Proxy 記住誰用了哪個值,只更新受影響的地方。 | 追蹤依賴 |
| React | 宣告式函式。狀態變,重新執行函式,靠 Virtual DOM 對帳。 | 重跑函式 |
| Flutter | 宣告式 Widget。狀態變,重建 widget 樹,最後畫到 canvas。 | 重建重畫 |
差別在心智負擔擺在哪:框架幫你追,還是你負責描述當下。Vue 幫你記,React 要你重跑,Flutter 要你描述,build() 回傳「此刻該長怎樣」,完全不管上一幀。

樣式住在哪裡
這是前端最有感、也最不習慣的一點。
- Vue:
<style scoped>,樣式跟結構分開住。 - React:CSS-in-JS 或 utility class,樣式靠近元件但仍然是 CSS。
- Flutter:沒有 CSS。padding、顏色、圓角,全部是 widget 的參數。
換句話說,你在傳參數,不是在寫樣式表。
我在台上用的比喻是:Vue / React 比較像從抽屜拿衣服幫元件穿上;Flutter 比較像填一張規格表,直接描述它此刻該長什麼樣。

什麼算一個「元件」
- Vue 的 SFC:一個檔案三段,template / script / style,邊界很清楚。
- React:一個函式就是一個元件,邊界靠你自己拆。
- Flutter:萬物皆 Widget,連 padding、置中、對齊都是一個 widget。
所以 Flutter 的巢狀會很深。我第一次看的時候手很癢想重構,但那就是它的表達方式。

真實 code 對照一:同一顆狀態,兩種寫法
講到這裡,我把 Waypoint Air 裡的真實 code 攤開。
Vue 3 + Pinia(src/store/booking.ts):
export const useBookingStore = defineStore('booking', {
state: () => ({
departure: '台北 (TPE)',
destination: '',
passengers: 1,
selectedFlight: null,
}),
actions: {
setDestination(dest: string) {
this.destination = dest
// 直接賦值,UI 自動更新
},
setPassengers(count: number) {
this.passengers = count
},
},
})
Flutter Web + Riverpod(lib/main.dart):
class BookingState {
final String departure;
final String destination;
final int passengers;
BookingState copyWith({ String? destination, ... }) =>
BookingState(
destination: destination ?? this.destination, ...);
}
class BookingNotifier extends Notifier<BookingState> {
void setDestination(String dest) {
state = state.copyWith(destination: dest);
// 不改舊的,換一顆新 state
}
}
可變 proxy 改一個欄位一行搞定。不可變 copyWith 要多打好幾行字,換來的是可預測的狀態流。
真實 code 對照二:URL 去哪了
這組是我覺得前端最該看的一組。
vue-router(src/router/index.ts):
const routes = [
{ path: '/', component: HomeView },
{ path: '/flights', component: FlightListView },
{ path: '/confirm', component: FlightConfirmView },
{ path: '/success', component: SuccessView },
]
createRouter({
history: createWebHistory(),
routes,
})
// 跳轉後 URL 改變:可分享、可重新整理、可上一頁
router.push('/confirm')
Flutter Navigator(lib/main.dart):
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const FlightListPage(),
),
);
// 頁面推進堆疊,URL 停在原地。
// 想要 /flights 這種網址,
// 要自己上 go_router 宣告 path:
GoRoute(
path: '/flights',
builder: (_, __) => const FlightListPage(),
)
vue-router 的路由天生綁 URL,可分享、可重新整理、可上一頁;Flutter 預設是頁面堆疊,URL 是後加的選配。這是 Web 血統跟 App 血統最直接的分水嶺:在 Web 世界裡「網址會變」是預設值,在 Flutter 裡那是你要自己補的。
真實 code 對照三:同一顆 header,兩種組件
Vue SFC(src/views/FlightListView.vue):
<template>
<header class="flex items-center mb-6">
<button @click="router.push('/')">←</button>
<div>
<h1 class="text-xl font-bold">選擇航班</h1>
<p class="text-xs text-gray-400">
台北 (TPE) ➔ {{ store.destination }}
</p>
</div>
</header>
<div v-if="isLoading" class="space-y-4">
<div v-for="i in 3" :key="i">
<SkeletonLoader />
</div>
</div>
</template>
Flutter build()(lib/main.dart):
Widget build(BuildContext context) {
final destination =
ref.watch(bookingProvider).destination;
return Column(children: [
Row(children: [
IconButton(icon: Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context)),
Column(children: [
Text('選擇航班',
style: TextStyle(fontWeight: FontWeight.bold)),
Text('台北 (TPE) ➔ $destination'),
]),
]),
if (_isLoading)
ListView.separated(itemCount: 3,
itemBuilder: (_, i) => SkeletonCard()),
]);
}
對應關係其實很整齊:v-if 對上 Dart 的 collection if,v-for 對上 itemBuilder。
兩邊做的事完全一樣,差的是你腦中先想「文件」,還是先想「物件樹」。
Flutter 唯一無法被取代的那件事
Vue、React 的戰場是 Web。要上手機,你得再選 React Native 或包一層。
Flutter 從第一天就不是為 Web 設計的,Web 只是它其中一個輸出。同一份程式碼,Web / iOS / Android 出來的像素幾乎一樣。
講白一點,這是聖杯等級的野心:不只要能跑,還要三端看起來幾乎一樣。而這個特性,是它所有優勢的來源,也是它所有代價的來源。

Canvas 的過路費:四筆,一筆都跑不掉
這張投影片決定台下信不信你,所以我講得特別慢。
畫布換來一致,代價要付四筆:
- SEO:爬蟲讀不到內文,那是像素,不是文字節點。
- A11y:螢幕閱讀器要靠額外的 semantics 樹橋接。
- 首屏:要先載 runtime 跟字型,才畫得出第一幀。
- 文字選取:預設選不起來,複製、翻譯外掛都要另外處理。
這四筆不是 bug,是架構選擇的必然帳單。

語言補得掉,生態補不掉
第二筆代價,而且要誠實區分 AI 補得掉哪一半、補不掉哪一半。
語言門檻這半,AI 補掉了,我不會 Dart 也做得出東西。
生態那半補不掉。npm 幾乎什麼都有,pub.dev 的 Web 端套件明顯薄。遇到坑,Vue / React 通常已經有人寫過答案,Flutter Web 常常要自己試,而 AI 也是拿同一批資料在猜。
所以成本不在「學不學得會」,在卡住的時候有沒有人踩過。
那本已經拆封攤開的 Dart 書幫不上我,因為我要找的東西不在書裡,在生態的空格裡。

Act 3:三個問題,順序不能換
我給台下的不是結論,是判斷工具。三個問題就能決定:
一、這頁需要被 Google 找到嗎?需要的話就不要 Flutter Web。這是否決題。
二、這個產品之後要上手機嗎?要的話 Flutter Web 立刻加分。
三、卡住的時候,有人踩過這個坑嗎?注意,問的不是「會不會寫」,那題 AI 已經幫你答了。
順序不能換。第一題先問,因為它最殘酷也最快;一旦答案是「要靠搜尋」,後面兩題其實不用再做了。

Flutter Web 明顯最優解的情境
- 已經有 Flutter App,要補一個 Web 版,幾乎不用想。
- 後台、儀表板、內部工具,登入牆後面,SEO 本來就不重要。
- 地圖、繪圖、遊戲類高互動介面,本來就不靠 DOM。
共通點很清楚:使用者不是從 Google 搜尋進來的。
留給 Vue / React 的情境
- 內容站、行銷頁、部落格、電商商品頁。
- 這些頁面的流量來源就是搜尋,Canvas 是死穴。
- 需要深度整合現有 Web 生態(第三方 SDK、瀏覽器 API、既有元件庫)也一樣。
這不是 Flutter 不好,是這題它本來就沒打算解。
今天的判斷有保存期限
Wasm 已經 stable,roadmap 上要變成 Web 的預設。這背後的意思是,瀏覽器越來越能接受「不是只跑 JavaScript」的高效能模組,Web 的地板正在往上抬。同時 agentic 開發的迴圈也在成形,我那個 side project 就是這樣做完的。這兩件事會繼續往下壓 Flutter Web 的門檻。
語言不再是門檻之後,選型的重點會整個往上移,從「我會什麼」變成「我判斷得準不準」。
所以我今天講的判斷有保存期限,明年這三個問題的答案可能就不一樣。再大膽一點想:如果哪天 AI 能把 canvas 畫面即時逆譯成可索引、可讀屏的語意結構,今天的死穴說不定會變成明天的突破口。技術判斷永遠綁著時間。

三句話帶走
- Hot Reload 補上、AI 補掉語言門檻之後,「不好寫」跟「不會寫」都不再是拒絕的理由。
- 差別在 DOM 還是 Canvas,這決定了 SEO、無障礙跟跨平台。
- 先問「使用者從哪裡來」,再選框架。
工具會一直變便宜,判斷不會。那才是我們這十年累積下來、AI 拿不走的東西。
我不是叫大家改用 Flutter,是叫大家把選型標準拉高。

謝謝天瓏、謝謝 Flutter Taipei 跟 GDG Taipei,也謝謝留下來 Q&A 的每一位。
兩個 demo 再放一次,歡迎自己按 F12 對照:Flutter 版(這支常掛,掛了就看文章開頭那支影片)|Vue 版

最後私心推一下自己的 podcast《妖你聽新聞》EP54,聊中年求職。面試不是只有你被評分,你也有權利好好看看對方。Spotify 這裡聽。