模組六|SEO/SSR(Day 25–28)
先備:Day 21(Flutter Web 的產物長什麼樣)。本篇會把我自己那支 13 行的
index.html攤開,它是這四天所有論證的起點。
進入 SEO 專題前,先把武器庫盤點清楚。接下來三天我要證明 Flutter Web 在這個戰場有多裸奔,但要量出差距,得先講清楚 Vue 陣營的起跑線在哪——Nuxt 把 SSR/SSG 做到「你不特別關掉它、它就存在」的程度,這件事很多人用了三年也沒想過底層在幹嘛。今天回顧原理,明天再看 Flutter Web 交出什麼卷子。
結論先講:
Vue 的答案是一條光譜,Flutter Web 的答案是一個點。
這句話會在 Day 28 的決策框架裡再出現一次,今天先把它的地基鋪好。
先回到問題的原點。純 CSR 的 Vue SPA,伺服器回傳的 HTML 幾乎是空的:
<body>
<div id="app"></div>
</body>
真正把 app 拉起來的是那行 entry script:<script type="module" src="/assets/index.js"></script>。也就是說,HTML 裡先只有掛載點,內容要等 JS 接手才出現。
這不是我隨手編的示意——這是我自己那支 Vue demo 的 index.html 節錄,我只把 entry script 移到前一句行內說明:
<!-- flight-booking-vue/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>temp-project</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
實際檔案的 entry 是 src="/src/main.ts" 的 <script type="module">。注意那個 <title>temp-project</title>——那是 npm create vue@latest 產生的樣板值,我做完整個 App 都沒回頭改它。沒有 description、沒有任何 Open Graph 標籤、沒有結構化資料。我先自曝這件事,是因為接下來三天我要用它當對照組,而它自己也不乾淨。
內容要等 JS 下載、執行、fetch 資料之後才存在。Googlebot 雖然會執行 JS,但要排進 render queue,而社群平台的預覽爬蟲(FB、LINE、Slack)與多數非 Google 爬蟲根本不跑 JS——它們看到的就是上面那個空殼。
SSR 的解法是讓同一份 app 先在伺服器跑一次:Node 端執行 Vue app、renderToString 吐出完整 HTML、瀏覽器先顯示這份「死的」HTML,接著 client bundle 載入、hydration 接管——把事件監聽掛回去、把 reactivity 建起來,頁面才活過來。爬蟲拿到的是 hydration 之前的完整內容,這就是 SSR 對 SEO 有效的全部原因:內容不依賴瀏覽器執行 JS。
SSG 則是把「伺服器跑一次」提前到建置期:nuxi generate 把每條路由各 render 一遍,產出純靜態 HTML,丟 CDN 就能上線。代價是路由要可枚舉、內容變了要重建,所以它最適合部落格、文件站這類更新頻率以天計的場景。
Nuxt 的價值在於把這整套變成設定檔等級的決策,同一個專案裡每條路由可以走不同策略:
// nuxt.config.ts
export default defineNuxtConfig({
routeRules: {
'/': { prerender: true }, // 建置期就渲染成靜態 HTML
'/blog/**': { isr: 3600 }, // 增量再生,內容過期自動重建
'/dashboard/**': { ssr: false }, // 登入牆後,退回純 CSR
}
})
外加 useAsyncData 的 payload 機制(server 抓過的資料序列化給 client,hydration 不重抓)、自動的 server/client entry 分離。這些細節平常無感,正因為無感,才叫基礎建設。
另一個常被低估的角色是 Nitro——Nuxt 3 的 server engine。routeRules 裡那些 ISR、SWR 的快取語意是它實作的,同一份 server code 還能打包成 Node、serverless、edge 等各種部署目標。對本篇主題的意義在於:SSR 從「你要自己養一台 Node server、自己處理快取失效」變成「build 時選個 preset」,維運門檻直接降了一個量級。我說 Nuxt 把 SSR 的成本壓到可接受,講的不只是開發體驗,是整條部署鏈都被打平了。
一句話:沒有對應物。不是「還沒做好」,是結構上不存在。
flutter build web 的產物長這樣:一個近乎空殼的 index.html、一支 flutter_bootstrap.js、編譯後的 main.dart.js(或 WASM 路線的產物)、加上 CanvasKit 渲染引擎。UI 要等 main() 在瀏覽器裡執行起來才存在,而且長在 <canvas> 上,不是 DOM。
<!-- flutter build web 產物 index.html(節錄)——這就是伺服器能給爬蟲的全部 -->
<body>
</body>
實際載入 Flutter runtime 的那行是 <script src="flutter_bootstrap.js" async></script>;爬蟲先拿到的仍然是一個沒有可讀內容的空 body。
那能不能學 Nuxt,在伺服器端先跑一次?Dart 當然能寫 server(shelf 生態存在),但 Flutter 沒有「把 widget tree render 成 HTML 字串」這條路——widget 的最終輸出是像素,不是標記語言。renderToString 這個動詞在 Flutter 的世界裡根本不成立:你頂多在 server 端把畫面 rasterize 成一張圖,而圖對爬蟲的價值是零。
所以 Nuxt 那張 routeRules 選單——SSR、SSG、ISR、per-route 切換——在 Flutter Web 不是「選項比較少」,是選單本身不存在。官方文件也直說 Flutter Web 適合 app 中心的體驗,不適合文件型、內容型網站,這點倒是罕見地誠實。
坑一:Vue 這邊也不是免費午餐。 hydration mismatch(server 與 client render 結果不一致)是 Nuxt 工程師的經典噩夢;server 端沒有 window,DOM 操作得等 onMounted;useAsyncData 和 onMounted 裡 fetch 的時機差異,十年老手也踩過。我回顧這些不為炫耀 Vue 多好,只想強調一件事:SSR 是有維運成本的能力,Nuxt 只是把成本壓到可接受。這個誠實很重要——Day 28 做決策時,Vue 這邊的成本也要進帳本,不能只算 Flutter 的。症狀:本機開發一切正常,部署上去 console 冒出一串 hydration mismatch 警告,畫面閃一下才穩定下來,而你根本看不出是哪個元件造成的。
坑二:心智模型的根本差異。 SSR 是「同一份 app 在兩個 runtime 各跑一次,第一次輸出 HTML」;Flutter Web 是「一份 app 只在瀏覽器跑一次,輸出像素」。前者天生能餵爬蟲,後者天生不能——這是架構定義問題,優化解不掉。症狀:你去搜「Flutter Web SSR」,找到的全是幾年前的 issue 討論串,底下沒有結論,只有官方回覆說這不在 roadmap 上。
坑三:SSR 也不是免費的效能。 每個請求都要在 server render 一次,TTFB 直接取決於你的 API 快不快;SSG/ISR 之所以流行,就是把這筆成本搬到建置期或快取層去付。選 rendering mode 本質上是在「內容新鮮度」和「伺服器成本」之間畫線,而不是在「快」和「慢」之間二選一。症狀:改成 SSR 之後 Lighthouse 分數確實漲了,但某天後端 API 變慢,整站的首字節時間跟著一起爛掉——而純 CSR 時代這兩件事是分開的。
坑四:別把 SSG 當萬靈丹。 動態內容(價格、庫存、即時資料)SSG 會過期,所以才有 ISR/SWR 這些精細控制。記住這個光譜的存在,Day 28 做決策框架時會用到:Vue 的答案是一條光譜,Flutter Web 的答案是一個點。症狀:你把商品頁全部 SSG,上線兩週後客服開始接到「網頁上寫有貨、下單卻說缺貨」的抱怨。
順帶回扣一次獨家論點:這一整套 rendering mode 的選擇,是我全系列少數覺得 agent 幫不上忙的地方——它沒有編譯器可以問,routeRules 寫錯不會有任何紅線,錯誤要等真實流量進來才浮出水面。強型別守得住語法,守不住架構決策。
一句話:Nuxt 讓「爬蟲拿到完整 HTML」是預設福利,Flutter Web 則連參賽資格都得另外想辦法買。明天 Day 26〈Flutter Web 的 SEO 困境:爬蟲看到的是什麼〉,我直接開 view-source 給你看爬蟲眼中的 Flutter 網站有多空。
如果你卡在語法
深入原理