模組五|建置與部署(Day 20–24)
先備:Day 20(Vite 的預設最佳化與 deferred import)、Day 21(產物裡多了一顆引擎)。本篇所有數字都附量法,你可以自己跑一次。
先講結論再講過程:這一局 Vue 贏一個數量級,而且 Flutter Web 追不平——這是結構性的,不是調校還沒做完。但「輸一個量級」到底是幾十 KB 對上幾 MB?有沒有攤提得掉的場景?今天實際量,而且連原始碼行數一起量。
結論先講:
Vue 賣的是自助餐,先拿到什麼先吃;Flutter Web 賣的是套餐,菜沒上齊不開動。
這句話同時解釋了 bundle size 和首屏體驗兩件事——它們在 Flutter Web 上是同一件事,因為載入模型把兩者綁死了。
量測工具就三個:dist 目錄的實際檔案大小、rollup-plugin-visualizer 看組成、Lighthouse 看 FCP/LCP。真正該量的是 gzip 之後的傳輸量,光看檔案大小會嚇錯地方:
npm run build
# 逐檔算 gzip 後的位元組數,這才是使用者真的要下載的量
find dist -name '*.js' -exec sh -c 'gzip -c "$1" | wc -c' _ {} \;
實測於本系列環境(數字會因版本而異):flight-booking-vue 這種含 vue-router、Pinia、Tailwind v4 的專案,JS gzip 後總量落在幾十 KB 的量級,其中 Vue 框架本體的 vendor chunk 三十幾 KB。首屏方面,HTML 一到就有東西可畫,冷快取加 4G 節流的模擬下,FCP 也能壓在一秒上下。
關鍵不在數字本身,在漸進性:HTML 先渲染、CSS 到了套樣式、JS 到了才互動——每一步使用者都看得到東西在動。就算網路爛到 JS 載了五秒,前面那四秒他看到的也是一個有骨架、有配色、有文字的頁面,而不是一片白。這個「先給一半」的能力是 Web 平台的預設行為,不需要任何人替它爭取。
把 Day 21 那份產物逐項上秤。實測於本系列環境(數字會因版本而異):
main.dart.js:hello world 等級的 app 就有 1MB 上下(minified;gzip 後小一截,但仍是幾百 KB 的量級)canvaskit.wasm:1.5MB 起跳。這是引擎本體,跟你的 app 寫得多小完全無關--tree-shake-icons 預設開啟,Material icons 只留用到的字符,這點做得不錯這些數字你可以自己驗,靶就是 Day 1 那組對照專案。線上產物量一次是這樣:
# Flutter 版:光 main.dart.js 就約 640 KB,還沒算渲染引擎
# Vue 版對照:assets/ 底下那支 index-*.js gzip 後約 64 KB
curl -sH 'Accept-Encoding: gzip' -o /dev/null -w '%{size_download}\n' \
https://flight-booking-flutter.vercel.app/main.dart.js
一邊幾十 KB,一邊幾百 KB 起跳、而且那還只是你自己的 code——引擎本體的重量是另外加的。這就是我說「量級差」而不說「差一截」的意思。
更痛的是載入模型:全有或全無。引擎沒下載完、app 沒初始化完,畫面就是白的——沒有漸進渲染這回事,FCP 直接被整包下載時間綁架。你能做的只剩三件事:把包變小、把白屏期填滿、把回訪的成本攤提掉。
main.dart.js 為什麼比 index-*.js 大一個量級?一部分是引擎黏合層,另一部分更根本——同一支 App,Flutter 端就是要寫比較多行。兩個 repo 都在 2026-08-04 收工,撰稿當天量了一次:
find src -type f \( -name '*.vue' -o -name '*.ts' -o -name '*.css' \) | xargs cat | wc -l # 2694
find lib -name '*.dart' | xargs cat | wc -l # 7180
2694 對 7180,約 2.7 倍。這個倍數不是抽象的統計,隨便挑一個功能拆開就看得到。載入中的骨架卡片,Vue 端是 FlightListView.vue:50-62 這 13 行:
<!-- FlightListView.vue:50-62:一張骨架卡,靠 Tailwind class 描述全部視覺 -->
<div v-for="i in 3" :key="i"
class="p-5 rounded-2xl bg-slate-900/60 border border-slate-900 flex flex-col justify-between h-32">
<div class="flex justify-between items-center">
<SkeletonLoader variant="rect" width="5rem" height="1.25rem" />
<SkeletonLoader variant="rect" width="4rem" height="1.5rem" />
</div>
<div class="flex justify-between items-end mt-4">
<div class="space-y-2">
<SkeletonLoader variant="text" width="10rem" />
<SkeletonLoader variant="text" width="6rem" />
</div>
<SkeletonLoader variant="circle" width="1.75rem" />
</div>
</div>
Flutter 端同一張卡是 flight_list_view.dart:123-174 的 _skeletonCard(),整整 52 行。節錄前半:
// flight_list_view.dart:123-174 的前半(節錄,全長 52 行)
Widget _skeletonCard() {
return Container(
// `p-5 rounded-2xl bg-slate-900/60 border border-slate-900 h-32`
height: 128,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.slate900.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.slate900),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SkeletonLoader(variant: SkeletonVariant.rect, width: 80, height: 20),
SkeletonLoader(variant: SkeletonVariant.rect, width: 64, height: 24),
],
),
// …下半段第二個 Row 再 24 行,結構相同
四個地方值得停一下:
p-5 一個 class,Dart 端要拆成 padding 加一個 EdgeInsets.all(20);rounded-2xl 要拆成 decoration 裡的 BorderRadius.circular(16)。一個字變兩層巢狀。5rem、1.25rem 這些相對單位在 Dart 端全部被換算成 80、20 這種絕對像素,還得留註解記住原值。換算是移植時手動做的,做錯了沒有人會告訴你。flex justify-between 對到 Column 加 mainAxisAlignment,這組還算好對;space-y-2 就沒有對應物了,得插一顆 SizedBox(height: 8) 手動補間距。13 行對 52 行,四倍。這一頁就是那個 2.7 倍的縮影,而多出來的每一行最後都要被編譯進 main.dart.js。
官方與實務上緩解首載的招數,最有力的一個是 Day 20 提過的 deferred import。它的完整用法長這樣:
// Dart 的 deferred import:Flutter 唯一的 code splitting 原語
import 'package:my_app/report_page.dart' deferred as report;
Future<void> openReport(BuildContext context) async {
await report.loadLibrary(); // 使用者真的走到,才下載這個 library
if (context.mounted) {
Navigator.push(context,
MaterialPageRoute(builder: (_) => report.ReportPage()));
}
}
注意 await 那一行:Vite 的 import() 你寫下去就自動切 chunk,Dart 這邊你得自己決定切點、自己等載入、自己處理等待期間畫面要顯示什麼。其他手段還有三個:在 index.html 塞一個純 HTML/CSS 的 loading 畫面撐住白屏期、用 --wasm 換取更快的啟動、確保 host 開好 gzip/brotli,以及把回訪流量交給 service worker 快取攤提(Day 23 細講它的副作用)。
坑一:量級差是地板,不是天花板。 Vue 的幾十 KB 還能再壓,manualChunks、路由懶載、圖片格式全都是槓桿;Flutter 的 1.5MB+ 引擎是常數項——deferred import 只能切你自己的 code,切不動 CanvasKit。症狀:你花一整天做 deferred 改造,把首包裡自己的 code 砍掉三成,重測發現總下載量只少了 8%,因為分母裡有一顆你動不了的引擎。
坑二:首訪與回訪是兩個世界。 service worker 快取生效後,回訪近乎秒開。所以「進來看一篇就走」的內容站會被首載殺死,「一開盤掛一整天」的看盤工具卻完全無感——Day 1 決策樹的分支二就是這麼來的。症狀:你自己用了兩週覺得快得很,使用者卻在客訴白屏——因為你的瀏覽器早就快取好了,而每個新使用者都要從頭來一次。
坑三:別被 demo 騙。 光纖加熱快取,什麼 demo 都流暢。請開 DevTools 的 network throttling,勾 Disable cache,用 4G 量一次再說話。症狀:你在會議室裡 demo 得漂漂亮亮,主管回家用手機開同一個網址,等了七秒還是白的,隔天你收到一封標題寫「這樣可以上線嗎」的信。
坑四:我故意不放精確對照表。 renderer 路線、Flutter 版本、gzip 等級、託管平台的壓縮設定,任何一項變動數字就會漂移。我寧可給你量法讓你自己跑,也不給一張看起來很專業、三個月後就過期的對照表——量級差與載入模型差,才是幾年後還成立的結論。症狀:你拿著網路上某篇文章的精確 KB 數去說服主管,對方當場打開兩個網站量給你看,數字對不上,而你連對方哪裡量錯都說不出來。
agent 工作流註記:這一輪的量測腳本、deferred 改造、行數統計全是 agent 做的,快而準——因為每一步都有 shell 的回傳值可以驗。但「該量什麼、哪個結論站得住」還是得人來。量測是 agent 最容易一本正經胡說的環節,它會給你一個格式漂亮的表格,而你要到很後面才發現某一格是它推論出來的,不是量出來的。強型別能擋住 code 裡的胡說,擋不住散文裡的——所以數字要附量法,這是我在這篇能給出的唯一防線。
一句話:Vue 賣的是幾十 KB 的漸進式載入,Flutter Web 賣的是 MB 級的全有或全無——選型前先問你的使用者一次停留多久。
而 2694 對 7180 那個倍數,是這個量級差在原始碼層的預告:多寫的行最後都會變成要下載的位元組。Day 23 換到部署現場,靜態託管、路由 rewrite、快取設定,三個坑一次踩完,還有兩段我親手寫進 repo 的證據。
如果你卡在語法
深入原理