iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Modern Web

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

Day 26|Flutter Web 的 SEO 困境:爬蟲看到的是什麼

  • 分享至 

  • xImage
  •  

模組六|SEO/SSR(Day 25–28)

先備:Day 25(SSR 為什麼對 SEO 有效、我那支 13 行的 index.html)。今天要看的是同一個問題在 Flutter Web 這一側的樣子。

打開你手邊任何一個 Nuxt 網站按 view-source,再打開一個 Flutter Web 網站按 view-source——今天整篇文章,其實就是這兩個畫面的對比。一邊是完整的文章內容,一邊是一支 <script>。剩下的篇幅只是解釋為什麼這件事救不回來。

結論先講:

劣勢可以優化,困境是結構。

Vue 的 CSR 是劣勢,套一層 Nuxt 就補回來;Flutter Web 的是困境,因為它交出去的東西裡沒有內容可以被索引。

Vue 怎麼做

先建立爬蟲的分層心智模型。搜尋引擎處理一個頁面大致三關:抓取原始 HTML → (只有部分爬蟲,如 Googlebot)執行 JS 做二次渲染 → 索引。需要跑 JS 的頁面會先被丟進 render queue(爬蟲的等待佇列,排隊多久不保證),而每個網站能被抓的量還受 crawl budget(搜尋引擎願意花在你網站上的抓取配額)限制——兩件事加起來的意思是:你的內容需不需要跑 JS 才看得到,直接決定它多快、甚至會不會被索引。Nuxt SSR 的策略是在第一關就交卷:view-source 裡直接有 <h1><p><meta>,不用賭第二關的 render queue 什麼時候輪到你。

而且 meta 是路由級、資料驅動的:

// Nuxt:每條路由的 title/og 在 server 端就寫進 HTML
const { data: post } = await useAsyncData(() => fetchPost(route.params.id))

useSeoMeta({
  title: () => post.value.title,
  description: () => post.value.excerpt,
  ogTitle: () => post.value.title,
  ogImage: () => post.value.cover,
})

這對社群分享特別關鍵:FB、LINE、Slack 的預覽爬蟲不執行 JS,只看第一關的 HTML。SSR 讓每篇文章分享出去都有正確的標題和縮圖,這在 Vue 世界是理所當然到沒人會拿出來講的事。

順帶一提,驗證方法本身也值得學起來:curl -s <url> | grep '<h1>' 模擬第一關;Search Console 的網址檢查工具可以看 Googlebot 第二關實際渲染出什麼。這兩個動作加起來五分鐘,比任何 SEO 顧問的簡報都誠實——接下來對 Flutter Web 的驗屍,用的就是這兩把刀。

Flutter Web 怎麼做

實測。對一個部署好的 Flutter Web app 下 curl,模擬不跑 JS 的爬蟲,拿到的 <body> 主要內容只有一行:<script src="flutter_bootstrap.js" async></script>。整個 body 就這樣,沒有任何看得見的文字。

這不用相信我,Day 1 那組對照專案就是拿來被驗的,自己 curl 一次就好。但這裡要先誠實補一刀,因為它正好戳破一個常見誤解:我的 Vue 對照組是純 Vite SPA,不是 Nuxt——所以它 curl 出來也只有一個空的 <div id="app"></div>,第一關同樣交白卷。

curl -s https://flight-booking-vue.vercel.app/      # → <div id="app"></div>,空的
curl -s https://flight-booking-flutter.vercel.app/  # → 只有 flutter_bootstrap.js

樣板值從來沒被改過

光是空殼還不算最糟。真正說明問題的是我兩邊的 <head> 都長什麼樣——因為那部分不需要跑 JS,爬蟲百分之百拿得到,也就是說,它是我唯一能無條件送到爬蟲面前的東西。

Flutter 端 web/index.html 的兩行(flutter create 產生、我從沒改過):

<!-- web/index.html:21 -->
<meta name="description" content="A new Flutter project.">
<!-- web/index.html:32 -->
<title>flight_booking</title>

web/manifest.json 也一樣:

{
  "name": "flight_booking",
  "description": "A new Flutter project.",
  "theme_color": "#0175C2"
}

那個 #0175C2 是 Flutter 的預設藍,而我這支 App 從頭到尾是暗色底配翡翠綠與橘。也就是說,使用者把它加到主畫面時,看到的品牌色是框架送的、跟畫面上任何一個像素都對不起來。

Vue 那邊也沒好到哪去——Day 25 那支 13 行的 index.html 裡是 <title>temp-project</title>

兩邊我都沒改。 我把這件事寫出來,是因為它比任何論證都有力:唯一那塊零成本、爬蟲保證讀得到的欄位,兩個專案我都留著樣板值。這不是 Flutter 的問題,是我的問題——但它也剛好說明了為什麼「Flutter Web 的 SEO 很差」這句話常常被講得太籠統。差的層次有很多層,最上面那層其實跟框架無關。

**第一關的差別不是 Vue 對 Flutter,是 SSR 對 CSR。**真正的分水嶺在第二關:JS 跑完之後,Vue SPA 的 DOM 裡長出的是可被解析、可被夾起來的元素,爬蟲賭贏 render queue 就拿得到內容;Flutter Web 的 DOM 裡長出的是一張 <canvas>,賭贏了也拿不到東西。所以 Vue 的 CSR 是「有救的差」——套一層 Nuxt 就補回第一關;Flutter Web 是「補了第一關也沒用」,因為第二關本身就沒有內容可交。這才是 Day 25 的 SSR 光譜在這裡真正的意義。

順帶一提,Flutter 版 curl 回來的 <title>flight_booking<meta name="description">A new Flutter project.——這兩個預設值不改掉會原封不動上線,是 Flutter Web 專案很常見的裸奔現場。

第一關:零內容。那 Googlebot 執行 JS 之後呢?等 Dart 程式跑起來、CanvasKit 載入完,DOM 裡出現的是一個 <flutter-view>,裡面一個 <canvas>。你頁面上所有的標題、文章、按鈕文字,全是畫在畫布上的像素。對爬蟲來說那不是慢出現的內容,是不存在的內容。

而且別忘了第二關本身的代價:Googlebot 的 render queue 是共享資源,需要 JS 渲染的頁面本來就要排隊、消耗 crawl budget,等待從數分鐘到數天都有可能。CSR 網站是「排到隊之後總算有內容」,Flutter Web 是「排到隊之後依然沒有內容」——它把 JavaScript SEO 的所有等待成本照單全付,卻拿不到任何回報。這是我說「困境」而不是「劣勢」的原因:劣勢可以優化,困境是結構。

有人會搬出語意樹(semantics tree):Flutter 確實能生成一層帶 aria-label 的隱形 DOM 節點,甚至可以在啟動時強制開啟——

// 能救無障礙(screen reader),救不了 SEO
void main() {
  WidgetsFlutterBinding.ensureInitialized();
  SemanticsBinding.instance.ensureSemantics();
  runApp(const MyApp());
}

但這層是給輔助科技用的,不是給搜尋引擎的。Google 從未承諾把 aria-label 當正文內容索引,實務上它的排名信號趨近於零;而且語意樹的結構是為朗讀順序設計的,不是為資訊架構設計的——扁平的標籤集合,沒有標題階層、沒有連結權重,連內部連結的爬行都成問題。把它當 SEO 解藥,是這個題目最常見的一廂情願。

官方立場其實寫得很白:Flutter Web 的定位是 app-centric 體驗,不是文件型、內容型網站。這不是社群的酸言,是 docs.flutter.dev 自己的 FAQ。

差異與坑

坑一:「Googlebot 會跑 JS,所以沒差。」 這句話對 CSR 的 Vue SPA 勉強成立——JS 跑完至少有 DOM 文字,只是慢、只是不穩。Flutter Web 是另一個層級:JS 跑完是一張圖。它的 SEO 困境不是「JavaScript SEO 問題」的子集,是比那更深一層的結構問題。拿 CSR SPA 的經驗去推估 Flutter Web 的 SEO 風險,會系統性地低估。症狀:你在 Search Console 看到頁面「已檢索、目前尚未建立索引」,重新提交幾次都一樣,而錯誤訊息不會告訴你原因是那一頁的內容根本不在 DOM 裡。
坑二:把語意樹當 SEO 功能。 上面講過了,它是無障礙功能,兩件事的消費者不同。症狀:你打開 Flutter 的 semantics debugger 看到一棵漂亮的樹,以為 SEO 沒問題,然後 curl 一次發現那棵樹是 JS 跑起來之後才長出來的。
坑三:改 index.html 的靜態 meta 就當交差。 那是全站一組的 title/description/og,一百個商品頁分享出去全長一樣。路由級的 meta 需要 server 端配合,這是 Day 27 的主題。症狀:你認真改好了 <title>,然後發現分享任何一個內頁到社群,預覽卡片標題全部是首頁那一組。
坑四:塞一份 <noscript> 或隱藏 DOM 餵爬蟲。 對爬蟲和使用者出兩份不同的內容,做粗了就是 cloaking(對爬蟲和使用者出不同內容,Google 明文禁止),踩的是紅線。想走白帽版本的「另外生一份 HTML」,有正規做法,Day 27 會正面處理。症狀:短期排名有起色,某次演算法更新後整站流量斷崖,而 Search Console 的人工處置通知會直接告訴你原因。
坑五:最先發現問題的往往不是工程師。 把網址貼進 LINE 群組,預覽卡片一片空白——老闆用肉眼就能做的 SEO 稽核。在選型階段先做這個測試,比事後解釋便宜得多。順帶一提,這個測試對 Nuxt 專案也適用:hydration 前的 HTML 有沒有料,一張預覽卡片就會告訴你。症狀:老闆在群組貼了產品連結,出現的是一片灰色空白加上網址本身,然後他問你「這個是不是壞了」。

  • document.title 這種小事在 Flutter Web 都要透過路由設定自己餵,Vue 人習慣的「meta 生態系」——useSeoMeta、sitemap 模組、og image 產生器——在這裡是從零開始的荒地,每一塊磚都得自己燒。

小結與下一篇

一句話:對不執行 JS 的爬蟲,Flutter Web 網站是一頁空白;對執行 JS 的 Googlebot,它是一張看不懂的圖。明天 Day 27 看三帖補救方案——預渲染、meta 注入、混合架構——哪些是真藥,哪些只是安慰劑。

參考資料

如果你卡在語法

深入原理


上一篇
Day 25|Nuxt SSR/SSG 原理回顧
系列文
Vue 前端工程師視角看 Flutter Web26
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言