
最近想在部落格的文章頁面加上瀏覽數統計,雖然已經有用Google Analytics,但想讓前台使用者也可以看到數字,就決定自己寫一個輕量的 PV 組件
唯一難處只有資料該怎麼儲存呢?剛好最近在看Cloudflare Worker KV
為什麼選 Cloudflare Workers KV?
Cloudflare Workers KV 是 Cloudflare 提供的全球分散式 Key-Value 儲存服務,專門為 Workers 設計,適合用在:
- 計數器(PV / UV / like)
- 設定值、feature flag
- 快取型資料(讀多寫少)
它的特點是:
- 無需自行架設資料庫
- 讀取速度快,延遲低
- 與 Worker 原生整合
- 低用量免費!
在這次 PV 統計的場景中,每個「文章 URL」就是一個 key,瀏覽數就是對應的 value,非常符合 KV 的使用模型
需要注意的是,KV 屬於最終一致性(eventual consistency),不適合高精度即時統計,但對部落格 PV 這種「大概準就好」的用途來說,效能與成本的平衡非常理想
需求整理
需求很簡單:
- 在文章的 post-info 區塊(日期、分類、標籤那一排)加上「眼睛 icon + 數字」
- 打 API 取得當前頁面的瀏覽數,API 會自動 +1
做成組件還是插件?
在 VitePress 架構下,這種小功能做成 Vue 組件 比較合適,原因:
- 現有的
PostDate、PostCategory、PostTag都是小型SFC(Single File Components) - 只需要在文章頁顯示,不用跨多個地方共用
- 不需要插件的生命週期控制
決定就做成與現有組件風格一致的 PostPageView.vue
API 規格
自架的 PV API 規格很簡單:
GET /pv
功能:取得當前頁面的瀏覽數並自動 +1
請求:
- Method: GET
- Headers:
- Referer: 必填,完整 URL(含 domain + path)
- Origin: 選填,用於 CORS 驗證
回應格式:
{
"count": 42
}API 透過 Referer header 識別不同頁面,每次 GET 都會自動計數 +1
2026-07 更新
這個「靠 Referer 識別頁面」的設計後來踩坑了:Safari 會把跨站 Referer 裁到只剩 origin,導致手機拿到錯誤的數字。詳見2026-07 更新:Referer 的坑
實作步驟
1. 在 config.js 加入 API URL 設定
在 .vitepress/config.js 頂部新增常數:
const PV_API_URL = 'https://api.logicat.tw/pv/'並加進 themeConfig:
themeConfig: {
pvApiUrl: PV_API_URL,
// ...其他設定
}這樣在 Vue 組件中就能透過 useData() 取得 theme.value.pvApiUrl
2. 建立 PostPageView.vue 組件
參考 PostDate.vue 的結構,做一個類似的組件:
<template>
<span v-if="count !== null" class="post-pv">
<svg class="pv-icon" viewBox="0 0 20 20" fill="currentColor">
<!-- 眼睛 icon 的 SVG path -->
</svg>
{{ count }}
</span>
</template>
<script lang="ts" setup>
import { useData } from 'vitepress'
import { onMounted, ref } from 'vue'
const count = ref<number | null>(null)
const emit = defineEmits<{
ready: []
}>()
const { theme } = useData()
onMounted(async () => {
const apiUrl = theme.value.pvApiUrl
if (!apiUrl) {
emit('ready')
return
}
try {
const response = await fetch(apiUrl, {
method: 'GET',
referrerPolicy: 'unsafe-url',
})
if (response.ok) {
const data = await response.json()
count.value = data.count
} else {
count.value = 0
}
} catch {
count.value = 0
}
emit('ready')
})
</script>
<style scoped>
.post-pv {
display: inline-flex;
align-items: center;
gap: calc(var(--inline-gap) / 2);
font-weight: 500;
font-size: 0.75rem;
}
.pv-icon {
width: 14px;
height: 14px;
}
</style>重點設計:
- count 初始為
null:用來區分「未啟用」與「已啟用但載入中」 - 未設定 API URL 時:直接 emit
ready並 return,不發請求 - API 失敗時顯示 0:本地開發時 API 可能驗證來源失敗,顯示 0 比隱藏更清楚
referrerPolicy: 'unsafe-url':讓瀏覽器帶完整 URL 作為 Referer header
3. 註冊組件
在 .vitepress/theme/index.js 中 import 並註冊:
import PostPageView from './components/PostPageView.vue'
export default {
// ...
enhanceApp({ app }) {
app.component('PostPageView', PostPageView)
// ...
},
}4. 插入到 ThemeLayout.vue
在 post-info 區塊加入 PostPageView:
<div
v-if="!frontmatter.page"
class="post-info"
:class="{ 'post-info-hidden': !postInfoReady }"
>
<PostDate :date="frontmatter.date" />
<PostPageView @ready="postInfoReady = true" />
<PostCategory v-if="frontmatter.category" ... />
<PostTag v-for="item in frontmatter.tags" ... />
</div>在 script 部分加上狀態管理:
const postInfoReady = ref(false)
// 路由切換時重置,避免顯示舊的 PV 數字
watch(
() => route.path,
() => {
postInfoReady.value = false
},
)處理載入體驗
一開始遇到的問題:PV組件載入完成後才出現,會造成post-info區塊的高度跳動(layout shift)。
解決方式:
- 預留高度:用
opacity: 0取代v-show,讓 post-info 一開始就佔據空間但不可見 - 淡入動畫:加上
transition: opacity 0.3s ease-out,讓出現更平滑
CSS 調整:
.post-info {
transition: opacity 0.3s ease-out;
}
.post-info-hidden {
opacity: 0;
}這樣 post-info 區塊會:
- 一開始就存在並佔據空間(
opacity: 0) - API 完成後移除
.post-info-hiddenclass - 透明度從 0 漸變到 1,呈現平滑淡入效果
- 完全沒有 layout shift
心得
這次實作很順利,VitePress的組件架構很清楚,只要:
- 參考現有組件的結構
- 在
themeConfig加設定 - 用
useData()取得設定值 - 處理好載入狀態就完成了
如果你也想在VitePress加入自訂功能,這套模式可以直接套用:
- 小型展示功能 → Vue 組件
- 設定值放
themeConfig - 載入體驗用
opacity+transition處理
完整內容可以看部落格的commit
2026-07 更新:Referer 的坑
上線半年後發現一個奇怪的現象:同一篇文章,手機看到的 PV 跟電腦不一樣,而且手機上每篇文章的數字都長一樣
原因:Safari 會把跨站 Referer 裁到只剩 origin
原本的設計靠 Referer header 識別頁面,並用 referrerPolicy: 'unsafe-url' 要求瀏覽器帶完整 URL。問題是這個設定不是每個瀏覽器都買單:
- 桌面 Chrome / Edge:尊重
unsafe-url,跨站請求送出完整網址 - iOS Safari(以及 iOS 上所有瀏覽器,核心都是 WebKit):基於隱私保護,跨站請求的 Referer 一律裁成只剩 origin,
unsafe-url直接被忽略,網頁無法覆蓋 - Firefox 嚴格追蹤保護模式、Brave 也有類似行為
於是手機每篇文章打 API 時,後端看到的 Referer 都是 https://kakahikari.github.io/,全部計進根路徑的計數器——手機拿到的永遠是那個混在一起的數字
修法:改用 query 參數,別依賴 Referer
頁面資訊改由前端明確帶 path 參數,不再依賴 Referer:
// 正規化路徑作為計數 key,避免 /index.html 與 /、有無 .html 被拆成不同計數
const path = location.pathname
.replace(/\/index\.html$/, '/')
.replace(/\.html$/, '')
const response = await fetch(`${apiUrl}?path=${encodeURIComponent(path)}`)referrerPolicy: 'unsafe-url' 也一併移除,瀏覽器預設政策就會帶 origin-only 的 Referer 和 Origin header,足夠後端做來源驗證
後端對應調整:
- 改用 query 的
path作為計數 key - 來源驗證改看
Originheader——Origin 在所有瀏覽器都不會被隱私機制裁掉,跨站 fetch 一定會帶 path參數要驗證與正規化(/開頭、限制長度、套用和前端相同的正規化規則),畢竟任何人都能亂帶參數- 舊資料要把「完整 URL」的 key 轉成新的 path 格式,才能保留歷史計數
順帶修掉的另一個坑:SPA 切頁不會重新請求
修的時候發現 PostPageView 放在 layout 的 slot 裡沒有 key,SPA 切頁(例如用上一篇/下一篇連結)時組件實例會被重用,onMounted 不會再觸發——PV 停在舊數字,ready 也不會重新 emit。加上 :key 讓它每次切頁都重新掛載:
<PostPageView :key="route.path" @ready="postInfoReady = true" />教訓
Referer 是給後端做來源驗證用的,不要拿它攜帶業務資料——各家瀏覽器基於隱私會裁掉多少內容你控制不了,只有 origin 部分是大家都保證會留下的。要傳頁面資訊,自己放進 query 或 body 最可靠
參考資料: