模組三|Routing(Day 11–14)
先備:Day 2(widget 是建構子、不是標籤)、Day 7(頁面級 state 走
setState)。這一篇的 Dart code 只有一段,看不懂可以先跳到「差異與坑」。
路由大概是 Vue 開發裡最無感的一塊:createRouter 十分鐘設定完,之後幾個月不會再打開那個檔案。我自己的訂票 demo 就是這樣——src/router/index.ts 從第一天寫完就再也沒動過。但正因為它太順了,你可能從沒想過自己到底默認了哪些假設。而 Flutter Web 的路由,恰好把這些假設一條一條拆給你看,所以模組三的第一篇,我要先把 vue-router 的心智模型攤在桌上,之後三篇才有座標可以對。
結論先講,你可以帶著它讀下去:
vue-router 的一切都建立在「URL 是應用狀態的一等公民」這個前提上。
你宣告哪個 URL 對應哪個元件,剩下的(history 監聽、元件切換、返回鍵)框架全包。Flutter 的預設路由模型則是從 mobile 長出來的:頁面是一疊卡片,URL 只是事後補上的副產品。方向相反,這個差異是接下來四篇所有坑的源頭。
標準配置長這樣,十年來沒什麼大變:
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Home },
{ path: '/product/:id', component: ProductDetail, props: true },
{ path: '/admin', component: Admin, meta: { requiresAuth: true } },
],
})
// 全域前置守衛:最常見的登入牆
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
兩個重點值得回顧,因為它們在 Flutter 那邊都會變形:
1. history mode 的本質。 createWebHistory 用 History API(pushState / popstate)讓 URL 長得像 /product/42 而不是 /#/product/42。代價大家都踩過:使用者直接開 /product/42 或按重新整理時,伺服器上並沒有這個檔案,所以要設 rewrite 全部導回 index.html。這是 SPA 的原罪,跟 Vue 無關——記住這點,Day 14 和 Day 23 會再遇到它兩次。
2. 守衛是一條攔截管線。 beforeEach → 路由獨享的 beforeEnter → 元件內守衛,一路可以放行、改道、取消,甚至可以在裡面等一個非同步請求回來再決定。這個自由度,等 Day 12 看到 go_router 的 redirect 時你會發現被收窄了。
不過我要誠實交代對照組的規模。這是 Waypoint Air Vue 端的路由表全文(src/router/index.ts:13-24):
const routes = [
{ path: '/', name: 'home', component: HomeView },
{ path: '/login', name: 'login', component: LoginView },
{ path: '/date-select', name: 'date-select', component: DateSelectView },
{ path: '/passenger-select', name: 'passenger-select', component: PassengerView },
{ path: '/flights', name: 'flights', component: FlightListView },
{ path: '/confirm', name: 'confirm', component: FlightConfirmView },
// …共 10 條,全部同一個形狀
]
十條扁平路由,每一條都是 path + name + component。沒有動態參數(/flights 查的是 store 裡的條件,不是 URL 上的 :id)、沒有巢狀路由、沒有 lazy loading(十個 view 全部在檔頭靜態 import)、沒有任何守衛、沒有 404 fallback。也就是說,上面那段教科書範例裡最值錢的三個特性,我的 demo 一個都沒用到。
我把這件事寫出來,是因為它剛好說明了為什麼路由這塊「感覺很簡單」:多數 App 的路由表確實就長這樣。真正的複雜度不在宣告,在你開始需要參數、守衛、巢狀之後——而那正是兩個生態開始分岔的地方。
先看 Flutter 的「原廠設定」,也就是 Navigator 1.0 的命名路由。我刻意先不上 go_router,因為原廠模型才能暴露心智落差:
void main() {
runApp(MaterialApp(
routes: {
'/': (context) => const HomePage(),
'/admin': (context) => const AdminPage(),
},
// 動態參數?沒有 '/product/:id' 這種寫法,
// 得自己在 onGenerateRoute 手動 parse 字串。
onGenerateRoute: (settings) {
final uri = Uri.parse(settings.name ?? '/');
if (uri.pathSegments.length == 2 &&
uri.pathSegments.first == 'product') {
final id = uri.pathSegments[1];
return MaterialPageRoute(
builder: (_) => ProductDetailPage(id: id),
);
}
return null;
},
));
}
// 導航是命令式的:把一頁「推」進堆疊
Navigator.pushNamed(context, '/product/42');
看出味道了嗎?Navigator.push 的動詞是「推」——它操作的是一個頁面堆疊,不是一個 URL。在 mobile 上這完全合理:App 沒有網址列,返回鍵就是 pop。但搬到 Web,幾件 Vue 人視為空氣的事情開始要錢:
/#/product/42),要漂亮路徑得自己切換 path strategy,Day 14 詳談。onGenerateRoute 就是 vue-router 一行 :id 的等價物,十幾行換一個冒號。redirect。坑一:心智模型是 URL-first vs Stack-first。 vue-router 是「URL 變了,畫面跟著變」;Navigator 1.0 是「畫面堆疊變了,URL 盡力跟上」。方向相反。這解釋了為什麼 Flutter Web 早期最常見的抱怨是瀏覽器返回鍵行為怪異——返回鍵動的是 URL,但真正的狀態在堆疊裡,兩者只是「盡力」同步。症狀:使用者按了返回鍵,網址列變回上一頁,畫面卻停在原地;或者反過來,畫面退了但網址沒動,重新整理就跳回錯的頁。
坑二:伺服器 rewrite 的債兩邊都要還。 Vue 的 history mode 要設 rewrite,Flutter Web 拿掉 hash 之後一樣要。差別在資料量:Vue 社群踩了十年,每個託管平台都有現成教學;Flutter Web 這邊少一個量級,而且錯誤訊息完全不會提示你是這個原因。症狀:本機 flutter run 一切正常,部署上去點連結也正常,唯獨直接貼網址或按 F5 會拿到平台的 404 頁——因為請求真的送到伺服器了,而那裡沒有這個檔案。
坑三:守衛邏輯沒有地方放。 Vue 把攔截集中在 router 這一層,全站只有一個入口;Navigator 1.0 沒有這層,登入檢查會散落在每一個 push 呼叫點。這也是為什麼 Day 12 的 go_router 在 Flutter Web 幾乎是必選項,而不是可選項。症狀:新增一個需要登入的頁面,你在三個地方加了檢查、漏了第四個,未登入的使用者從某個角落的按鈕就直接進去了,而且沒有任何工具會提醒你漏了。
坑四:字串路由是整個 API 型別最弱的一環。 這週的範例 code 我依然沒有手寫 Dart,onGenerateRoute 的回傳型別是 Route<dynamic>?,agent 少接一個 null case,flutter analyze 當場報錯——這是本系列獨家論點的又一次兌現。但 '/product/42' 這個字串本身,編譯器一個字都幫不上。症狀:把 '/profile' 打成 '/profle',編譯全綠、analyze 全綠,跑起來點下去畫面一片空白或直接丟例外。這個伏筆留給 Day 12 的 typed routes 收。
一句話總結:vue-router 給你的其實是「URL 即狀態」這個世界觀,而 Flutter 原廠路由的世界觀是「堆疊即狀態」——工具可以換,世界觀的翻譯才是成本。
順帶說一件我改觀的事:寫這篇之前我以為 Navigator 1.0 只是「比較土」,看完 onGenerateRoute 才發現它不是同一個抽象層級的東西——它更像是 Express 的 handler,而不是 vue-router 的 routes 陣列。明天 Day 12,看官方答案 go_router 怎麼把宣告式路由帶回 Flutter:〈go_router 宣告式路由:對照 vue-router 設定方式〉。
如果你卡在語法
深入原理
Navigator class
MaterialApp.onGenerateRoute