模組五|建置與部署(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-vue 和 flight-booking-flutter。Dart 那份的 code 由 AI agent 產出,我負責提需求、審 diff、驗結果——今天要攤開的兩段 code,就躺在 Flutter 那份裡。
結論先講:
Vue 的部署契約是「hash 即版本」,一條規則走天下;Flutter Web 是「bootstrap 即版本」,三個檔案都得保持新鮮。
Vue SPA 部署三件套,寫過的人閉著眼睛都能背:
/reports/42 而不是 /#/reports/42)下,/reports/42 這種深層 URL 直接打到伺服器會 404,所有路徑都要 fallback 到 index.html
index.html 設 no-cache——換版本靠 HTML 裡的引用換 hash寫成 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 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.js(index.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 在供舊檔。
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-Policy 與 Cross-Origin-Embedder-Policy,一組開啟跨來源隔離的 HTTP 標頭)、任何 _headers 或 vercel.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 事件,發現 deltaX/deltaY 帶小數就攔下來,四捨五入成整數再造一個新事件丟回去。
為什麼會這樣?macOS 的觸控板慣性捲動會產生帶小數的 delta 值,而 Flutter Web 的事件處理路徑在某個版本上對此有斷言(assertion,程式內部的自我檢查,條件不成立就直接中止)。這個坑的特徵值得記住:它只在 macOS 加 Chrome 加觸控板這個交集出現,用滑鼠滾輪測不出來,用 Windows 測不出來,flutter test 更測不出來。 而它炸的時候不是掉幀,是整個 app 停住。
我要說的重點不是這個特定 bug(它在新版可能已經修掉),是那個檔案的地位:web/index.html 在 Vue 世界裡幾乎是唯讀的樣板,在 Flutter Web 世界裡是你的緊急補丁層。引擎有 bug、載入要客製、白屏期要塞畫面,能動手的地方就這一個。它是你在那顆封閉引擎外面唯一的插座。
平台能力也要對表。上面那些規則在自架 nginx 上都好辦,但選純靜態託管平台時,能不能設自訂 header 是硬門檻:
_headers、vercel.json),no-cache 規則與 COOP/COEP 都能落地把兩邊的快取規則並排,差異一目了然:
| 檔案 | 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。
如果你卡在語法
深入原理