模組三|Routing(Day 11–14)
先備:Day 11(history mode 的伺服器 rewrite 義務)、Day 12(
context.go與context.replace)。本篇後半有兩項部署的一手證據,那是我實際踩到才知道的。
一個最日常的場景:使用者把 /product/42?tab=reviews 貼給同事,同事點開,畫面必須還原到一模一樣——正確的商品、正確的分頁。在 Vue 這件事幾乎是免費的,免費到它甚至沒有一個專有名詞;在 Flutter 的世界它有名字,叫 deep linking,還有專屬的文件章節。
一件事需要被命名,通常代表它不是預設就成立的。
結論先講:
Vue 把 URL 當免費的響應式 store,Flutter Web 把 URL 當導航目標。
兩邊都做得到深層連結,差別在手感——以及在部署那一關,兩邊要付的設定費完全不同。
Vue 人處理這題的標準手法是:把 URL 當成一個免費的 store。tab、篩選條件、分頁頁碼這類「希望可分享、可重新整理還原」的狀態,不放 Pinia,直接放 query:
// ProductDetail.vue(Composition API)
import { useRoute, useRouter } from 'vue-router'
import { computed } from 'vue'
const route = useRoute()
const router = useRouter()
// 讀:query 直接當響應式狀態用,route 一變 computed 就更新
const tab = computed(() => route.query.tab ?? 'specs')
// 寫:切換分頁 = 更新 URL,replace 避免污染上一頁歷史
function switchTab(name) {
router.replace({ query: { ...route.query, tab: name } })
}
深層連結在這裡根本不是議題:使用者從任何入口帶著任何 URL 進來,router 解析、元件掛載、route.query 就位,整條鏈路和站內導航走的是同一條。唯一要顧的是老朋友——history mode 下伺服器要把所有路徑 rewrite 回 index.html,Day 11 講過了。
而這裡有一件我到部署那天才注意到的事:我的 Vue 專案裡沒有 vercel.json。 整個 repo 根目錄從頭到尾沒有這個檔案,直開 /flights 也不會 404。因為託管平台認得 Vite 專案,SPA rewrite 是框架預設值送的。這個「不用設定」的體感,等一下對照組會把它變得很刺眼。
Flutter Web 要分兩層處理。第一層是 URL strategy:Flutter Web 預設用 hash(/#/product/42),要拿掉 # 得明確宣告,而且從此揹上跟 Vue history mode 一樣的伺服器 rewrite 義務:
import 'package:flutter_web_plugins/url_strategy.dart';
void main() {
usePathUrlStrategy(); // '/#/product/42' → '/product/42'
runApp(const MyApp());
}
第二層才是狀態同步。go_router 把 query 放在 state.uri.queryParameters,但注意讀寫的姿勢跟 Vue 有本質差異:
GoRoute(
path: '/product/:id',
builder: (context, state) => ProductDetailPage(
id: state.pathParameters['id']!,
// 讀:deep link 進來時,query 在 build 當下就拿得到
tab: state.uri.queryParameters['tab'] ?? 'specs',
),
),
// 寫:沒有「更新 query」這種操作,只有「導航到新 URL」
void switchTab(BuildContext context, String id, String name) {
context.replace('/product/$id?tab=$name');
}
能動,deep link 也能還原。但寫過 Vue 的人應該已經聞到味道了——這是每次都重新導航,不是綁定。
第一項:Waypoint Air 的 Flutter 端有一個 vercel.json,而且它全部的內容就是這四行:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Vue 端沒有這個檔案,Flutter 端非有不可。同一個平台、同一種 SPA 行為,差別只在託管方認不認得你的框架。而這個檔案還有一個尾巴:flutter build web 的產物裡不會包含它,得手動 cp 進 build/web/——那條指令躺在我的 Makefile 裡,Day 23 會把它整段攤開。
第二項在 web/index.html:17,這是 flutter create 產生的樣板留下的:
<base href="$FLUTTER_BASE_HREF">
那不是變數語法,是一個等著被字串取代的佔位符。flutter build web 會用 --base-href 參數的值把它換掉;如果你的 App 要部署在子路徑(例如 /app/)而忘了傳這個參數,路由會全部對不上。Vue 這邊的等價物是 vite.config.ts 的 base 選項——差別在於 Vite 沒設就是預設值 /,而 Flutter 是把一個明顯不合法的字串留在 HTML 裡等你處理。
坑一:URL 同步是導航,不是響應式綁定。 Vue 裡 route.query 是響應式來源,改它、watch 它,跟操作 ref 一個手感。go_router 裡 URL 是導航的結果:每次 context.replace 都會重跑 redirect、重建 route state。想要「多個篩選條件即時同步到 URL」這種在 Vue 裡一個 watch 收工的需求,在 Flutter 得自己節流、自己組 URL 字串、自己防止導航迴圈。這是我實測下來整個模組三手感落差最大的一點。症狀:拖動一個滑桿,每一格都觸發一次導航,畫面閃爍、瀏覽器歷史被塞滿幾十筆,按返回鍵要按二十次才離得開這一頁。
坑二:deep link 必須是入口,不是路徑。 stack-first 的殘影還在:很多 Flutter 範例假設使用者從首頁一路點進來,狀態沿路傳遞。deep link 一來這個假設就碎了——/product/42 可能是這個 session 的第一個畫面,商品資料還沒載、登入狀態未知。紀律是:每一頁都要能只靠 URL 冷啟動。Vue 人其實有同款紀律(別假設 store 已被上一頁填好),只是 Vue 的路由結構會自然逼你遵守,Flutter 得自己記得。症狀:從首頁點進去一切正常,把同一個網址複製到無痕視窗打開就白畫面或紅屏,因為那一頁在讀一個從沒被填過的 store。
坑三:hash 或 path,牽動的不只美觀。 hash 模式免伺服器設定、重新整理絕不 404,但 # 後面的東西不會進 server log、部分分析工具視為同一頁;path 模式乾淨,但每個託管環境都要補 rewrite——上面那四行 vercel.json 就是這筆帳單,而 Vue 端連帳單都沒收到。另外先潑一盆 Day 25 的冷水:不管選哪個 strategy,爬蟲打開 deep link 看到的都是同一個 <canvas>,分享出去的預覽全站長一樣。URL 同步救得了使用者體驗,救不了 SEO。症狀:改用 path 模式之後本機一切正常,部署上去點站內連結也正常,唯獨直接貼網址進來拿到平台的 404 頁——因為那個 rewrite 設定檔沒有被複製進產物裡。
坑四:這種錯誤 agent 自己抓得到。 這篇的 query 同步邏輯,agent 第一版就寫出了導航迴圈(在 build 裡呼叫 context.replace)。但它不是靠我發現的——widget test 直接卡死超時,agent 自己讀輸出、自己修掉。這一篇讓我對 Flutter Web 的懷疑減少了一格,理由是回饋迴圈的確定性:在路由這種「錯了不會爆、只會怪」的領域,有一個會當場卡死的測試,比任何靜態檢查都有用。症狀:如果沒有那個測試,畫面看起來只是「有點閃」,你會以為是動畫沒調好,然後花一個下午調動畫。
一句話總結:Vue 把 URL 當免費的響應式 store,Flutter Web 把 URL 當導航目標——deep link 兩邊都做得到,但「即時同步」的手感差距,是 stack-first 出身留下的最後一道疤;而部署那一關的設定費差距,是它留下的第二道。
模組三到此收工。明天進模組四,回到 Vue 人的舒適圈開場:〈Pinia store 設計:state/getters/actions〉——我會把 demo 那支 56 行的 store 全檔攤開,包括裡面兩處我自己寫的 any。
如果你卡在語法
深入原理