iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0
Modern Web

Vue 前端工程師視角看 Flutter Web系列 第 23

Day 23|部署踩坑:靜態託管、路由 rewrite、快取設定

  • 分享至 

  • xImage
  •  

模組五|建置與部署(Day 20–24)

先備:Day 11(history mode 的 rewrite 原罪)、Day 14(那個 vercel.json 的伏筆,今天兌現)、Day 21(COOP/COEP)。

部署 SPA(Single Page Application,單頁應用:整站只有一個 HTML,換頁靠 JS 重畫)後重新整理就 404——這個坑,Vue 人十年前就踩過了。Flutter Web 讓你有機會再踩一次,還附贈幾個全新的:一個不帶 hash 的 main.dart.js、一個會讓使用者卡在舊版的 service worker(瀏覽器在背景常駐、專門幫你快取檔案的一支腳本),一份 build 完就消失的部署設定檔,以及一段我為了某牌觸控板手寫的 polyfill。

前面兩個是知識,你查得到;後面兩個是我的 repo 裡真實躺著的 code,今天當主角。

先交代靶子。這系列從頭到尾拿同一支訂票 App 當對照組:它叫 Waypoint Air,一條從選日期、選人數、選航班到確認、付款的流程,我用 Vue 和 Flutter Web 各刻了一份,兩個 repo 分別叫 flight-booking-vueflight-booking-flutter。Dart 那份的 code 由 AI agent 產出,我負責提需求、審 diff、驗結果——今天要攤開的兩段 code,就躺在 Flutter 那份裡。

結論先講:

Vue 的部署契約是「hash 即版本」,一條規則走天下;Flutter Web 是「bootstrap 即版本」,三個檔案都得保持新鮮。

Vue 怎麼做

Vue SPA 部署三件套,寫過的人閉著眼睛都能背:

  1. 路由 rewrite:history mode(vue-router 用瀏覽器 History API 做的模式,網址長成 /reports/42 而不是 /#/reports/42)下,/reports/42 這種深層 URL 直接打到伺服器會 404,所有路徑都要 fallback 到 index.html
  2. 快取分層:帶 content hash 的資產設一年 immutable;index.html 設 no-cache——換版本靠 HTML 裡的引用換 hash
  3. 壓縮:gzip/brotli 開好開滿

寫成 nginx 就這幾行,三件套各佔一段:

location / {
  try_files $uri $uri/ /index.html;   # history mode fallback
}
location /assets/ {
  add_header Cache-Control "public, max-age=31536000, immutable";
}
location = /index.html {
  add_header Cache-Control "no-cache";
}

這套之所以能簡單成這樣,靠的是 Day 20 那個資產指紋:檔名裡有 content hash,內容變了檔名就變,舊檔案和新檔案永遠不會撞名。於是「哪些能久放、哪些不能放」的判斷退化成一條規則——看檔名有沒有 hash。而 vite build 產出的 dist/ 就是完整的可部署目錄,你不需要在 build 之後對它動任何手腳。

Flutter Web 怎麼做

flutter build web 出來的 build/web/ 也是純靜態檔,理論上丟哪都行——但三件套每一件都有變體。

路由:Flutter Web 預設用 hash strategy(URL 長成 /#/reports/42),伺服器只會看到 /,反而不需要 rewrite。想要乾淨 URL,得自己切到 path strategy:

import 'package:flutter_web_plugins/url_strategy.dart';

void main() {
  usePathUrlStrategy(); // URL 變成 /reports/42 這種乾淨路徑
  runApp(const MyApp());
}

切了之後,rewrite 需求就跟 Vue 的 history mode 一模一樣。部署在子路徑還要記得 flutter build web --base-href /myapp/,對應 Vite 的 base 選項。

快取main.dart.js(你的 Dart code 編成 JS 之後的主程式)與 flutter_bootstrap.jsindex.html 唯一載入的那支啟動腳本,由它把引擎和主程式拉進頁面)的檔名不帶 content hash,版本化靠 bootstrap 內嵌的 build 指紋處理。照搬 Vue 的「JS 一律 immutable 一年」策略,使用者會拿到新 HTML 配舊 JS,直接炸。

Service worker:Flutter 預設註冊 flutter_service_worker.js 做資源快取。好處是 Day 22 實測 bundle size 與首屏載入時間時說的那件事——回訪流量可以靠它攤提掉;壞處是它自己也會被 HTTP 快取——header 設錯時,使用者重新整理好幾次都還在跑上一版。這不是理論:我在測試環境親眼看著 agent 連部署三次,都以為改動沒生效,最後發現是 service worker 在供舊檔。

證據一:build 產物不含你的部署設定檔

Day 14 談深層連結與 Web URL 同步策略時,我埋過一個伏筆,說 Flutter 端非有 vercel.json 不可,而 flutter build web 的產物裡不會包含它。今天把那條指令整段攤開。這是 flight-booking-flutter/Makefile:3-8

# Makefile:3-8(flight-booking-flutter)
build:
	flutter build web --release
	cp vercel.json build/web/

deploy: build
	vercel deploy build/web --prod

中間那行 cp 就是整篇的第一顆地雷。vercel.json 是 Vercel 的託管設定檔,我那份裡只有一條規則:把所有路徑導回 index.html,也就是上面說的 SPA rewrite。它躺在專案根目錄,build/web/ 則是 build 每次重生的目錄——Flutter 的 build 只認識 web/ 樣板和 lib/ 原始碼,你的託管平台設定檔對它來說是外人。所以 SPA rewrite 規則、將來要加的 COOP/COEP header(Cross-Origin-Opener-PolicyCross-Origin-Embedder-Policy,一組開啟跨來源隔離的 HTTP 標頭)、任何 _headersvercel.json 的內容,全部得在 build 之後補回去。

Vite 那邊沒有這個動作,因為 vercel.json 待在專案根目錄就會被 Vercel 讀到,你部署的是整個 repo;而 Flutter Web 的部署單位是 build/web/ 這個子目錄,根目錄的設定檔在部署範圍外。這一行 cp 我寫進 Makefile 不是為了懶——是因為它漏掉的那次,我對著全站 404 找了半小時,而錯誤訊息只有 Vercel 的預設 404 頁。

順帶說一句:這也是為什麼部署動作值得寫成 Makefile 或 script。手打三個指令,你總有一天會漏掉中間那個。

證據二:有些坑只在特定瀏覽器加特定硬體上炸

第二段證據更妙。這是 web/index.html:44-60,我手寫塞進 <body> 的一段 script:

<!-- web/index.html:44-60(flight-booking-flutter,節錄) -->
<script>
  // Polyfill to prevent Flutter Web trackpad assertion crash on macOS Chrome
  window.addEventListener('wheel', function(e) {
    if (e.deltaX % 1 !== 0 || e.deltaY % 1 !== 0) {
      e.stopImmediatePropagation();
      const cloneEvent = new WheelEvent('wheel', {
        deltaX: Math.round(e.deltaX),
        deltaY: Math.round(e.deltaY),
        deltaZ: Math.round(e.deltaZ),
        deltaMode: e.deltaMode, bubbles: true, cancelable: true
      });
      e.target.dispatchEvent(cloneEvent);
    }
  }, { capture: true, passive: false });
</script>

那行英文註解是我當時寫給未來的自己的:防止 Flutter Web 在 macOS Chrome 上因為觸控板事件觸發 assertion 崩潰。整段做的事很土——攔截所有 wheel 事件,發現 deltaXdeltaY 帶小數就攔下來,四捨五入成整數再造一個新事件丟回去。

為什麼會這樣?macOS 的觸控板慣性捲動會產生帶小數的 delta 值,而 Flutter Web 的事件處理路徑在某個版本上對此有斷言(assertion,程式內部的自我檢查,條件不成立就直接中止)。這個坑的特徵值得記住:它只在 macOS 加 Chrome 加觸控板這個交集出現,用滑鼠滾輪測不出來,用 Windows 測不出來,flutter test 更測不出來。 而它炸的時候不是掉幀,是整個 app 停住。

我要說的重點不是這個特定 bug(它在新版可能已經修掉),是那個檔案的地位web/index.html 在 Vue 世界裡幾乎是唯讀的樣板,在 Flutter Web 世界裡是你的緊急補丁層。引擎有 bug、載入要客製、白屏期要塞畫面,能動手的地方就這一個。它是你在那顆封閉引擎外面唯一的插座。

平台能力也要對表。上面那些規則在自架 nginx 上都好辦,但選純靜態託管平台時,能不能設自訂 header 是硬門檻:

  • Netlify / Vercel / Cloudflare Pages:都支援以設定檔宣告 header(_headersvercel.json),no-cache 規則與 COOP/COEP 都能落地
  • GitHub Pages:完全不能自訂 header——service worker 快取策略聽天由命,skwasm(Flutter 把 Dart 直接編成 WebAssembly/WASM 的那條渲染路線,要靠 COOP/COEP 才吃得到多執行緒)直接出局。Demo 可以,正式產品別放這
  • S3 + CloudFront:可以但繁瑣,Cache-Control 要在物件 metadata 或 behavior 層逐條設,上傳腳本漏一條就中獎

差異與坑

把兩邊的快取規則並排,差異一目了然:

檔案 Vue(Vite) Flutter Web
index.html no-cache no-cache
主要 JS hash 檔名,可設 immutable main.dart.js 無 hash,必須 no-cache 或短快取
service worker 通常沒有 flutter_service_worker.js 必須 no-cache
渲染引擎 不存在 canvaskit/ 可長快取

坑一:把 Vue 的快取直覺整包搬過來。 「JS 一律 immutable 一年」在 Vite 是安全的,因為檔名帶 hash;在 Flutter Web 是自殺,因為 main.dart.js 永遠叫這個名字。症狀:你部署了新版,自己開無痕視窗看一切正常,回頭用平常那個分頁重整,畫面還是舊的——而且重整幾次都還是舊的,因為瀏覽器根本沒去問伺服器。

坑二:service worker 自己也會被 HTTP 快取。 它負責幫你管別人的快取,但沒有人管它。flutter_service_worker.js 被快取住的那一刻,整套更新機制就凍結在那個版本。症狀:連續部署三次都「沒生效」,你開始懷疑 CI、懷疑平台、懷疑自己有沒有存檔,最後在 Network 面板看到那支 service worker 的 Status 寫著 200 (from disk cache)

坑三:WASM 的 MIME type 與 COOP/COEP。 canvaskit.wasm 要以 application/wasm 回應——MIME type 就是伺服器回給瀏覽器的那張「這是什麼檔案」標籤——老舊 nginx 設定或自架環境可能回 application/octet-stream,streaming compile(邊下載邊編譯)失效,載入更慢甚至直接失敗;而 Day 21 拆解 Flutter Web build 產物與 renderer 選項時說的多執行緒渲染,還要 host 給你 COOP/COEP。這兩件事的共同點是——它們都在平台能力那一層,程式碼裡改不了。症狀:同一包產物,本機 flutter run -d chrome 順得很,丟到某個免費靜態託管上就是慢半拍,DevTools 的 Console 一片安靜,什麼都沒說。

坑四:部署設定活在弱型別世界,agent 在這裡是瞎子。 nginx conf、header 規則、平台設定檔、Makefile 的那行 cp,全都不會在 build 時爆——agent 寫錯了,CI 一片綠,然後線上默默供舊檔。這跟 Day 20 回顧 Vite build 流程時比過的那道型別閘門正好是對照組——Dart 型別寫錯就編譯不過,JS 那邊得自己在 build script 掛型別檢查:編譯器管得到的地方,agent 快得驚人;管不到的地方,你得自己驗。我的做法是把上面那張快取對照表直接寫進 CLAUDE.md 當部署規範,讓 agent 每次產設定檔都對表自檢——把人踩過的坑變成 agent 的 lint 規則,這才是可複利的踩坑。症狀:agent 回報「部署完成、設定已更新」,一切看起來都對,直到一週後某個使用者說他的畫面從上禮拜就沒變過。

換版流程我最後固定成四步:部署 → 硬重整驗證 bootstrap 指紋有變 → 二次重整確認 service worker 完成接管 → 舊分頁放一晚驗證隔天自動更新。聽起來囉嗦,但這四步是我讓 agent 反覆部署踩出來的最小集合——少任何一步,都有機率把「舊版卡住」的鬼故事留給真實使用者。

小結與下一篇

一句話:Flutter Web 部署不難,難在你帶著 Vue 的快取直覺上場——先把「哪些檔案不能快取」背下來再上線。

而那行 cp vercel.json build/web/ 和那段 wheel polyfill,是我這個 side project 裡最不起眼、卻最說明問題的兩段 code:一個提醒你 build 產物不等於部署產物,一個提醒你封閉引擎外面只有一個插座。Day 24 做個案例研究收尾模組五——把一個 three.js 捲動式登陸頁搬到 Flutter Web,會發生什麼事?順便兌現 Day 3 講 CSS 樣式系統搬家時埋的那個伏筆:CSS 三行的打勾動畫,Flutter 端配了一支 216 行的手寫 SVG path parser。

參考資料

如果你卡在語法

深入原理


上一篇
Day 22|Bundle size 與首屏載入時間實測比較
系列文
Vue 前端工程師視角看 Flutter Web23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言