模組五|建置與部署(Day 20–24)
先備:Day 1(兩個對照 demo 的來歷)、Day 11(SPA 的 rewrite 原罪,Day 23 會再撞一次)。本篇沒有 Dart code,是替模組五校正量尺的一天。
進入模組五之前,先招認一件尷尬的事:我經手的 Vue 專案,vite build 從開專案到上線,config 一行都沒改過。倒不是懶,Vite 的預設就是夠好,好到我們忘了它底下替我們做了多少事。
接下來四天我要拿 Flutter Web 的 build 產物來對照。秤不準自己的重量,就沒資格說別人重,所以今天先把 Vite 的 build 管線拆開,順便把四個之後每天都會冒出來的名詞——tree shaking、code splitting、AOT、deferred import——都在今天這篇講過定義。
結論先講:
Vite 的最佳化是一排你可以逐項打開的旋鈕,Flutter 的最佳化是編譯器替你做完的既成事實。
旋鈕多代表自由,也代表責任落在你身上;既成事實代表省事,也代表那就是你的天花板。這句話會在後面四天反覆兌現。
Vite 是兩套系統縫在一起的產物:dev 模式只用 esbuild 把 node_modules 依賴先併好(pre-bundling),你自己寫的 code 靠瀏覽器原生 ESM 直接載,完全不打包;vite build 則整包交給 Rollup 做完整打包。我們平常感受到的「快」大多來自前者,上線品質卻全部取決於後者——這也是很多人 dev 一切正常、build 完才出事的原因。
vite build 預設就替你做完的事,有四項請記住名字,因為模組五每天都會用到:
import(),Rollup 就自動切出一個獨立 chunk。vue-router 的 route-level lazy loading 就是這件事的包裝。index-BqX3xxxx.js),內容一變檔名就變。這是整套快取策略的地基,Day 23 會回收這個伏筆。modulepreload 注入(讓瀏覽器提早抓後續會用到的模組),全部預設開啟。講完預設,把我自己的設定攤開。下面這段是 flight-booking-vue 的 vite.config.ts,不是節錄,是全檔:
// vite.config.ts(flight-booking-vue,全檔 7 行)
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})
七行,其中兩行 import、一行註解、一行空白。沒有 build 區塊、沒有 rollupOptions、沒有 manualChunks——這個帶著 vue-router、Pinia、Tailwind v4、六個執行時依賴與十個開發依賴的 app,最佳化設定是零。
我把它原樣留著,因為它就是模組五的前提:Day 22 那組數字是「零最佳化的 Vue」對上「零最佳化的 Flutter Web」,兩邊都沒開外掛,比起來才公平。
唯一被動過的一行不在 config 裡,在 package.json:8:
"build": "vue-tsc -b && vite build"
這行的意思是:跑 vite build 之前,先讓 vue-tsc 把整個專案的型別檢查一遍,型別不過就不會產出 dist。之所以要多這一道,是因為 Vite 的轉譯器 esbuild 只轉譯、不檢查型別——它把 .ts 的型別標註剝掉就送走,型別錯誤一個都不會攔。Vue 專案裡的型別安全,是靠這條 && 手動接上去的——語言本身沒給你這道保險。這個細節請先記在心裡,等一下對照 Flutter 時會用到。
如果哪天真的需要壓體積,慣用的三板斧長這樣。這段是示範用的設定,我的 demo 沒有採用:
// vite.config.js —— 需要動手最佳化時的起手式
import { visualizer } from 'rollup-plugin-visualizer'
export default {
plugins: [visualizer({ gzipSize: true })], // 板斧一:先量,不要瞎猜
build: {
target: 'es2020',
rollupOptions: {
output: {
manualChunks: {
// 板斧二:把不常變的依賴獨立成一包,讓瀏覽器快取活得久一點
vendor: ['vue', 'vue-router', 'pinia'],
},
},
},
},
}
板斧三是路由層級的動態 import:() => import('./views/Report.vue')。首屏只載首屏要用的東西,其他頁面等使用者真的走過去再說。三板斧的共同點是——你隨時可以只開其中一個,也可以一個都不開,程式照跑。
對照組先劇透一口:flutter build web,一個指令,沒有 config 檔可調。沒有 rollupOptions、沒有 manualChunks、沒有 plugin 生態,pubspec.yaml 裡也找不到任何等價的 build 區塊。
Dart 編譯器(dart2js 或 dart2wasm)做的是整個程式的 AOT 編譯(ahead-of-time:事先編譯成目標格式,不在瀏覽器裡才編)。這個差別比字面上大:Rollup 是把一堆模組黏起來再刪掉沒用到的,編譯器則是把整個程式當一個封閉世界看待,連 Material icons 字型都會自動 subset——--tree-shake-icons 預設開啟,你用了哪幾個 icon 就只留哪幾個字符。
它也順手做掉了我剛剛在 Vue 端手動接上的那件事。Dart 沒有「只轉譯不檢查」這個檔位,型別錯就是編譯不過,flutter build web 天生就是一道型別閘門,不需要誰在指令列上加 &&。
唯一接近 code splitting 的原語是 deferred import(Dart 的「先別載這包,等我叫你」):在 import 後面掛 deferred as 別名,那個 library 就不會進首包,要用之前先 await 一次 loadLibrary()。它跟 Vite 的 import() 目的相同,代價完全不同——Vite 那邊你什麼都不用做,寫了 import() 就自動切;Dart 這邊你得手動宣告、手動載入、手動處理載入中的畫面。完整的可跑範例排在 Day 22,因為要看到它的效果,得先看到體積數字。
想插手打包過程?基本上沒門,編譯器說了算。
坑一:別去找不存在的旋鈕。 我一開始反射性地想找「Flutter 版的 rollupOptions」,翻文件翻了一晚上,最後結論是它不存在。Flutter 的哲學是編譯器比你懂最佳化——以 AOT 語言來說大致成立,但也代表 bundle 過重時,你手上的槓桿遠比 Vite 少。症狀:你在 flutter build web --help 裡上上下下找了十分鐘,想找一個對應 manualChunks 的參數,然後發現整份說明只有二十幾個 flag,沒有一個跟切 chunk 有關。
坑二:tree shaking 的作用層不同。 Vite 在模組層做,範圍是「你 import 進來的東西」;Dart 在語言層做,範圍是「整個程式可觸及的 code」,理論上更徹底。但兩邊都有一個共同的盲點——它們都刪不掉引擎本體,而 Flutter Web 的引擎本體正好是體積的大頭。症狀:你把 app 的 code 刪到只剩一個 Text('hi') 重新 build,產物只小了幾十 KB,因為佔位子的從頭到尾都不是你寫的那些。
坑三:code splitting 的顆粒度差一個等級。 Vite 是「寫了 import() 就自動切」,Flutter 是「手動宣告 deferred、手動 await、切完還是切不動引擎」。同一個名詞,一邊是免費的預設行為,一邊是要規劃的架構決策。症狀:你把某個大頁面改成 deferred import,滿懷期待重測 Lighthouse,首載數字幾乎沒動——省下來的那幾十 KB 淹沒在 MB 級的引擎裡了。
坑四:型別閘門的位置,決定了 agent 能跑多遠。 Vue 端我得自己在 build script 寫 vue-tsc -b && 才有型別檢查,拿掉它,一個型別錯到離譜的分支照樣 build 成功、照樣部署上線;Flutter 端這道閘門焊死在編譯器裡,拆不掉。這對 agent 工作流的差別很直接——同樣是讓 agent 大改一輪,Dart 那邊「build 過了」約等於「型別上沒亂寫」,JS 那邊「build 過了」只代表「語法沒錯」。症狀:agent 改完 Vue 專案回報「build 成功」,你上線後才發現某個 prop 型別根本對不上,而 CI 從頭到尾一片綠。
一句話:Vite 給你一整面調音台,Flutter 只給你少數幾個開關——先把調音台上自己專案的基準值量清楚,明天才有得比。
而我的基準值誠實得有點難堪:七行 config、零最佳化。Day 21 進正題,flutter build web 到底編出了什麼?CanvasKit、skwasm,以及一個已經死掉的 HTML renderer——順便招認我在 renderer 這題上同樣什麼都沒調。
如果你卡在語法
深入原理