前言

在現代 Web 開發中,Vue 單頁應用程式(SPA)帶來了優秀的使用者體驗,但同時也面臨著 SEO 和社交媒體分享的挑戰。當用戶在 Facebook、LINE 或其他社交平台分享你的網站連結時,你希望顯示的是精美的預覽卡片,而不是空白或錯誤的資訊。然而,搜尋引擎爬蟲和社交媒體爬蟲無法執行 JavaScript,導致只能抓到空白的 HTML 骨架,Open Graph 標籤也無法正確顯示。

本文將深入探討兩種預渲染策略:Run-time Prerender(動態預渲染)Build-time Prerender(靜態預渲染),並分享實際的實作經驗與部署過程中遇到的挑戰。

問題發現與場景

典型的問題場景

當開發一個基於 Vue.js 3 + Strapi 的網頁平台時,可能會發現一個重要問題:

當用戶分享服務頁面(如 https://www.abc.com/service-us/6)到 Facebook 或 LINE 時,顯示的預覽資訊總是預設值,而非該服務的實際標題和描述。

使用 Facebook 的 Open Graph Debugger 測試後發現,爬蟲抓取到的 HTML 只包含基本的模板,動態生成的 meta 標籤完全沒有被識別。

SPA 的 SEO 困境

典型的 Vue SPA 在未預渲染前,HTML 長這樣:

<!DOCTYPE html>
<html lang="zh-TW">
  <head>
    <meta charset="UTF-8">
    <title>範例網站</title>
    <meta name="description" content="預設描述">
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

問題:

  • Facebook 爬蟲抓不到動態產生的 Open Graph 標籤
  • Google 爬蟲雖然能執行 JS,但會降低 SEO 排名
  • 分享連結到社群媒體時無法顯示預覽圖

我們需要什麼?

針對不同的路由,回傳不同的 meta 標籤:

<!-- /service-us/1 -->
<title>線上課程 - 範例網站</title>
<meta property="og:title" content="線上課程 - 範例網站">
<meta property="og:image" content="https://example.com/course.jpg">

<!-- /latest-news/category/5 -->
<title>最新消息:健康飲食新知 - 範例網站</title>
<meta property="og:title" content="最新消息:健康飲食新知 - 範例網站">
<meta property="og:image" content="https://example.com/news.jpg">

根本原因分析

客戶端渲染 vs 伺服器端渲染

問題的核心在於 客戶端渲染(CSR)社交媒體爬蟲 之間的根本衝突:

<!-- 社交媒體爬蟲看到的 HTML -->
<!DOCTYPE html>
<html>
<head>
  <title>ABCDEFG</title>
  <!-- 只有靜態的預設 meta 標籤 -->
</head>
<body>
  <div id="app"></div>
  <!-- 爬蟲不會執行這些 JavaScript -->
  <script src="/assets/index.js"></script>
</body>
</html>

而 Vue.js 應用動態設定的 meta 標籤:

// Vue 組件中的動態 meta 設定
const metaTitle = computed(() => {
  const openGraph = response.value?.openGraph
  return openGraph?.ogTitle || response.value?.title || "預設標題"
})

useHead(() => ({
  title: metaTitle.value,
  meta: [
    { property: 'og:title', content: metaTitle.value },
    { property: 'og:description', content: metaDescription.value },
    // ... 其他 meta 標籤
  ]
}))

Facebook、LINE 等社交媒體的爬蟲不執行 JavaScript,因此看不到動態生成的內容。

兩種預渲染策略比較

Run-time Prerender(動態預渲染)

時機: 爬蟲訪問時即時產生

爬蟲請求 → Express Server 檢測 User-Agent →
即時讀取 dist/index.html → 動態注入 meta tags →
快取 24 小時 → 回傳 HTML

Build-time Prerender(靜態預渲染)

時機: npm build 時一次性產生所有頁面

npm run build → Vite 打包 →
Puppeteer 爬取所有路由 → 產生靜態 HTML →
儲存到 dist/service-us/1/index.html

功能對比表

特性Run-timeBuild-time
產生時機爬蟲訪問時建置時
檔案結構只有 1 個 index.html每個路由一個 index.html
伺服器需求Node.js + Express靜態檔案伺服器(Nginx)
快取策略記憶體快取(24小時)檔案系統快取(永久)
動態路由支援(可即時處理新 ID)不支援(需重新建置)
建置時間快速(只打包一次)慢(需爬取所有路由)
維護成本中等(需監控伺服器)低(靜態檔案)
適用場景內容經常變動內容相對固定

Run-time Prerender 完整實作

架構圖

┌─────────────────────────────────────────────────┐
│                 爬蟲請求                          │
│     User-Agent: facebookexternalhit             │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│         Express Middleware                      │
│   src/utils/crawlerDetection.js                │
│   ↓ 檢測是否為爬蟲                               │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│     ServerPrerenderService                      │
│     server/ServerPrerenderService.js            │
│                                                 │
│   1. 檢查快取 (Map)                              │
│   2. 讀取 dist/index.html                       │
│   3. 根據 URL 產生 meta tags                    │
│   4. 注入 structured data                       │
│   5. 儲存快取                                    │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│              回傳預渲染 HTML                      │
│   <html data-prerendered="true">                │
│     <meta property="og:title" content="...">    │
│   </html>                                       │
└─────────────────────────────────────────────────┘

Step 1: 爬蟲偵測

// src/utils/crawlerDetection.js

const BOT_USER_AGENTS = {
  facebook: /facebookexternalhit|facebookcatalog/i,
  twitter: /twitterbot/i,
  linkedin: /linkedinbot/i,
  google: /googlebot/i,
  bing: /bingbot/i,
  line: /line/i
}

export function getBotType(userAgent) {
  if (!userAgent) return null

  for (const [type, regex] of Object.entries(BOT_USER_AGENTS)) {
    if (regex.test(userAgent)) {
      return type
    }
  }

  return null
}

export function shouldPrerender(request) {
  const userAgent = request.headers?.['user-agent'] || ''
  const url = request.url || ''

  // 排除靜態資源
  if (url.match(/\.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot)$/)) {
    return false
  }

  // 排除 API 請求
  if (url.startsWith('/api/') || url.startsWith('/_')) {
    return false
  }

  // 檢測是否為爬蟲
  return getBotType(userAgent) !== null
}

Step 2: Express 伺服器

// server/prerenderServer.js

import express from 'express'
import { ServerPrerenderService } from './ServerPrerenderService.js'

const app = express()
const prerenderService = new ServerPrerenderService()

// 預渲染中間件
app.use(async (req, res, next) => {
  try {
    const userAgent = req.headers['user-agent'] || ''
    console.log(`🔍 Request: ${req.method} ${req.url}`)
    console.log(`🤖 User-Agent: ${userAgent}`)

    const html = await prerenderService.handleRequest(req)

    if (html) {
      console.log(`✅ Serving prerendered content for ${req.url}`)
      res.setHeader('Content-Type', 'text/html; charset=utf-8')
      res.setHeader('Cache-Control', 'public, max-age=3600') // 1 小時
      return res.send(html)
    }
  } catch (error) {
    console.error('❌ Prerender error:', error)
  }

  next()
})

// 靜態檔案
app.use(express.static('dist'))

// 健康檢查
app.get('/health', (req, res) => {
  res.json({
    status: 'healthy',
    service: 'prerender-server'
  })
})

// 快取管理
app.get('/_prerender/stats', (req, res) => {
  res.json(prerenderService.getCacheStats())
})

app.post('/_prerender/clear', (req, res) => {
  prerenderService.clearCache()
  res.json({ success: true })
})

// SPA Fallback
app.get('*', (req, res) => {
  res.sendFile(join(__dirname, '../dist/index.html'))
})

const port = process.env.PORT || 3001
app.listen(port, () => {
  console.log(`🚀 Prerender server running on port ${port}`)
})

Step 3: 預渲染服務

// server/ServerPrerenderService.js

import fs from 'fs'
import { join } from 'path'
import { shouldPrerender, getBotType } from '../src/utils/crawlerDetection.js'

export class ServerPrerenderService {
  constructor() {
    this.cache = new Map()
    this.cacheTimeout = 24 * 60 * 60 * 1000 // 24 小時
    this.maxCacheSize = 100
    this.distPath = join(__dirname, '../dist')
    this.indexPath = join(this.distPath, 'index.html')
  }

  // 產生快取鍵
  generateCacheKey(url, userAgent = '') {
    const botType = getBotType(userAgent)
    const cleanUrl = url.split('?')[0].split('#')[0]
    return `${cleanUrl}:${botType}`
  }

  // 取得快取
  getCachedHTML(url, userAgent = '') {
    const key = this.generateCacheKey(url, userAgent)
    const cached = this.cache.get(key)

    if (!cached) return null

    // 檢查是否過期
    const isExpired = Date.now() - cached.timestamp > this.cacheTimeout
    if (isExpired) {
      this.cache.delete(key)
      return null
    }

    return cached.html
  }

  // 儲存快取(LRU 策略)
  setCachedHTML(url, html, userAgent = '') {
    if (this.cache.size >= this.maxCacheSize) {
      const oldestKey = this.cache.keys().next().value
      this.cache.delete(oldestKey)
    }

    const key = this.generateCacheKey(url, userAgent)
    this.cache.set(key, {
      html,
      timestamp: Date.now(),
      url
    })
  }

  // 動態產生 HTML
  async generateStaticHTML(url, userAgent = '') {
    try {
      let html = fs.readFileSync(this.indexPath, 'utf-8')

      const botType = getBotType(userAgent)
      const cleanUrl = url.split('?')[0].split('#')[0]

      // 產生 meta tags
      const metaTags = await this.generateMetaTags(cleanUrl, botType)

      // 替換 title
      html = html.replace(
        /<title>.*?<\/title>/i,
        `<title>${metaTags.title}</title>`
      )

      // 注入 Open Graph 和 Twitter 標籤
      const metaTagsHTML = Object.entries(metaTags.meta).map(([key, value]) => {
        if (key.startsWith('og:') || key.startsWith('twitter:')) {
          return `    <meta property="${key}" content="${value}">`
        } else {
          return `    <meta name="${key}" content="${value}">`
        }
      }).join('\n')

      html = html.replace(
        /<head>/i,
        `<head>\n${metaTagsHTML}`
      )

      // 加入 structured data
      const structuredData = this.generateStructuredData(cleanUrl)
      if (structuredData) {
        html = html.replace(
          /<\/head>/i,
          `    <script type="application/ld+json">${JSON.stringify(structuredData, null, 2)}</script>\n  </head>`
        )
      }

      // 標記為預渲染
      html = html.replace(
        /<html/i,
        `<html data-prerendered="true" data-bot-type="${botType}"`
      )

      return html
    } catch (error) {
      console.error('❌ Error generating HTML:', error)
      return null
    }
  }

  // 根據路由產生 meta tags
  async generateMetaTags(url, botType = 'generic') {
    const baseUrl = process.env.VITE_BASE_URL || 'https://www.example.com'
    const fullUrl = `${baseUrl}${url}`

    const defaultMeta = {
      title: '範例網站-人類肥胖的終極解答',
      description: '數位內容平台,分享專業知識與實用資訊',
      image: `${baseUrl}/images/og-default.jpg`
    }

    // 靜態路由對應表
    const routeMetaMap = {
      '/about-us': {
        title: '關於我們 - 範例網站',
        description: '了解範例網站的理念、服務與專業團隊',
        image: 'https://wuling-strapi-prod-s3.s3.ap-southeast-1.amazonaws.com/4_2_R_3.jpg'
      },
      '/online-class': {
        title: '線上課程 - 範例網站',
        description: '豐富的健康管理線上課程,由專業醫師授課',
        image: 'https://wuling-strapi-prod-s3.s3.ap-southeast-1.amazonaws.com/01.jpg'
      }
    }

    let meta = defaultMeta

    // 處理靜態路由
    if (routeMetaMap[url]) {
      meta = { ...defaultMeta, ...routeMetaMap[url] }
    }
    // 處理動態路由(使用 fallback 資料)
    else if (url.includes('/service-us/')) {
      const serviceId = url.split('/service-us/')[1]?.split('?')[0]
      if (serviceId) {
        const serviceMeta = this.getServiceFallback(serviceId)
        meta = { ...defaultMeta, ...serviceMeta }
      }
    }
    else if (url.includes('/latest-news/category/')) {
      const newsId = url.split('/latest-news/category/')[1]?.split('?')[0]
      if (newsId) {
        const newsMeta = this.getNewsFallback(newsId)
        meta = { ...defaultMeta, ...newsMeta }
      }
    }

    return {
      title: meta.title,
      meta: {
        'description': meta.description,
        'robots': 'index,follow',
        'og:title': meta.title,
        'og:description': meta.description,
        'og:image': meta.image,
        'og:url': fullUrl,
        'og:type': 'website',
        'og:site_name': '範例網站',
        'twitter:card': 'summary_large_image',
        'twitter:title': meta.title,
        'twitter:description': meta.description,
        'twitter:image': meta.image
      }
    }
  }

  // Fallback 資料(避免每次都查詢 API)
  getServiceFallback(serviceId) {
    const fallbacks = {
      '1': {
        title: '健康管理服務 - 範例網站',
        description: '專業的健康管理和醫療諮詢服務',
        image: 'https://example.com/service-1.jpg'
      }
      // ...更多 fallback 資料
    }
    return fallbacks[serviceId] || {}
  }

  // 產生 structured data
  generateStructuredData(url) {
    const baseUrl = process.env.VITE_BASE_URL || 'https://www.example.com'

    if (url === '/') {
      return {
        '@context': 'https://schema.org',
        '@type': 'WebSite',
        'name': '範例網站',
        'url': baseUrl,
        'description': '數位內容平台,分享專業知識與實用資訊'
      }
    }

    if (url.includes('/service-us/')) {
      return {
        '@context': 'https://schema.org',
        '@type': 'MedicalBusiness',
        'name': '範例健康服務',
        'url': `${baseUrl}${url}`
      }
    }

    return null
  }

  // 處理請求
  async handleRequest(req) {
    const userAgent = req.headers['user-agent'] || ''
    const url = req.url

    if (!shouldPrerender({ headers: req.headers, url })) {
      return null
    }

    try {
      // 先檢查快取
      const cachedHTML = this.getCachedHTML(url, userAgent)
      if (cachedHTML) {
        console.log(`💾 Serving from cache: ${url}`)
        return cachedHTML
      }

      console.log(`🔨 Generating HTML for: ${url} (${getBotType(userAgent)})`)

      // 產生新的 HTML
      const html = await this.generateStaticHTML(url, userAgent)

      if (html) {
        this.setCachedHTML(url, html, userAgent)
        return html
      }

      return null
    } catch (error) {
      console.error('❌ Prerender error:', error)
      return null
    }
  }

  // 快取統計
  getCacheStats() {
    return {
      size: this.cache.size,
      maxSize: this.maxCacheSize,
      keys: Array.from(this.cache.keys()),
      memory: process.memoryUsage()
    }
  }

  // 清除快取
  clearCache() {
    this.cache.clear()
    console.log('🗑️ Cache cleared')
  }
}

Step 4: package.json 設定

{
  "scripts": {
    "dev": "vite --host --mode dev",
    "build": "vite build",
    "prerender:server": "node server/prerenderServer.js",
    "build:prerender": "npm run build && npm run prerender:server"
  }
}

Meta Server 整合與 GraphQL 支援

與 Strapi GraphQL 整合

如果你的後端使用 Strapi 並透過 GraphQL 提供資料,可以在 Meta Server 中整合 Apollo Client:

// meta-server.js 核心邏輯
const express = require('express');
const { ApolloClient, InMemoryCache } = require('@apollo/client/core');

const app = express();

// Apollo Client 設定,連接到 Strapi GraphQL API
const client = new ApolloClient({
  uri: process.env.VITE_GRAPHQL_API_URL + '/graphql',
  cache: new InMemoryCache(),
  headers: {
    authorization: `Bearer ${process.env.VITE_GRAPHQL_ACCESS_TOKEN}`
  }
});

// GraphQL 查詢定義
const GET_SERVICE_PAGE = gql`
  query GetServicePage($pagination: PaginationArg) {
    layoutDSingles(pagination: $pagination) {
      id
      title
      content
      openGraph {
        ogTitle
        ogDescription
        ogImage
      }
    }
  }
`;

// User Agent 檢測函數
function isCrawlerRequest(userAgent) {
  const crawlers = [
    'facebookexternalhit',
    'Twitterbot',
    'LinkedInBot',
    'WhatsApp',
    'Line',
    'LineBotMessenger',
    'Googlebot'
  ];

  return crawlers.some(crawler =>
    userAgent.toLowerCase().includes(crawler.toLowerCase())
  );
}

// 處理服務頁面的動態 meta 標籤
app.get('/service-us/:id', async (req, res) => {
  const serviceId = req.params.id;
  const userAgent = req.get('User-Agent') || '';

  console.log(`Request for service ${serviceId} from: ${userAgent}`);

  try {
    // 從 Strapi 獲取服務資料
    const { data } = await client.query({
      query: GET_SERVICE_PAGE,
      variables: { pagination: { limit: 100 } }
    });

    const serviceData = data?.layoutDSingles?.find(
      item => String(item.id) === String(serviceId)
    );

    if (!serviceData) {
      return res.status(404).send('Service not found');
    }

    // 生成動態 meta 標籤
    const metaTags = generateMetaTags(serviceData, req.originalUrl);

    // 將 meta 標籤注入 HTML 模板
    const html = htmlTemplate.replace(
      '<!-- SEO_META_PLACEHOLDER -->',
      metaTags
    );

    res.setHeader('Content-Type', 'text/html');
    res.send(html);

  } catch (error) {
    console.error('Error fetching service data:', error);
    // 回退到預設 meta 標籤
    const defaultMetaTags = generateMetaTags({}, req.originalUrl);
    const html = htmlTemplate.replace(
      '<!-- SEO_META_PLACEHOLDER -->',
      defaultMetaTags
    );
    res.send(html);
  }
});

Build-time Prerender 實作概覽

檔案結構差異

# Run-time (1 個檔案)
dist/
  └── index.html

# Build-time (每個路由一個檔案)
dist/
  ├── index.html
  ├── about-us/
  │   └── index.html
  ├── service-us/
  │   ├── 1/
  │   │   └── index.html
  │   └── 2/
  │       └── index.html
  └── latest-news/
      └── category/
          ├── 1/
          │   └── index.html
          └── 2/
              └── index.html

Puppeteer 爬取腳本

// scripts/prerender.js

import puppeteer from 'puppeteer'
import fs from 'fs'
import path from 'path'

const routes = [
  '/',
  '/about-us',
  '/online-class',
  '/service-us/1',
  '/service-us/2',
  '/latest-news/category/1',
  '/latest-news/category/2'
]

async function prerenderRoutes() {
  const browser = await puppeteer.launch()

  for (const route of routes) {
    console.log(`🔨 Prerendering ${route}...`)

    const page = await browser.newPage()
    await page.goto(`http://localhost:4173${route}`, {
      waitUntil: 'networkidle0'
    })

    // 等待 Vue 渲染完成
    await page.waitForSelector('[data-prerendered]')

    const html = await page.content()

    // 儲存 HTML
    const filePath = route === '/'
      ? 'dist/index.html'
      : `dist${route}/index.html`

    const dir = path.dirname(filePath)
    if (!fs.existsSync(dir)) {
      fs.mkdirSync(dir, { recursive: true })
    }

    fs.writeFileSync(filePath, html)
    console.log(`✅ Saved ${filePath}`)
  }

  await browser.close()
}

prerenderRoutes()

package.json 腳本

{
  "scripts": {
    "build": "vite build",
    "preview": "vite preview",
    "prerender": "node scripts/prerender.js",
    "build:prerender": "npm run build && npm run preview & sleep 5 && npm run prerender && pkill -f 'vite preview'"
  }
}

技術細節解析

1. User-Agent 偵測原理

const BOT_USER_AGENTS = {
  facebook: /facebookexternalhit|facebookcatalog/i,
  twitter: /twitterbot/i,
  google: /googlebot/i
}

// Facebook 爬蟲的 User-Agent 範例:
// "facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)"

2. 快取策略:LRU (Least Recently Used)

setCachedHTML(url, html, userAgent = '') {
  // 超過上限時刪除最舊的項目
  if (this.cache.size >= this.maxCacheSize) {
    const oldestKey = this.cache.keys().next().value
    this.cache.delete(oldestKey)
  }

  this.cache.set(key, {
    html,
    timestamp: Date.now(),
    url
  })
}

使用 JavaScript Map 的特性:

  • Map 保持插入順序
  • keys().next().value 取得第一個(最舊)的鍵

3. Cache-Control 標頭設定

res.setHeader('Cache-Control', 'public, max-age=3600')
  • public:允許 CDN 快取
  • max-age=3600:快取 1 小時(3600 秒)

可以根據需求調整:

  • 靜態頁面:max-age=86400(1 天)
  • 動態內容:max-age=300(5 分鐘)

4. HTML 注入技巧

// 在 <head> 開頭注入 meta tags
html = html.replace(
  /<head>/i,
  `<head>\n${metaTagsHTML}`
)

// 在 </head> 前注入 structured data
html = html.replace(
  /<\/head>/i,
  `    <script type="application/ld+json">${JSON.stringify(structuredData)}</script>\n  </head>`
)

使用正規表達式的 i flag 進行大小寫不敏感比對。

5. Fallback 資料設計

為什麼需要 Fallback?

// ❌ 每次都查詢 API(慢)
const serviceMeta = await fetch(`${API_URL}/services/${serviceId}`)

// ✅ 使用靜態 fallback(快)
const serviceMeta = getServiceFallback(serviceId)

Fallback 資料可以:

  1. 手動維護在程式碼中
  2. 從 JSON 檔案讀取
  3. 定期從 API 更新並快取

驗證與測試

1. 本機測試

# 啟動預渲染伺服器
npm run build
npm run prerender:server

# 模擬 Facebook 爬蟲
curl -A "facebookexternalhit/1.1" http://localhost:3001/service-us/1

# 查看快取統計
curl http://localhost:3001/_prerender/stats

# 清除快取
curl -X POST http://localhost:3001/_prerender/clear

2. 檢查預渲染是否生效

# 查看 HTML 是否包含正確的 meta tags
curl -A "facebookexternalhit/1.1" http://localhost:3001/about-us | grep "og:title"

# 應該會看到:
# <meta property="og:title" content="關於我們 - 範例網站">

3. Facebook 除錯工具

使用 Facebook Sharing Debugger

  1. 輸入你的網址
  2. 點擊「除錯」
  3. 查看「抓取資訊」是否正確顯示 Open Graph 標籤

4. LINE 分享測試

直接在 LINE 中分享連結,檢查預覽卡片是否顯示正確的標題和描述。

5. 壓力測試

# 使用 Apache Bench 測試
ab -n 1000 -c 10 -H "User-Agent: facebookexternalhit/1.1" http://localhost:3001/service-us/1

# 結果應顯示:
# - 快取命中率 > 99%
# - 平均回應時間 < 50ms

6. 監控快取效能

app.get('/_prerender/stats', (req, res) => {
  const stats = prerenderService.getCacheStats()
  res.json({
    cacheSize: stats.size,
    maxSize: stats.maxSize,
    hitRate: calculateHitRate(), // 自行實作
    memory: stats.memory,
    keys: stats.keys
  })
})

部署注意事項

Run-time Prerender 部署

Docker Compose

# docker-compose.yml
services:
  prerender-server:
    build:
      context: .
      dockerfile: Dockerfile.prerender
    ports:
      - "3001:3001"
    environment:
      - NODE_ENV=production
      - VITE_BASE_URL=https://www.example.com
    restart: unless-stopped

Dockerfile

# Dockerfile.meta
FROM node:18-alpine AS build
WORKDIR /app

# 安裝依賴並構建應用
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# 生產環境
FROM node:18-alpine AS production
WORKDIR /app

COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/scripts/meta-server.js ./scripts/
COPY --from=build /app/package.json ./

EXPOSE 3001
CMD ["npm", "run", "meta-server"]

Nginx 反向代理

# nginx.conf
upstream prerender {
    server localhost:3001;
}

server {
    listen 80;
    server_name www.example.com;

    location / {
        proxy_pass http://prerender;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header User-Agent $http_user_agent;
    }
}

Kubernetes 部署配置

# my_web_meta_prod_development.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-web-meta
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-web-meta
  template:
    spec:
      containers:
        - name: my-web-meta-prod
          image: your-registry/my-web-meta:latest
          ports:
            - containerPort: 3001
          env:
            - name: VITE_GRAPHQL_API_URL
              value: "https://api.abc.com"
            - name: VITE_GRAPHQL_ACCESS_TOKEN
              value: "your-token-here"

Build-time Prerender 部署

Nginx 設定

server {
    listen 80;
    server_name www.example.com;
    root /var/www/dist;

    # 嘗試尋找預渲染的檔案
    location / {
        try_files $uri $uri/index.html /index.html;
    }

    # 靜態資源快取
    location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

效能比較

Run-time Prerender

優點:

  • 建置時間快(< 1 分鐘)
  • 支援動態路由(新增內容無需重新部署)
  • 彈性高(可即時調整 meta tags)

缺點:

  • 需要 Node.js 伺服器(成本較高)
  • 首次訪問較慢(需即時產生 HTML)
  • 需要監控伺服器狀態

Build-time Prerender

優點:

  • 伺服器成本低(只需靜態檔案伺服器)
  • 回應速度快(直接讀取檔案)
  • 穩定性高(無程式執行風險)

缺點:

  • 建置時間長(需爬取所有路由)
  • 不支援動態路由(新增內容需重新建置)
  • 檔案數量多(管理複雜)

實測數據(1000 次請求)

指標Run-time (快取命中)Run-time (快取未命中)Build-time
平均回應時間12ms85ms8ms
記憶體使用150MB150MB50MB
CPU 使用率5%30%2%
建置時間45秒45秒8分鐘

實施過程中的挑戰

1. GraphQL 認證問題

初期測試時,meta-server 無法連接到 Strapi GraphQL API:

# 錯誤日誌
ConnectTimeoutError: Connect Timeout Error

解決方法:確保環境變數中包含正確的 GraphQL access token。

2. Docker 構建問題

最初使用 npm ci --only=production 導致 Vite 無法找到:

sh: vite: not found

解決方法:改用 npm ci 安裝所有依賴,包括 devDependencies。

3. Kubernetes 資源限制

在部署過程中遇到節點資源不足的問題:

0/1 nodes are available: 1 Too many pods

解決方法:採用並行部署策略,先測試新服務再切換流量。

4. SSL 憑證配置錯誤

在切換服務過程中,意外發現 Ingress 中的 SSL 憑證名稱配置錯誤:

# 錯誤的配置
secretName: www-abc-com-custom-cert # 不存在
# 正確的配置
secretName: www-abc-com-cert # 實際存在的憑證

常見問題

Q1: Run-time Prerender 會消耗很多記憶體嗎?

取決於快取大小設定:

this.maxCacheSize = 100  // 約 50-100MB
this.maxCacheSize = 1000 // 約 500MB - 1GB

建議根據伺服器規格調整:

  • 小型專案(< 100 頁):100 個快取項目
  • 中型專案(< 1000 頁):500 個快取項目
  • 大型專案:考慮使用 Redis

Q2: 如何處理需要即時資料的頁面?

方案 1:混合策略

// 預渲染提供基本 meta tags(for 爬蟲)
<title>線上課程 - 範例網站</title>

// Vue App 載入後更新為即時資料(for 使用者)
onMounted(async () => {
  const data = await fetchCourseData()
  document.title = `${data.title} - 範例網站`
})

方案 2:快取時間縮短

this.cacheTimeout = 5 * 60 * 1000  // 5 分鐘

Q3: 如何避免爬蟲快取過期資料?

方案 1:主動清除快取

// 當內容更新時,呼叫 API 清除快取
await fetch('http://localhost:3001/_prerender/clear', {
  method: 'POST'
})

方案 2:版本化 URL

// 在 URL 加入版本號
query.filters.user = { updatedAt: { $gte: '2024-01-01' } }

Q4: Build-time 如何處理數千個動態路由?

方案 1:部分預渲染

只預渲染熱門頁面:

// scripts/prerender.js
const popularRoutes = await fetch('/api/popular-pages?limit=100')
const routes = popularRoutes.map(r => r.url)

方案 2:增量預渲染

只重新建置更新的頁面:

const changedRoutes = await getChangedRoutes() // 從 Git 或資料庫取得
for (const route of changedRoutes) {
  await prerenderRoute(route)
}

Q5: 兩種策略可以混合使用嗎?

可以!最佳實踐:

┌─────────────────────────────────────┐
│  靜態頁面(about-us, contact)       │
│  → Build-time Prerender            │
└─────────────────────────────────────┘

┌─────────────────────────────────────┐
│  動態頁面(service-us/*, news/*)    │
│  → Run-time Prerender              │
└─────────────────────────────────────┘

實作方式:

// Nginx 配置
location ~* ^/(about-us|contact) {
    # 直接讀取預渲染檔案
    try_files $uri $uri/index.html /index.html;
}

location / {
    # 轉發到 Node.js 伺服器
    proxy_pass http://prerender-server:3001;
}

替代方案探討

雖然本文主要介紹了 Run-time 和 Build-time 兩種預渲染方案,但還有其他可行的解決方案:

1. Cloudflare Workers

可以在 CDN 層面處理爬蟲請求:

addEventListener('fetch', event => {
  event.respondWith(handleRequest(event.request))
})

async function handleRequest(request) {
  const userAgent = request.headers.get('User-Agent') || ''

  if (isCrawler(userAgent)) {
    // 返回預渲染的 HTML
    return generateMetaHTML(request.url)
  }

  // 返回正常的 SPA
  return fetch(request)
}

2. 預渲染服務

使用 Puppeteer 等工具預渲染特定頁面:

const puppeteer = require('puppeteer');

async function prerenderPage(url) {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.goto(url, { waitUntil: 'networkidle0' });
  const content = await page.content();
  await browser.close();
  return content;
}

3. 遷移到 Nuxt.js 或其他 SSR 框架

  • 優點:完整的 SSR 支援
  • 缺點:需要重寫整個應用,成本過高

經驗教訓與最佳實踐

1. 理解社交媒體爬蟲的限制

  • 不執行 JavaScript:所有動態內容對爬蟲都是不可見的
  • 有特定的 User Agent:可以利用這點來識別爬蟲請求
  • 快取機制:Facebook 等平台會快取 OpenGraph 資料

2. 部署策略

  • 並行部署:同時運行新舊服務,降低停機風險
  • 漸進式切換:先測試新服務,確認無誤後再切換流量
  • 快速回滾:準備回滾腳本,遇到問題立即恢復

3. 監控與除錯

  • 詳細日誌:記錄所有爬蟲請求和處理過程
  • 健康檢查:確保服務正常運行
  • 錯誤處理:優雅降級,避免服務中斷

4. 環境配置管理

  • 敏感資訊保護:使用 Kubernetes Secrets 管理 API tokens
  • 環境變數驗證:確保所有必要的配置都正確設定
  • 憑證管理:定期檢查和更新 SSL 憑證

5. 技術選型的重要性

在專案初期就應該考慮 SEO 和社交媒體分享的需求,選擇合適的技術架構。

6. 漸進式改進

不需要重寫整個應用,可以通過添加輔助服務來解決特定問題。

總結

選擇指南

選擇 Run-time Prerender,如果:

  • 內容經常變動(每天更新)
  • 有大量動態路由(> 100 個)
  • 需要根據爬蟲類型客製化內容
  • 有足夠的伺服器資源

選擇 Build-time Prerender,如果:

  • 內容相對固定(每週更新)
  • 路由數量少(< 50 個)
  • 追求極致的回應速度
  • 想降低伺服器成本

關鍵要點

  1. Run-time Prerender 本質是動態伺服器端渲染(SSR)的精簡版

    • 只針對爬蟲,不針對一般使用者
    • 使用快取避免重複渲染
  2. 爬蟲偵測是核心

    • 必須準確判斷 User-Agent
    • 避免一般使用者也拿到預渲染版本(影響互動功能)
  3. 快取策略要合理

    • 平衡記憶體使用和效能
    • 設定合理的過期時間
    • 提供手動清除機制
  4. 測試要充分

    • 使用 Facebook Debugger 驗證
    • 模擬各種爬蟲的 User-Agent
    • 壓力測試確保穩定性
  5. 混合策略是最佳解

    • 靜態頁面用 Build-time
    • 動態頁面用 Run-time
    • 根據實際需求靈活調整
  6. 部署安全

    • 採用並行部署和快速回滾策略
    • 確保服務穩定性
    • 充分的監控和日誌

最終建議

這次解決 OpenGraph 標籤問題的經歷讓我深刻理解了 SPA 應用在社交媒體分享方面的限制,以及如何通過技術手段克服這些挑戰。雖然實施過程中會遇到各種問題(GraphQL 認證、Docker 構建、Kubernetes 資源限制、SSL 憑證配置等),但只要理解問題的本質,選擇合適的方法,並在實施過程中保持謹慎和耐心,技術問題總有解決方案。

記住,技術選型沒有絕對的對錯,關鍵是要根據你的專案特性、團隊能力和資源限制來選擇最合適的方案。

完整的程式碼範例可以參考 GitHub Repository 或根據本文的範例進行調整。

希望這篇文章能幫助你選擇適合的預渲染策略,並成功實現 Vue SPA 的 OpenGraph 支援!如果有任何問題,歡迎留言討論。


標籤: #Vue.js #SEO #Prerender #SSR #Node.js #Express #Open-Graph #Performance #Strapi #GraphQL #Docker #Kubernetes

相關文章:

  • Vue SPA SEO 完整指南
  • 深入理解 Open Graph 協議
  • Express 中間件開發實戰
  • Web 效能優化:快取策略大全
  • Kubernetes 部署最佳實踐