模組三|Routing(Day 11–14)
先備:Day 11(URL-first vs Stack-first 的世界觀差異)。本篇會出現
state.pathParameters['id']!這種寫法,那個!的意思 Day 13 才正式交代,先當成「我保證這裡有值」。
昨天的結論是:Flutter 原廠路由的世界觀是「堆疊即狀態」,跟 vue-router 的「URL 即狀態」方向相反。而 go_router——Flutter 官方維護的路由套件——本質上就是一次世界觀的翻譯工程:把「URL 對應頁面」的宣告式寫法帶回 Flutter。
結論先講:
go_router 用起來像 vue-router,但它翻譯的是寫法,不是行為。
對 Vue 人來說這是整個模組三最有既視感的一篇,你會一直有「這不就是 vue-router 嗎」的錯覺——然後在導航動詞、守衛時機這兩個地方被咬一口。我自己的 demo 就被咬過,而且咬痕還留在原始碼的註解裡,等一下給你看。
拿一個有巢狀路由、動態參數、登入牆的典型設定當對照組——注意這是教學用的完整版,我自己的 demo 一個都沒用(Day 11 已經招認過):
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: AppLayout, // 外層 layout,內有 <router-view>
children: [
{ path: '', component: Home },
{ path: 'product/:id', component: ProductDetail, props: true },
{
path: 'admin',
component: Admin,
meta: { requiresAuth: true },
},
],
},
{ path: '/login', component: Login },
],
})
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isLoggedIn()) {
return { path: '/login', query: { redirect: to.fullPath } }
}
})
三個 Vue 人習以為常的機制:巢狀路由靠 children + <router-view> 出口、參數靠 :id 宣告、守衛是一條可以做任何事的攔截管線。
同一個需求,go_router 版:
final router = GoRouter(
routes: [
ShellRoute(
// ShellRoute ≈ 外層 layout,child 就是 <router-view> 的出口
builder: (context, state, child) => AppLayout(child: child),
routes: [
GoRoute(path: '/', builder: (_, __) => const HomePage()),
GoRoute(
path: '/product/:id',
builder: (_, state) =>
ProductDetailPage(id: state.pathParameters['id']!),
),
GoRoute(path: '/admin', builder: (_, __) => const AdminPage()),
],
),
GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
],
// 守衛:全域只有一個 redirect,回傳 null = 放行
redirect: (context, state) {
final loggingIn = state.matchedLocation == '/login';
if (!isLoggedIn() && state.matchedLocation == '/admin') {
return '/login?redirect=${Uri.encodeComponent(state.uri.toString())}';
}
if (isLoggedIn() && loggingIn) return '/';
return null;
},
);
void main() {
runApp(MaterialApp.router(routerConfig: router));
}
逐項對照:GoRoute 的巢狀 routes ≈ children;:id 語法直接照搬,從 state.pathParameters 取值;ShellRoute 扮演 layout 加 <router-view>;redirect 扮演 beforeEach。宣告式路由表、URL 驅動畫面、深層連結直接可用——昨天列的原廠痛點,一次補齊大半。
但我要放一段真實的 code,因為它講的事情比上面整段教學都重要。這是 Waypoint Air 的 lib/router.dart:15-22,我移植 Vue 版時寫的:
/// `App.vue` is a bare `<router-view />` with no `<transition>` wrapper, so
/// route changes are instantaneous.
CustomTransitionPage<void> _page(Widget child) => CustomTransitionPage<void>(
child: child,
transitionDuration: Duration.zero,
reverseTransitionDuration: Duration.zero,
transitionsBuilder: (context, animation, secondaryAnimation, widget) => widget,
);
這幾行在做的事是:把 Flutter 的預設轉場關掉。go_router 預設每次換頁都套 MaterialPage 的平台轉場動畫,而在 Flutter Web 上「平台」是由瀏覽器所在的作業系統推導出來的——同一份 code,我在 macOS Chrome 看到的是從右滑入,Windows Chrome 的讀者看到的是縮放淡入。我的 Vue 版 App.vue 只有一個裸的 <router-view />,沒包 <transition>,換頁是瞬間的。兩邊要長得一樣,就得把轉場關掉。
這裡要招認一件事:go_router 其實內建了 NoTransitionPage,一行就能達成同樣的效果,從 2.5.0 版就有了。我當時沒找到它,於是自己用 CustomTransitionPage 把兩個方向的 duration 設成零、讓 transitionsBuilder 原封不動回傳 widget——寫出來的東西跟官方那個類別的定義一字不差。我把它留在 repo 裡沒改,因為它剛好示範了移植時最貴的成本:不是寫不出來,是不知道框架已經幫你寫好了。
十條路由,每一條的 pageBuilder 都得包這個 _page()。而真正的重點在於:這件事本身不是 go_router 的問題,是兩個框架的預設值不同——教學不會提,因為對只寫 Flutter 的人來說,平台轉場本來就是想要的行為。
同一個檔案再往下,:55-64 還有一處:
/// `router.back()` — falls back to `/` when there is nothing to pop, which is
/// what a browser history stack of length 1 effectively does here.
void routerBack(BuildContext context) {
final router = GoRouter.of(context);
if (router.canPop()) {
router.pop();
} else {
router.go('/');
}
}
Vue 那邊 router.back() 一行的事。Flutter 這邊要自己判斷「堆疊上還有沒有東西可以 pop」,沒有的話手動導回首頁——因為使用者可能是直接貼網址進來的,此時堆疊只有一頁,pop() 會把 App 弄成空白。
坑一:go 和 push 是兩個世界。 vue-router 只有一種導航(router.push,差別只在 history 記錄)。go_router 有兩種:context.go('/product/42') 是「宣告我要到這個位置」,會依路由表重算整個頁面堆疊;context.push 才是往堆疊上疊一頁。症狀:用 go 進到詳情頁,按返回鍵卻沒回到你來的那個列表頁,而是跳到路由表結構上的父層——畫面對不上你的心智模型,而且沒有任何錯誤訊息告訴你用錯了動詞。
坑二:守衛被收窄成 redirect。 vue-router 的守衛是攔截管線,可以取消導航、可以等非同步請求、可以在元件內寫離開確認。go_router 的 redirect 只回答一個問題:「要不要改去別的 URL?」它會被反覆呼叫直到穩定,所以裡面不能有副作用、不適合塞慢速非同步。登入狀態要先放在外部(例如 Riverpod),再透過 refreshListenable 通知路由重算——守衛從「攔截點」變成「純函數」。症狀:redirect 一直回傳新位置、始終不收斂,go_router 數到第五次就丟 too many redirects 給你;而如果你在裡面 await 一個慢速 API(型別上是允許的,它收 FutureOr<String?>),畫面則會整個卡在導航中間不動。
坑三:框架預設值也要移植,不只是語法。 上面那個 transitionDuration: Duration.zero 就是活證據。移植的時候你會很自然地只對照「路由怎麼宣告」,然後在跑起來的那一刻發現手感不對。症狀:兩個版本並排開,功能一模一樣,但 Flutter 版每次換頁都慢半拍、還有一個 Vue 版沒有的滑動方向——你去翻路由設定卻找不到任何一行寫了動畫,因為那是預設值。
坑四:字串路由的型別債,這邊反而有官方解法。 昨天說 '/product/42' 打錯字要 runtime 才爆——go_router 配 go_router_builder 可以產生 typed routes,ProductRoute(id: '42').go(context),路徑拼錯、參數漏傳都變成編譯錯誤。這正中本系列的獨家論點:我全程用 AI agent 寫這些 code,typed routes 讓 agent 的路由錯誤在 flutter analyze 就被打回,不必等我點開瀏覽器一頁一頁點。症狀:在還沒導入 typed routes 的專案裡,你改了某個路徑字串,忘了改另外三個呼叫點,測試全過、build 全過,上線後某個入口點下去是白畫面。JS 生態要等 TanStack Router 才把這件事做到位,vue-router 至今主要靠字串——這一篇讓我對 Flutter Web 的懷疑減少了一格,減少的原因不是它更好用,是它把錯誤往前挪了。
還沒完的部分。 go_router 用起來像 vue-router,但它底下包的是 Navigator 2.0——當你需要 debug 返回鍵行為、或看懂錯誤訊息裡出現 RouterDelegate 時,抽象層就會漏。這是明天的主題。
一句話總結:go_router 把 vue-router 的宣告式世界觀成功翻譯進 Flutter,代價是守衛變純函數、導航動詞一分為二,而真正會咬你的是那些沒人寫在教學裡的框架預設值——換來的 typed routes 則是 Vue 這邊反而該羨慕的東西。
明天 Day 13 往下挖一層:〈Navigator 2.0 底層機制:為什麼 Flutter 路由這麼複雜〉。先預告一件事:那一篇我會誠實說明,幾乎沒有人會裸寫 Navigator 2.0,包括我自己。
如果你卡在語法
深入原理
CustomTransitionPage
NoTransitionPage(我當時沒找到的那個)