useSeo()
useSeo() 是 framework SEO 體系的入口 composable。每個 page 一次呼叫,自動輸出全套
og:* / twitter:* / canonical / JSON-LD(WebSite + WebPage,可選 Article),並接管
robots meta 與 dev 長度警告。SEO 在 Swift Web 專案中強制啟用(不需要 SEO 應該
選 Vue 3 SPA 而非 Nuxt),所有專案自動獲得 sitemap.xml + robots.txt + JSON-LD 全套能力。
完整 SEO 規範、最佳實踐、常見誤區見 /guide/seo;本頁只涵蓋
useSeo()這個 composable 本身的 API。
使用方式
// 基礎(首頁 / 靜態頁)
useSeo({
title: '首頁',
description: '一句話網站描述(中文目標 25-80 字 / 英文 50-160 字元)',
})
// 詳情頁 + 後端頁面級 SEO 節點
const { data } = await useFetch(`/movie/detail/${id}`)
useSeo(() => ({
title: data.value?.seo?.title ?? data.value?.name,
description: data.value?.seo?.description ?? data.value?.summary,
image: data.value?.seo?.image ?? data.value?.cover,
type: 'article',
article: {
author: data.value?.author,
datePublished: data.value?.created_at,
},
}))
// 工具型 / 私有頁(title/description 天然簡短,關閉長度警告)
useSeo({
title: '登入',
description: '登入帳號以使用完整功能',
robots: 'private', // 等價於 'noindex, nofollow'
silenceLengthWarnings: true,
})
響應式輸入與單集主實體
useSeo(source: MaybeRefOrGetter<SeoInput>): void 接受同步 getter、ref 或 computed,並隨資料更新 title、description、canonical、OG 與 schema。普通 SeoInput 物件仍是呼叫時的快照;不要把 data.value 複製進普通物件後期待切集更新,也不要傳非同步 getter。
JSON-LD 預設在 SSR 輸出;dev 允許客戶端更新,正式環境客戶端預設不更新。需要在同一頁面實例內切集同步 JSON-LD 時,在 nuxt.config.ts 明確設定 schemaOrg: { reactive: true }。head 響應式輸入與正式環境 schema 更新是兩個開關;dev 正常不能取代正式環境導覽驗收。
// nuxt.config.ts
export default defineNuxtConfig({
schemaOrg: { reactive: true },
})
media.url?: string 是穩定的作品網址,預設目前 canonical;單集頁必須明確傳整劇網址。media.mainEntity?: 'work' | 'episode' 預設作品;僅 kind: 'series' 且有 episode 時可選單集,否則回退作品。episode.url 預設目前 canonical;單集 @id 使用該集 URL + #episode,partOfSeries 指回穩定作品 @id。
import { ref } from 'vue'
const series = ref({ name: 'Example series', path: '/series/300', image: '/images/series.jpg' })
const episode = ref({ number: 3, name: 'Episode 3', description: 'Episode overview', path: '/series/300/episode/81' })
useSeo(() => ({
title: `${series.value.name} ${episode.value.name}`,
description: episode.value.description,
canonicalPath: episode.value.path,
media: {
kind: 'series', name: series.value.name, image: series.value.image,
url: series.value.path, mainEntity: 'episode',
episode: { episodeNumber: episode.value.number, name: episode.value.name, url: episode.value.path },
},
}))
本節 API 為本地新增,待 framework#430 發布。消費方須按實際安裝版本進行完整 XML 失敗 / 恢復驗收;本地通過不等於正式環境部署或收錄。
SeoInput 欄位
| 名稱 | 類型 | 必填 | 預設值 | 說明 |
|---|---|---|---|---|
title | string | ✓ | — | 頁面標題(建議 10-60 顯示寬度,> 70 觸發關鍵字堆砌警告。CJK 字元算 2,ASCII 算 1) |
description | string | ✓ | — | 頁面描述(建議 50-160 顯示寬度,> 200 觸發關鍵字堆砌警告,LLM 摘要關鍵欄位) |
image | string | /og-image.png | OG 圖。絕對 URL / 站內相對路徑;不傳走本地 /og-image.png 兜底(不讀後端 header.og_image) | |
type | 'website' | 'article' | 'profile' | 'website' | OG 類型。詳情頁傳 'article' 配合 article 欄位輸出 Article schema | |
keywords | string | — | 關鍵字,逗號分隔字串(≤10 個、≤200 字元) | |
locale | string | — | i18n locale 標識,OG 用底線格式如 'zh_CN' / 'en_US' / 'zh_TW' | |
siteUrl | string | 走主路徑 useRequestURL().origin | 站點根網域(含 protocol)。顯式逃生口:傳值即跳過 framework 的 CSR 一致性診斷(典型場景:多副網域想強制 canonical 收斂到主網域) | |
canonicalPath | string | useRequestURL().pathname | canonical 路徑(不含 origin) | |
schemaOrg | boolean | true | 是否輸出 JSON-LD。罕見場景顯式 false 關閉 | |
article | SeoArticleFields | — | type='article' 時的結構化欄位:author / datePublished / dateModified | |
video | SeoVideoFields | — | 影片頁輸出 VideoObject,見下「影視結構化資料」 | |
media | SeoMediaFields | — | 影視詳情頁輸出 Movie / TVSeries(+ TVEpisode)+ WatchAction | |
itemList | SeoItemListEntry[] | — | 頻道 / 片單頁輸出 ItemList | |
robots | 'noindex' | 'private' | string | — | 搜尋引擎索引策略。'noindex' = 'noindex, follow';'private' = 'noindex, nofollow';也接受任意原始字串如 'noindex, nofollow, noarchive' | |
silenceLengthWarnings | boolean | false | 靜音 dev 長度類警告。工具型 / 私有頁用(robots: 'noindex' | 'private' 時長度警告無意義) |
影視結構化資料(video / media / itemList)
面向 Google 影片豐富結果與 AI 搜尋(AI Overviews / ChatGPT Search / Perplexity)的影視頁 JSON-LD(framework#381)。三個入參各產一段,與 WebSite / WebPage 掛進同一個 graph、@id 互引;WebPage.mainEntity 預設指向作品(無作品則指影片),明確 media.mainEntity: 'episode' 時指向本集。缺必填整節點不輸出(殘缺節點會被 Google 判錯,不如不出)並 dev 告警點名缺的欄位。
| 入參 | 產出 | 必填欄位 | 可選欄位 |
|---|---|---|---|
video: SeoVideoFields | VideoObject | name / thumbnailUrl / uploadDate(ISO 8601) | description(預設頁面 description)/ duration(秒,框架轉 PT1H30M)/ embedUrl(播放器本身的位址,不能是影片所在頁,不傳就不出)/ contentUrl / expires / regionsAllowed / views |
media: SeoMediaFields | Movie 或 TVSeries(kind)+ WatchAction(isAccessibleForFree: true);kind='series' 且傳 episode 時另出 TVEpisode | kind / name / image | url / mainEntity: 'work' | 'episode' / description / datePublished / genre[] / countryOfOrigin / inLanguage(預設 i18n 當前 locale)/ actor[] / director[] / numberOfEpisodes(僅 series)/ episode: { episodeNumber, name?, url? } |
itemList: SeoItemListEntry[] | ItemList(ListItem 位置從 1 起) | 每條 url | name |
// 劇集播放頁:作品 + 本集 + 影片三節點互引
useSeo({
title: `${series.name} 第 ${ep.no} 集`,
description: series.seo.description,
image: series.seo.img,
video: { name: `第 ${ep.no} 集`, thumbnailUrl: series.seo.img, uploadDate: ep.publishedAt, duration: ep.durationSeconds ?? undefined },
media: { kind: 'series', name: series.name, image: series.seo.img, genre: series.tags, numberOfEpisodes: series.total, episode: { episodeNumber: ep.no, name: ep.title } },
});
// 頻道 / 片單頁
useSeo({ title: '懸疑劇', description: '…', itemList: list.map((m) => ({ url: `/movie/${m.id}`, name: m.name })) });
三條硬約束(都踩過):
thumbnailUrl/image必須是 Googlebot 能取、能解碼的普通圖 —— 不能給.bnc加密封面,用後端seo.img原圖(同 og:image 那個坑)。contentUrl給不了(簽名 m3u8 幾小時過期、供應商禁對外快取)就給embedUrl,但它必須是獨立播放器位址(iframe //embed/:id一類),不能是影片所在的詳情頁;兩者都沒有時 Google 抓不到影片、豐富結果不會出——這時 VideoObject 只剩 name / thumbnailUrl / uploadDate,價值有限。duration沒有就不傳,別編;views同理。
不做:llms.txt(Google 明確不用、爬蟲幾乎不取);FAQ 豐富結果(頁面沒有真實可見問答就別輸出)。GEO 的大頭是 SEO 基本功 + 直答式簡介 + 語意標籤與 schema 一致,見 issue framework#381 §1.4。
origin 決策與 image 兜底鏈
origin 解析:3 層、設定優先
- L1 顯式
input.siteUrl—— 單頁覆寫(多副網域想把 canonical 強制收斂到主網域時用)。傳了此參數即跳過下方 CSR 一致性診斷。 - L2
NUXT_PUBLIC_SITE_URL(站台設定) —— 全站主路徑,設了就用它。同樣跳過 CSR 一致性診斷。 - L3
useRequestURL().origin—— 未設定站台位址時的兜底。SSR 期 Node 從Host/X-Forwarded-Proto標頭解析,CSR / hydration 後等價於window.location.origin。
有 ISR 頁或預渲染頁的站台必須設定 NUXT_PUBLIC_SITE_URL,否則 canonical / og:url 會是 http://localhost/...。
原因是被快取的頁面拿不到請求標頭:nitro 的 cachedEventHandler 進 handler 前把請求標頭只保留 cache.varies 白名單,Host 被丟掉 ⇒ useRequestURL() 退化成 http://localhost。
為什麼主路徑是設定而不是請求標頭:被快取的 HTML 是全體訪客共用的,把「本次請求的 Host」烤進去在原理上就不對;canonical 的語意本就是「首選網域」。代價是從副網域存取也輸出主網域 canonical(這正是 canonical 該做的),
og:url同樣跟隨 —— 確實需要隨存取網域變化的頁面用 L1 覆寫。未設定該變數的專案行為不變(走 L3)。
systemStore.info.site_url仍不參與 origin 決策(它是後端「最新地址」跳轉用的業務欄位,語義不同),保留供業務讀取。
CSR 一致性診斷
SSR 階段 useSeo 把 useRequestURL().origin 寫入 <link rel="canonical">,完全依賴 nginx / cloudflare 正確透傳 Host / X-Forwarded-Host / X-Forwarded-Proto。透傳漂移會讓 SSR 渲染出內網位址 / 錯誤網域 —— framework 在 CSR hydration 階段做一次比對:
document.querySelector('link[rel="canonical"]').href.origin ← SSR 寫入
vs
window.location.origin ← 瀏覽器實際存取
不一致即 nginx 透傳缺失,立刻在 console 輸出診斷(dev console.warn / prod console.error)+ 修復指引 + docs URL。每會話僅觸發 1 次(模組級 hasChecked 去重,避免刷屏)。顯式傳 input.siteUrl 時跳過此診斷。
完整運維診斷指引:nginx origin 透傳設定。
image 兜底(不讀後端):
顯式 input.image → /og-image.png(專案本地佔位,starter 透過 prebuild 腳本強制存在)。後端營運預設圖如需使用,由頁面從 systemStore 讀取後顯式傳入 input.image。
單一入口原則
useSeo 是 SEO 的唯一出口,不再從後端 systemStore.header 隱式兜底 title / description / keywords / og_image:
- title / description:每頁必須顯式傳。缺失只
console.warn(不阻斷渲染),不再用後端值兜底。 - keywords:可選,不傳則不輸出(傳了才校驗數量 / 長度)。
- og:image:見上方 image 兜底,僅本地
/og-image.png,不讀後端。 <title>站點名後綴:framework 不再注入 titleTemplate,交還 Nuxt 原生 —— applier 在nuxt.config.ts的app.head.titleTemplate配置(如`%s | ${swiftConfig.project.title}`)。- JSON-LD
WebSite.name:取runtimeConfig.public.siteName(applier 從swift.config.json的project.title寫入,與 titleTemplate 後綴同源),不再讀後端site_title。
為什麼:舊版 framework 的
system.client外掛會在 hydration 後用後端 header 覆蓋頁面 useSeo 的 title/description,導致<title>重複成站點名 | 站點名、description 被後端值蓋掉。現已砍掉這條隱式鏈,讓「寫進 HTML 的 SEO 欄位都能在頁面 useSeo 呼叫處一眼看全」。後端 CMS 資料要用於 SEO,由頁面從 store 讀取後顯式塞進 useSeo(後端 → store → 頁面 → useSeo)。詳見 framework#89。
displayWidth 長度閾值
dev 環境長度校驗用顯示寬度(visual width)算字元數,不是 JS .length。CJK 字元算 2、ASCII 算 1,讓閾值數字 50-160 對中英繁三語言都精確:
- 英文 description 50-160 字元(與之前一致)
- 中文 / 繁體 description 25-80 字(每字算 2,故寬度 50-160)
警告文案直接顯示 (顯示寬度 N) 提醒開發者單位不是 .length。keywords 檢查的是數量與字串總長,與視覺寬度無關,仍走 .length。
displayWidth 函式從 useSeo.ts 中 export 出來,單測可直接 import 使用。詳細推導見 /guide/seo。
實戰配方
// 配方 1:詳情頁 —— 後端 seo 節點優先、業務欄位兜底(顯式鏈,不靠隱式兜底)
const { data } = await useFetch(`/movie/detail/${id}`)
useSeo(() => ({
title: data.value?.seo?.title ?? data.value?.name,
description: data.value?.seo?.description ?? data.value?.summary,
image: data.value?.seo?.image ?? data.value?.cover,
type: 'article',
article: { datePublished: data.value?.created_at }, // US-001:article 必帶 datePublished
}))
// 配方 2:列表 / 標籤頁 —— 沒有現成 description 也要拼一個有意義的(US-002)
useSeo({
title: `${tag} 相關內容`,
description: `${tag} 主題下 ${count} 部精選,包括 ${top3.join('、')} 等`,
})
// 配方 3:錯誤頁 / 私有頁 —— noindex + 靜音長度告警
useSeo({ title: '頁面未找到', description: '您訪問的內容不存在', robots: 'noindex,nofollow', silenceLengthWarnings: true })
注意事項
- 必填校驗:
title/description每頁必須顯式傳,缺失會console.warn(無後端兜底),但不阻斷渲染(避免 useFetch 失敗時崩 dev) silenceLengthWarnings不影響硬警告:雙空缺失的警告永遠不靜音(那是真實風險)- OG 圖尺寸:預設輸出
og:image:width=1200/og:image:height=630,業務方上傳的 OG 圖要按 1200×630 PNG/JPG 準備 - Article schema 僅當
type='article'且傳article欄位時輸出,非詳情頁不需要傳article