Skip to content

SPA 動態更新 title 與 meta:useHead 與原生 API 實作

SPA 動態更新 title 與 meta 的做法

單頁應用程式的路由切換 不會重新載入頁面,所以 <head> 裡的標籤不會自動換。切到第二頁時,<title> 還是第一頁的。

先講清楚這件事的限制

動態更新解決的是 搜尋引擎的第二階段渲染使用者看到的頁籤標題

解決不了社群分享,Facebook、X、LINE 的爬蟲完全不執行 JavaScript,只讀伺服器回傳的原始 HTML。那個問題只能靠 伺服器端渲染或預渲染 解決。

三種做法的比較

檔頭套件原生 document API路由守衛
能改標題
能改 meta 標籤要自己找元素要自己找元素
自動清理
支援 SSR
在元件裡宣告否(集中在路由)
維護成本

用檔頭套件

Vue 生態用 @unhead/vue(Nuxt 內建),React 生態有對應的方案。不要自己造。

做法一:檔頭套件

bash
npm install @unhead/vue
js
// 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');
vue
<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>

三個關鍵好處

  1. 在元件裡宣告:標籤與使用它的頁面放在一起,不必到別的檔案找。
  2. 自動清理:元件卸載時對應的標籤自動移除。
  3. 支援 SSR:同一份程式碼在伺服器端會把標籤寫進回傳的 HTML。

全站的樣板與預設值

js
// 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' },
  ],
});

首頁要關掉樣板

vue
<script setup>
// 首頁的標題自己就是完整的
useHead({
  title: "某某公司|網頁設計與前端開發",
  titleTemplate: null,
});
</script>

不關的話會變成「某某公司|網頁設計與前端開發|某某公司」。這與 靜態站的 titleTemplate 是同一個問題。

做法二:原生 document API

不裝套件時的最小可行版本:

js
// 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();
  });
}

這個版本已經看得出維護成本

才處理四個標籤就這麼長了。要再加 og:imagecanonicalrobots、結構化資料的話,程式碼會很快膨脹。

而且 它在伺服器端渲染時會直接出錯,那裡沒有 document

做法三:路由守衛(不建議)

js
// 不建議:只能改標題,而且與 SSR 衝突
router.afterEach((to) => {
  document.title = to.meta.title ?? '某某公司';
});

四個問題

問題說明
只能改標題meta 標籤要自己一個個找元素改
與 SSR 衝突伺服器端沒有 document
拿不到頁面資料標題如果要用 API 回來的商品名稱就做不到
集中在路由設定標籤與頁面分離,難維護

路由守衛 適合做權限判斷與導向,不適合管檔頭標籤。

更新時機:資料取回之後

太早更新會顯示佔位文字

js
// 標題先是「載入中」,資料回來才變成商品名
useHead({ title: '載入中' });
const { data } = await fetch(...);
useHead({ title: data.name }); // 兩次宣告會互相覆蓋,行為不確定

正確做法是 用回應式的取值,讓標題隨資料自動更新:

vue
<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>

用 Suspense 或伺服器端取資料更乾淨

Nuxt 的 useFetch 在伺服器端就把資料取好,useHead 拿到的直接是最終值,完全沒有「載入中」的中間狀態。做法見 Vue 與 Nuxt 的 SSR 實作

結構化資料的注入

vue
<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>

動態注入的結構化資料風險較高

風險說明
渲染有延遲排在第二階段的佇列裡
程式碼出錯就全沒了靜態寫在 HTML 裡不會有這個問題
累積沒清理的話逛五個商品頁會有五組標記

務必用 網址檢查工具 確認 搜尋引擎渲染後真的讀到那段標記。細節見 JSON-LD 語法與驗證

自己手動注入時記得清掉舊的:

js
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);
}

canonical 也要跟著換

路由切換時 canonical 若沒更新,每一頁都會宣告成同一個網址,等於主動要求只收錄其中一頁。

js
useHead({
  link: [
    {
      rel: 'canonical',
      href: computed(() => `https://example.com${route.path}`),
    },
  ],
});

用路由路徑組出來,不要手寫

手寫的最大風險是複製貼上後忘了改。從 route.path 組出來就不會有這個問題。

網域則用環境變數,避免測試機產出指向正式網域的標籤。

靜態產生是最省事的模式

如果頁面是靜態產生的,檔頭在建置時就寫好了,上面那些用戶端的處理一個都不需要。產出的 HTML 裡,每一頁的檔頭都已經是正確的內容:

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">

各頁只要在頁面資料裡宣告自己的標題與描述,canonicalog:url 則交給建置流程依路由自動產生,不必逐頁手寫,作法見 canonical 標準網址

這是最省事的模式:標籤寫在 HTML 裡(社群爬蟲讀得到),瀏覽器端切換時由框架自己換掉(使用者看到正確的頁籤標題),兩邊都不必自己處理。

檢查清單

項目標準
用檔頭套件而非操作 document建議
在頁面元件裡宣告而非路由守衛建議
標籤用回應式取值,資料變了自動更新建議
每頁的 <title>description 唯一必備
canonical 隨路由更新必備
首頁關掉標題樣板必備
離開頁面時標籤有清理必備
結構化資料沒有累積必備
已用網址檢查工具確認渲染後讀得到建議
知道這救不了社群分享必備

常見問題

三種做法該選哪一個?

用框架的檔頭套件。它在元件裡宣告、會自動清理離開頁面時的標籤、也支援伺服器端渲染。原生 document API 只能改標題,路由守衛則會與伺服器端渲染衝突而且很快就難以維護。

動態更新了社群卡片還是空的,為什麼?

因為社群平台的爬蟲完全不執行 JavaScript。它們只讀伺服器回傳的原始 HTML,所以任何動態注入的標籤對它們都無效。這個問題只能靠 伺服器端渲染或預渲染 解決。

離開頁面時要手動清掉標籤嗎?

用檔頭套件的話不用,它會在元件卸載時自動移除。自己操作 document 就必須手動清理,否則標籤會累積,逛過五個商品頁後檔頭裡會有五組結構化資料。

結構化資料也可以動態注入嗎?

可以,搜尋引擎渲染頁面後讀得到。但風險比伺服器端渲染高:渲染有延遲、程式碼出錯就全沒了。務必用網址檢查工具確認渲染後真的讀到那段標記。

標題什麼時候更新才對?

資料取回之後。太早更新會顯示成佔位文字或空白,使用者在頁籤上看到的是載入中。用 computed 這類回應式的取值方式讓標題隨資料自動更新是最乾淨的做法。

延伸閱讀

參考資料:Unhead:Vue 檔頭管理