前言
在現代 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-time | Build-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 資料可以:
- 手動維護在程式碼中
- 從 JSON 檔案讀取
- 定期從 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 除錯工具
- 輸入你的網址
- 點擊「除錯」
- 查看「抓取資訊」是否正確顯示 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 |
|---|---|---|---|
| 平均回應時間 | 12ms | 85ms | 8ms |
| 記憶體使用 | 150MB | 150MB | 50MB |
| 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 個)
- 追求極致的回應速度
- 想降低伺服器成本
關鍵要點
Run-time Prerender 本質是動態伺服器端渲染(SSR)的精簡版
- 只針對爬蟲,不針對一般使用者
- 使用快取避免重複渲染
爬蟲偵測是核心
- 必須準確判斷 User-Agent
- 避免一般使用者也拿到預渲染版本(影響互動功能)
快取策略要合理
- 平衡記憶體使用和效能
- 設定合理的過期時間
- 提供手動清除機制
測試要充分
- 使用 Facebook Debugger 驗證
- 模擬各種爬蟲的 User-Agent
- 壓力測試確保穩定性
混合策略是最佳解
- 靜態頁面用 Build-time
- 動態頁面用 Run-time
- 根據實際需求靈活調整
部署安全
- 採用並行部署和快速回滾策略
- 確保服務穩定性
- 充分的監控和日誌
最終建議
這次解決 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 部署最佳實踐