# CSR 原理及常見問題

用純 React 開發 CSR(Client-Side Rendering)、SPA(Single Page Application) Web APP 時,儘管在頁面切換速度、性能上有卓越的優勢,但最麻煩的地方莫過於 SEO。

CSR 之所以快,原理就是 瀏覽器收到的 HTML 只有一個根元件 <div id="root"></div> 和一個打包好的 JS 檔,之後所有內容會由 JS 在 client 端瀏覽器執行後動態渲染出來

雖然 Google 現在有支援 CSR 渲染,但因爬蟲的 Crawl Budget(抓取資源預算) 有限,CSR 頁面常被延遲甚至忽略。社群平台(如 Facebook、LINE、Twitter) 抓取 og:image、og:title 等 Open Graph meta 時,也只讀原始 HTML,不會執行 JS,因此抓不到任何資訊。

# 為何 React Helmet 無法解決問題?

相信大家搜 react meta tags 都會看到 React Helmet 套件,可以在 React 應用中動態地管理 meta,根據當前頁面自動更新 title、desciption、keywords 等,對 SEO 來說非常有用,但僅限於 Google 等搜尋引擎的爬蟲機器人

而且 React 19 後直接內建 react-helmet 的功能,可以直接在元件加入 meta 標籤了:React v19.1 官方文件

function BlogPost({ post }) {
 return (
  <article>
   <h1>{post.title}</h1>
   <title>{post.title}</title>
   <meta name='author' content='Josh' />
   <link rel='author' href='https://twitter.com/joshcstory/' />
   <meta name='keywords' content={post.keywords} />
   <p>Eee equals em-see-squared...</p>
  </article>
 );
}

儘管在網頁上確實能看到加入的那些 meta 標籤,但都是在頁面的 JS 載入之後才這麼做。對普通用戶來說,這完全沒問題——你打開瀏覽器,頁面載入完成,HTML 裡的 meta 標籤出現。但像 FB、LINE、WhatsApp 這類社群平台的爬蟲機器人可就沒那麼有耐心了。它們不會等你的 JS 執行完,而是直接抓取伺服器第一時間送出的 HTML,也就是那份空空如也、<head> 裡什麼都沒有的版本。

就想像你在烤披薩:React Helmet 就像是等披薩出爐後才加配料。你的朋友(使用者)吃到的是有滿滿配料的披薩,但社群平台的機器人就像急性子的朋友,一看到披薩出爐就立刻搶了一片,只有起司,沒有任何配料。

所以怎麼解決問題?幾種解法:

  1. 伺服器端渲染(SSR):
    • 在 HTML 回傳給使用者之前就把 meta 標籤加進去。像是 Next.js 或 Gatsby 這類工具就能做到。等於是在披薩還沒出爐前就先放好配料,這樣就算是那位急著搶的人也能吃到完整版本
  2. Pre-build 或靜態網站(SSG):
    • 預先建構(build)所有頁面,讓 meta 標籤直接寫死在 HTML 裡。這方式非常適合部落格、作品集等內容固定的網站
  3. 既有應用的替代方案
    • 使用像 Prerender.io 這類服務做中間人,它們會等你加好配料後再把披薩送出去——確保機器人也能吃到完整的頁面內容。

方法 1 和 2 都是比較繁瑣的流程,要重構整個請求 API 的程式,成本太高,也不想為了可以在社群分享時有特定標題跟縮圖就搞這些,所以最後透過方案 3 來解決

# 開始用

  1. 開發 useSEO hooks 取代在每個元件都寫死的 Meta Tags
  2. 設定 Prerender.io 服務
    • 申請帳號
    • 設定網站 URL
    • 取得 API Token
  3. 在 Nginx 中設定 Prerender.io 的代理轉發
  4. 測試 Facebook、LINE、LinkedIn 等社群平台抓取 meta tags

# 建構可重複使用的 useSEO hooks

這邊避免在每個頁面都寫死 Meta Tags,改成開發一個 useSEO hooks 來統一管理

完整程式碼很長,就放前面一些部分示意:

export const useSEO = (seoData: SEOData): void => {

 useEffect(() => {
  // ============ 只在有值時更新 document title ============
  if (seoData.title && seoData.title.trim()) {
   document.title = seoData.title;
   // console.log("標題已更新:", seoData.title);
  } else {
   // console.log("沒有提供標題,保持現有標題");
  }
  const updateMetaTag: UpdateMetaTag = (selector, content) => {
   // 如果內容為空,跳過更新(保持回退內容)
   if (!content || content.trim() === "") {
    // console.log(`⏭️ 跳過空內容更新: ${selector}`);
    return;
   }
   let meta = document.querySelector(selector) as HTMLMetaElement | null;
   if (!meta) {
    meta = document.createElement("meta");
    if (selector.includes("property=")) {
     const property = selector.match(/property="([^"]+)"/)?.[1];
     if (property) meta.setAttribute("property", property);
    } else if (selector.includes("name=")) {
     const name = selector.match(/name="([^"]+)"/)?.[1];
     if (name) meta.setAttribute("name", name);
    }
    document.head.appendChild(meta);
    console.log(`➕ 創建新 meta tag: ${selector}`);
   }

   // const oldContent = meta.getAttribute("content");
   meta.setAttribute("content", content);
   // console.log(`更新 ${selector}:`, oldContent, "→", content);
  };
  ...
  // ============ 條件性更新基本 meta tags ============
  if (seoData.description && seoData.description.trim()) {
   updateMetaTag('meta[name="description"]', seoData.description);
  }
  ...

之後在每個頁面中只需調用這個 useSEO hooks,傳入對應的 SEO 資料即可:

import { useSEO } from '@/hooks/useSEO';

const HomePage = () => {
 // ============ SEO Meta Tags ============
 useSEO({
  title: "KOSTEC 聖兒莉 | 國際認證 GMP 化妝保養品 OEM/ODM 專業代工廠",
  description: "KOSTEC 聖兒莉提供專業 GMP 認證化妝品與保養品 OEM/ODM 代工服務。擁有國際品質認證,從產品研發、配方設計到生產包裝,一站式專業代工解決方案。",
  keywords: "KOSTEC, 聖兒莉, GMP, 化妝品代工, 保養品代工, OEM, ODM, 專業代工廠, 化妝品製造, 保養品製造, 國際認證",
  url: "https://kostec.com/",
  image: "https://kostec.com/images/kostec-homepage-og.jpg",
  imageAlt: "KOSTEC 聖兒莉 GMP 化妝保養品專業代工廠",
  structuredData: {
   "@context": "https://schema.org",
   "@type": "Organization",
   name: "KOSTEC 聖兒莉",
   url: "https://kostec.com",
   logo: "https://kostec.com/images/kostec_logo.svg",
  },
 });
...
}

但這些 meta tags 仍然是動態生成的,對於 Google SEO 來說有幫助,但社群平台的爬蟲來說還是抓不到,所以接下來需要用 Prerender.io 來解決這個問題。

# 設定 Prerender.io 服務

Prerender.io 官網

進官網跑完註冊流程後拿完 API Token,接著就可以依據每個人不同的 Intergration methods 來設定了,左側可以選自己的情況來快速跳到官方教學

Prerender.io 註冊後畫面

那我自己是用 Nginx 來設定 Prerender.io 的代理轉發,就可以在不改動前後端程式碼的情況下,讓 Prerender.io 開一個 Headless Chrome 去跟我的 server 請求該路由的資料來預渲染 HTML
N

# Nginx 設定

他們都有提供教學影片,告訴你在要複製哪一段及修改哪個地

Nginx Prerender.io 設定

照著影片操作,把相關的 Prerender 設定整合進自己網站的 nginx 設定檔,重點把 YOUR_TOKEN 換成自己的 API Token 即可

...
 map $http_user_agent $prerender_ua {
   default       0;
   "~*Prerender" 0;

   "~*googlebot"                               1;
   "~*yahoo!\ slurp"                           1;
   "~*bingbot"                                 1;
   "~*yandex"                                  1;
   "~*baiduspider"                             1;
 ...
 location /prerenderio {
  if ($prerender = 0) {
   return 404;
  }
  proxy_set_header X-Prerender-Token YOUR_TOKEN;
  proxy_set_header X-Prerender-Int-Type Nginx;
  proxy_hide_header Cache-Control;
  add_header Cache-Control "private,max-age=600,must-revalidate";

  #resolve using Google's DNS server to force DNS resolution and prevent caching of IPs
  resolver 8.8.8.8 8.8.4.4;
  set $prerender_host "service.prerender.io";
  proxy_pass https://$prerender_host;
  rewrite .* /$scheme://$host$request_uri? break;
 }
...

# Prerender.io 缺點

雖然實作起來很簡單暴力,大部分現有 CSR 前端框架 React, Vue, Angular 都可以直接使用,只需設定 server middleware。但缺點就是免費方案,只有每月 1000 次預渲染次數

Prerender.io 方案

# 查看結果

重啟 nginx 服務後,可以到網頁或直接指定測試 Prerender 是否觸發

curl -I -A "facebookexternalhit/1.1" https://kostec.com/

成功就會看到 response header 多了:

curl_success

然後回到自己 Prerender.io 的 dashboard 看,也可以看到請求紀錄:

prerender_dashboard

# 成功畫面

網絡上也非常可以測試 Open Graph meta tags 的工具 Open Graph Debugger

FB、Discord、LINE 都成功正常擷取到 Meta Open Graph 標籤!

Open Graph Debugger 成功畫面

# 結論

以後遇到開發這種需要 SEO 跟以宣傳﹑行銷為主,又有大量內容的網站,還是直接一開始用 SSR 或 SSG 的方式來開發好 XD 不要找自己麻煩

請我喝[茶]~( ̄▽ ̄)~*

Young 微信支付

微信支付

Young 支付寶

支付寶