模組三|Routing(Day 11–14)
先備:Day 11(堆疊即狀態)、Day 12(go_router 是這一層的封裝)。本篇的 code 是骨架示意,不用背,看得懂它在同步哪三件事就夠了。
go_router 用起來很舒服,但你遲早會在文件或 stack trace 裡撞到這幾個詞:RouterDelegate、RouteInformationParser、Page、onPopPage。第一次看到 Navigator 2.0 的官方範例時,我的反應跟多數人一樣:「路由而已,為什麼要寫這麼多 code?」
先說清楚這一篇存在的理由,免得你讀到一半覺得被浪費時間:幾乎沒有人會裸寫 Navigator 2.0,包括我。 Waypoint Air 用的是 go_router(Day 12 那份 router.dart),我從頭到尾沒有自己實作過一個 RouterDelegate。那為什麼還要看它?因為 go_router 是它的封裝,抽象層一漏,你在 stack trace 裡看到的是這一層的名字。看不懂這層,出事時你連 Google 什麼關鍵字都不知道。
所以這篇不教你手寫,而是回答那個更有價值的問題:為什麼 Flutter 的路由底層非長這樣不可,而 vue-router 不用?
結論先講:
vue-router 的簡潔是單一平台換來的紅利,不是設計比較高明。
Flutter 的路由要同時滿足「沒有 URL 的世界」和「URL 就是一切的世界」。複雜度不是設計失誤,是題目本身比較難。
vue-router 底層其實也在做「三件套」,只是包得太好你沒感覺。把它拆開,一個迷你版大概長這樣:
// vue-router 核心迴圈的極簡示意
const routes = { '/': Home, '/about': About }
function resolve(path) {
return routes[path] ?? NotFound // ① URL → 要渲染什麼(parse + match)
}
let current = shallowRef(resolve(location.pathname))
window.addEventListener('popstate', () => {
current.value = resolve(location.pathname) // ② 瀏覽器事件 → 更新狀態
})
function push(path) {
history.pushState({}, '', path) // ③ 程式導航 → 寫回 URL
current.value = resolve(path)
}
// <router-view> 只是渲染 current.value 的出口
① 解析 URL、② 監聽 history、③ 把導航寫回 URL——這就是路由器的全部本質。vue-router 能把這些藏起來,靠的是一個奢侈的前提:瀏覽器只有一種。URL 永遠存在、返回鍵永遠是 history.back、一個 URL 對應一個畫面。前提穩固,抽象就能封死。
Flutter 沒有這個奢侈。同一套路由要跑在 iOS(系統返回手勢、無 URL)、Android(實體返回鍵)、Web(網址列、重新整理、深層連結)上。
在看 code 之前,先把核心思想放在你眼前,這樣下面那兩段就只是它的實作:
頁面堆疊不再用 push/pop 命令式地改,而是宣告成狀態的函數。 狀態變了,重建一個
pageslist,框架自己 diff 出該推該退哪些頁。
這跟 Vue 的 UI = f(state) 是同一個哲學,只是 Flutter 把它貫徹到了「有幾層頁面疊著」這件事上。Navigator 2.0 把前面那三件事各自抽成一個可替換的類別,第一個負責 URL 那一端:
class AppRouteParser extends RouteInformationParser<AppState> {
@override
Future<AppState> parseRouteInformation(RouteInformation info) async {
// ① URL → 應用狀態(對應 vue-router 的 resolve/match)
final uri = info.uri;
if (uri.pathSegments.isEmpty) return AppState.home();
if (uri.pathSegments.first == 'product') {
return AppState.product(uri.pathSegments[1]);
}
return AppState.notFound();
}
@override
RouteInformation restoreRouteInformation(AppState state) =>
RouteInformation(uri: state.toUri()); // ③ 狀態 → 寫回 URL
}
兩個方法、兩個方向:parseRouteInformation 把網址翻譯成你自訂的 AppState,restoreRouteInformation 把 AppState 翻譯回網址。回傳型別上的 Future(=JS 的 Promise)與 async 意思跟 JS 完全一樣——因為解析可能要等非同步資料(例如先查權限再決定去哪)。
第二個類別負責堆疊那一端:
class AppRouterDelegate extends RouterDelegate<AppState>
with ChangeNotifier, PopNavigatorRouterDelegateMixin { // 後者是官方樣板 mixin,替你把系統返回鍵接上
AppState state = AppState.home();
@override
Widget build(BuildContext context) {
return Navigator(
key: navigatorKey, // GlobalKey:跨重建都指向同一個 Navigator 的把手
pages: [
// ② 狀態 → 宣告式頁面堆疊:UI = f(state) 延伸到路由層
const MaterialPage(child: HomePage()),
if (state.productId != null)
MaterialPage(child: ProductDetailPage(id: state.productId!)),
],
onPopPage: (route, result) {
if (!route.didPop(result)) return false; // 問這個 route「同意被關掉嗎」
state = AppState.home(); // 返回鍵:自己定義 pop 的語義
notifyListeners();
return true;
},
);
}
// setNewRoutePath 等樣板略
}
逐行導覽,四個地方值得停一下:
with ChangeNotifier, ...——with 是 Dart 的 mixin 語法(把另一個類別的方法「混入」本類別,不算繼承)。這裡混進來的 ChangeNotifier 就是 Day 8 那個會 notifyListeners() 的東西,路由改變時靠它通知框架重建。pages: 那個 list 就是上面那段引言的實作。整個 list 每次都重建,不存在「push 一頁」這個動作。state.productId! 的 !(null assertion:=TS 的 !,我跟編譯器保證這裡有值,猜錯了執行期爆)。這裡值得停久一點,因為 Day 1 我說過 Dart 的 sound null safety 是「編譯器真的知道哪些值可能是 null」——上一行的 if 明明檢查過了,為什麼還要我保證?答案是 Dart 的型別提升只認 final 且私有的欄位:state 是可變的公開欄位,編譯器無法保證「檢查」和「使用」之間沒有別人改過它。把它先接成一個區域變數 final id = state.productId;,! 就不需要了。onPopPage 是你自己定義返回鍵的語義。vue-router 從來不會問你這個問題,因為瀏覽器已經定義好了。順帶一提,這個 callback 在 Flutter 3.16 之後已經被標為棄用,改用 onDidRemovePage——我刻意留著舊的,因為它正好是下一節坑二要講的東西:這一層的 API 自己也在換代。坑一:複雜度的來源是三向同步。 vue-router 同步兩端:URL ↔ 畫面。Flutter 要同步三端:URL ↔ 應用狀態 ↔ 頁面堆疊,而且堆疊還得回應三種平台各自的返回語義。RouteInformationParser 管 URL 這端,RouterDelegate 管堆疊那端,中間用你自己定義的狀態當通貨。拆開看,每個類別都只做一件事——是題目有三個端點,不是 API 愛繞。症狀:你以為只要處理網址,結果發現 Android 實體返回鍵、iOS 邊緣滑動手勢、瀏覽器返回鍵三條路徑都會進到 onPopPage,而它們期待的行為並不完全一樣。
坑二:Navigator 1.0 和 2.0 至今並存。 官方沒有廢掉 1.0,Navigator.push 在 2.0 的 pages 之上依然能用(dialog、bottom sheet 都還是走它)。兩套模型疊在同一個類別裡,是 Flutter 路由文件讓新手迷路的最大原因。症狀:你 Google 到的教學跑不起來,因為它屬於另一個時代的 API;而錯誤訊息不會告訴你「你混用了兩代模型」,只會抱怨某個參數不存在。
坑三:讀懂它,但別手寫它。 官方自己也承認這組 API 樣板太重,go_router 存在的意義就是替你寫掉上面那坨。但當返回鍵行為不符預期、或 deep link 進來堆疊長錯時,你 debug 的對象就是這一層。症狀:stack trace 裡出現一堆你專案裡根本沒寫過的類別名稱(RouterDelegate、RouteInformationProvider),你在自己的 code 裡搜尋一無所獲,因為那些是 go_router 幫你生的。
坑四:這一層對 agent 反而比對人友善。 這篇的骨架 code 是我描述完「三件套各自負責什麼」之後 agent 產的。抽象泛型(RouterDelegate<AppState>)對人類是閱讀負擔,對 agent 是護欄——型別參數對不上,編譯器直接指出是 parser 端還是 delegate 端接錯。症狀:同樣的錯誤發生在 JS 那邊時,路由設定寫錯不會有任何抱怨,畫面就是不動,你得自己從三個可能的環節裡猜是哪一個。
一句話總結:Navigator 2.0 的複雜是把「URL、狀態、頁面堆疊」三向同步加上三種平台返回語義攤開來的誠實價格,不是過度設計——而你不需要親手付這筆錢,只需要看得懂帳單。
明天 Day 14 處理這個三角形裡最 Web 的那條邊:〈深層連結(Deep Link)與 Web URL 同步策略比較〉。那一篇會有兩項一手證據,都是我在部署時才發現的。
如果你卡在語法
with 關鍵字)
! 與 ?)
Future / async)
深入原理
Router class
RouterDelegate class