SPA 動態更新 title 與 meta:useHead 與原生 API 實作
單頁應用程式的路由切換 不會重新載入頁面,所以 <head> 裡的標籤不會自動換。切到第二頁時,<title> 還是第一頁的。
先講清楚這件事的限制
動態更新解決的是 搜尋引擎的第二階段渲染 與 使用者看到的頁籤標題。
它 解決不了社群分享,Facebook、X、LINE 的爬蟲完全不執行 JavaScript,只讀伺服器回傳的原始 HTML。那個問題只能靠 伺服器端渲染或預渲染 解決。
三種做法的比較
| 檔頭套件 | 原生 document API | 路由守衛 | |
|---|---|---|---|
| 能改標題 | 是 | 是 | 是 |
| 能改 meta 標籤 | 是 | 要自己找元素 | 要自己找元素 |
| 自動清理 | 是 | 否 | 否 |
| 支援 SSR | 是 | 否 | 否 |
| 在元件裡宣告 | 是 | 是 | 否(集中在路由) |
| 維護成本 | 低 | 中 | 高 |
用檔頭套件
Vue 生態用 @unhead/vue(Nuxt 內建),React 生態有對應的方案。不要自己造。
做法一:檔頭套件
npm install @unhead/vue// main.js
import { createApp } from 'vue';
import { createHead } from '@unhead/vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
app.use(router);
app.use(createHead());
app.mount('#app');2
3
4
5
6
7
8
9
10
<script setup>
import { useHead } from "@unhead/vue";
import { computed } from "vue";
const props = defineProps({ product: Object });
useHead({
// 用函式或 computed,資料變了標籤自動跟著變
title: computed(() => props.product?.name ?? "載入中"),
meta: [
{ name: "description", content: computed(() => props.product?.summary) },
{ property: "og:title", content: computed(() => props.product?.name) },
{ property: "og:image", content: computed(() => props.product?.ogImage) },
{ property: "og:type", content: "product" },
],
link: [
{
rel: "canonical",
href: computed(() => `https://example.com/products/${props.product?.slug}`),
},
],
});
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
三個關鍵好處
- 在元件裡宣告:標籤與使用它的頁面放在一起,不必到別的檔案找。
- 自動清理:元件卸載時對應的標籤自動移除。
- 支援 SSR:同一份程式碼在伺服器端會把標籤寫進回傳的 HTML。
全站的樣板與預設值
// App.vue 或 layout 元件
useHead({
// %s 會被各頁的 title 取代
titleTemplate: '%s|某某公司',
meta: [
{ charset: 'UTF-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1.0' },
{ property: 'og:site_name', content: '某某公司' },
{ property: 'og:locale', content: 'zh_TW' },
{ name: 'twitter:card', content: 'summary_large_image' },
],
});2
3
4
5
6
7
8
9
10
11
12
首頁要關掉樣板
<script setup>
// 首頁的標題自己就是完整的
useHead({
title: "某某公司|網頁設計與前端開發",
titleTemplate: null,
});
</script>2
3
4
5
6
7
不關的話會變成「某某公司|網頁設計與前端開發|某某公司」。這與 靜態站的 titleTemplate 是同一個問題。
做法二:原生 document API
不裝套件時的最小可行版本:
// composables/useDocumentHead.js
import { watchEffect, onUnmounted } from 'vue';
export function useDocumentHead({ title, description }) {
const created = [];
function setMeta(attr, key, content) {
let el = document.querySelector(`meta[${attr}="${key}"]`);
if (!el) {
el = document.createElement('meta');
el.setAttribute(attr, key);
document.head.appendChild(el);
created.push(el); // 記下自己建的,離開時才清掉
}
el.setAttribute('content', content ?? '');
}
watchEffect(() => {
if (title.value) document.title = `${title.value}|某某公司`;
setMeta('name', 'description', description.value);
setMeta('property', 'og:title', title.value);
setMeta('property', 'og:description', description.value);
});
onUnmounted(() => {
for (const el of created) el.remove();
});
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
這個版本已經看得出維護成本
才處理四個標籤就這麼長了。要再加 og:image、canonical、robots、結構化資料的話,程式碼會很快膨脹。
而且 它在伺服器端渲染時會直接出錯,那裡沒有 document。
做法三:路由守衛(不建議)
// 不建議:只能改標題,而且與 SSR 衝突
router.afterEach((to) => {
document.title = to.meta.title ?? '某某公司';
});2
3
4
四個問題
| 問題 | 說明 |
|---|---|
| 只能改標題 | meta 標籤要自己一個個找元素改 |
| 與 SSR 衝突 | 伺服器端沒有 document |
| 拿不到頁面資料 | 標題如果要用 API 回來的商品名稱就做不到 |
| 集中在路由設定 | 標籤與頁面分離,難維護 |
路由守衛 適合做權限判斷與導向,不適合管檔頭標籤。
更新時機:資料取回之後
太早更新會顯示佔位文字
// 標題先是「載入中」,資料回來才變成商品名
useHead({ title: '載入中' });
const { data } = await fetch(...);
useHead({ title: data.name }); // 兩次宣告會互相覆蓋,行為不確定2
3
4
正確做法是 用回應式的取值,讓標題隨資料自動更新:
<script setup>
import { useHead } from "@unhead/vue";
import { computed, ref, onMounted } from "vue";
const product = ref(null);
onMounted(async () => {
const res = await fetch(`/api/products/${route.params.slug}`);
product.value = await res.json();
});
// 只宣告一次,資料變了標籤自動跟著變
useHead({
title: computed(() => product.value?.name ?? "商品載入中"),
meta: [{ name: "description", content: computed(() => product.value?.summary ?? "") }],
});
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用 Suspense 或伺服器端取資料更乾淨
Nuxt 的 useFetch 在伺服器端就把資料取好,useHead 拿到的直接是最終值,完全沒有「載入中」的中間狀態。做法見 Vue 與 Nuxt 的 SSR 實作 。
結構化資料的注入
<script setup>
import { useHead } from "@unhead/vue";
import { computed } from "vue";
const props = defineProps({ product: Object });
useHead({
script: [
{
type: "application/ld+json",
innerHTML: computed(() =>
JSON.stringify({
"@context": "https://schema.org",
"@type": "Product",
name: props.product?.name,
image: props.product?.images,
offers: {
"@type": "Offer",
price: String(props.product?.price ?? ""),
priceCurrency: "TWD",
availability:
(props.product?.stock ?? 0) > 0
? "https://schema.org/InStock"
: "https://schema.org/OutOfStock",
},
}),
),
},
],
});
</script>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
動態注入的結構化資料風險較高
| 風險 | 說明 |
|---|---|
| 渲染有延遲 | 排在第二階段的佇列裡 |
| 程式碼出錯就全沒了 | 靜態寫在 HTML 裡不會有這個問題 |
| 累積 | 沒清理的話逛五個商品頁會有五組標記 |
務必用 網址檢查工具 確認 搜尋引擎渲染後真的讀到那段標記。細節見 JSON-LD 語法與驗證 。
自己手動注入時記得清掉舊的:
function injectSchema(data) {
// 先移除自己上次注入的
document.querySelector('script[data-schema="page"]')?.remove();
const script = document.createElement('script');
script.type = 'application/ld+json';
script.dataset.schema = 'page'; // 用自訂屬性標記,才找得回來
script.textContent = JSON.stringify(data);
document.head.appendChild(script);
}2
3
4
5
6
7
8
9
10
canonical 也要跟著換
路由切換時 canonical 若沒更新,每一頁都會宣告成同一個網址,等於主動要求只收錄其中一頁。
useHead({
link: [
{
rel: 'canonical',
href: computed(() => `https://example.com${route.path}`),
},
],
});2
3
4
5
6
7
8
用路由路徑組出來,不要手寫
手寫的最大風險是複製貼上後忘了改。從 route.path 組出來就不會有這個問題。
網域則用環境變數,避免測試機產出指向正式網域的標籤。
靜態產生是最省事的模式
如果頁面是靜態產生的,檔頭在建置時就寫好了,上面那些用戶端的處理一個都不需要。產出的 HTML 裡,每一頁的檔頭都已經是正確的內容:
<title>SPA 動態更新 title 與 meta:useHead 與原生 API 實作</title>
<meta property="og:title" content="SPA 動態更新 title 與 meta:useHead 與原生 API 實作">
<link rel="canonical" href="https://example.com/seo/rendering-dynamic-meta">2
3
各頁只要在頁面資料裡宣告自己的標題與描述,canonical 與 og:url 則交給建置流程依路由自動產生,不必逐頁手寫,作法見 canonical 標準網址 。
這是最省事的模式:標籤寫在 HTML 裡(社群爬蟲讀得到),瀏覽器端切換時由框架自己換掉(使用者看到正確的頁籤標題),兩邊都不必自己處理。
檢查清單
| 項目 | 標準 |
|---|---|
用檔頭套件而非操作 document | 建議 |
| 在頁面元件裡宣告而非路由守衛 | 建議 |
| 標籤用回應式取值,資料變了自動更新 | 建議 |
每頁的 <title> 與 description 唯一 | 必備 |
canonical 隨路由更新 | 必備 |
| 首頁關掉標題樣板 | 必備 |
| 離開頁面時標籤有清理 | 必備 |
| 結構化資料沒有累積 | 必備 |
| 已用網址檢查工具確認渲染後讀得到 | 建議 |
| 知道這救不了社群分享 | 必備 |
常見問題
三種做法該選哪一個?
用框架的檔頭套件。它在元件裡宣告、會自動清理離開頁面時的標籤、也支援伺服器端渲染。原生 document API 只能改標題,路由守衛則會與伺服器端渲染衝突而且很快就難以維護。
動態更新了社群卡片還是空的,為什麼?
因為社群平台的爬蟲完全不執行 JavaScript。它們只讀伺服器回傳的原始 HTML,所以任何動態注入的標籤對它們都無效。這個問題只能靠 伺服器端渲染或預渲染 解決。
離開頁面時要手動清掉標籤嗎?
用檔頭套件的話不用,它會在元件卸載時自動移除。自己操作 document 就必須手動清理,否則標籤會累積,逛過五個商品頁後檔頭裡會有五組結構化資料。
結構化資料也可以動態注入嗎?
可以,搜尋引擎渲染頁面後讀得到。但風險比伺服器端渲染高:渲染有延遲、程式碼出錯就全沒了。務必用網址檢查工具確認渲染後真的讀到那段標記。
標題什麼時候更新才對?
資料取回之後。太早更新會顯示成佔位文字或空白,使用者在頁籤上看到的是載入中。用 computed 這類回應式的取值方式讓標題隨資料自動更新是最乾淨的做法。
延伸閱讀
參考資料:Unhead:Vue 檔頭管理