VitePress 接入 Vercount 访客统计指南与 SPA 路由适配实践
本文档详细记录了在 VitePress 静态博客中接入 Vercount(现代访客计数系统)的完整实现方案、核心原理、单页应用(SPA)路由适配机制及关键注意事项。
1. 背景与技术选型
1.1 什么是 Vercount?
Vercount 是一个基于 Serverless 架构的极简无服务器网站计数器,设计目标是作为经典计数服务「不蒜子(Busuanzi)」的高可用、无广告、现代化的替代品。
- 全站访问量 (Site PV):站点所有页面的总访问次数。
- 全站访客数 (Site UV):站点的独立访客数(基于 Client Cookie 去重)。
- 单页阅读量 (Page PV):特定文章/页面的独立浏览次数。
1.2 为什么不能直接使用 <script> 注入?
传统静态多页应用(MPA)每次点击链接都会触发浏览器整页刷新(Page Reload),此时 HTML 重新解析,<script defer src="..."></script> 自动执行并统计当前 URL。
然而,VitePress 在构建后运行时是一个 Vue 3 单页应用(SPA):
- 路由无刷新切换:页面间的跳转基于 HTML5 History API (
pushState) 和 Vue Router 机制,浏览器不会重新发起页面加载,因此<script>标签与DOMContentLoaded事件仅在首次进入网站时触发一次。 - DOM 重新挂载与丢失:Vue 在切换页面时会卸载旧组件的 DOM 并挂载新组件的 DOM。若第三方脚本在初始加载时通过
document.getElementById缓存了 DOM 引用,后续新页面中的<span id="vercount_value_page_pv">将永远无法被该脚本更新。 - URL 变更无法被自动捕获:静态嵌入的脚本不会自动监听客户端 URL 的变化,导致后续浏览的文章无法上报 PV,阅读量始终保持初始状态或显示
--。
2. 核心设计与实现架构
为了优雅且稳定地解决 SPA 路由切换统计问题,我们采用了 「主题扩展 + 路由监听钩子 + 响应式状态管理 + DOM 回填兜底」 的双轨架构。
mermaid
flowchart TD
A[用户访问/路由切换] --> B{是否在浏览器环境 inBrowser?}
B -- 否 SSR构建阶段 --> C[安全跳过,防止 Node 报错]
B -- 是 客户端运行 --> D[触发 router.onAfterRouteChanged 或 onMounted]
D --> E[调用 updateVercount]
E --> F[读取 LocalStorage 缓存并立即可见渲染]
E --> G[并发检查/写入 UV Cookie 区分新老访客]
E --> H[向 Vercount API 异步上报当前完整 URL]
H --> I[更新响应式状态 vercountState]
H --> J[调用 nextTick 等待 Vue DOM 树更新完毕]
J --> K[批量回填 #vercount_value_xxx 与 #busuanzi_value_xxx DOM 节点]3. 核心代码模块解析
3.1 统计核心模块:.vitepress/theme/vercount.ts
该模块封装了 UV 计算、网络请求、数据标准化、LocalStorage 缓存预填充以及 DOM 批量回填逻辑,同时对外暴露响应式对象 vercountState。
ts
import { ref, reactive, nextTick } from 'vue'
import { inBrowser } from 'vitepress'
export interface VercountData {
site_pv: number
site_uv: number
page_pv: number
}
const STORAGE_KEY = 'visitorCountData'
const UV_PREFIX = 'vercount_uv_'
const API_URL = 'https://events.vercount.one/api/v2/log'
// 响应式数据状态,供 Vue 模板中直接双向绑定
export const vercountState = reactive<VercountData>({
site_pv: 0,
site_uv: 0,
page_pv: 0
})
export const isVercountLoading = ref(false)
/**
* 格式化 API 返回数据
*/
function normalizeData(raw: any): VercountData {
if (raw?.status === 'success' && raw.data) {
return {
site_pv: Number(raw.data.site_pv ?? 0),
site_uv: Number(raw.data.site_uv ?? 0),
page_pv: Number(raw.data.page_pv ?? 0)
}
}
return {
site_pv: Number(raw?.site_pv ?? 0),
site_uv: Number(raw?.site_uv ?? 0),
page_pv: Number(raw?.page_pv ?? 0)
}
}
/**
* 校验并写入 UV Cookie(与 Vercount 官方逻辑对齐,1 年有效期)
*/
function checkAndSetUvCookie(): boolean {
if (!inBrowser) return false
try {
const host = window.location.host || 'unknown-host'
const cookieKey = `${UV_PREFIX}${host.replace(/[^a-zA-Z0-9_-]/g, '_')}`
const cookies = document.cookie.split('; ')
const hasUv = cookies.some((c) => c.startsWith(`${cookieKey}=`))
if (!hasUv) {
document.cookie = `${cookieKey}=1; path=/; max-age=31536000; samesite=lax`
return true
}
return false
} catch {
return false
}
}
/**
* 回填数据至 DOM 元素(同时兼容 Vercount 与 Busuanzi 的经典 ID 选择器)
*/
function updateDomElements(data: VercountData) {
if (!inBrowser) return
const targets = [
{
key: 'site_pv' as const,
ids: ['vercount_value_site_pv', 'busuanzi_value_site_pv'],
containerIds: ['vercount_container_site_pv', 'busuanzi_container_site_pv']
},
{
key: 'site_uv' as const,
ids: ['vercount_value_site_uv', 'busuanzi_value_site_uv'],
containerIds: ['vercount_container_site_uv', 'busuanzi_container_site_uv']
},
{
key: 'page_pv' as const,
ids: ['vercount_value_page_pv', 'busuanzi_value_page_pv'],
containerIds: ['vercount_container_page_pv', 'busuanzi_container_page_pv']
}
]
targets.forEach(({ key, ids, containerIds }) => {
const val = String(data[key] || '0')
ids.forEach((id) => {
const el = document.getElementById(id)
if (el) el.textContent = val
})
containerIds.forEach((cid) => {
const container = document.getElementById(cid)
if (container) container.style.display = 'inline'
})
})
}
/**
* 主动触发更新当前页面的统计数据
*/
export async function updateVercount(url?: string): Promise<VercountData | null> {
if (!inBrowser) return null
const currentUrl = url || window.location.href
if (!currentUrl.startsWith('http')) return null
isVercountLoading.value = true
// 1. 优先读取缓存,秒开渲染,避免页面数字闪烁
const cached = getCachedData()
if (cached) {
Object.assign(vercountState, cached)
await nextTick()
updateDomElements(cached)
}
const isNewUv = checkAndSetUvCookie()
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 6000)
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: currentUrl, isNewUv }),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!res.ok) throw new Error(`Vercount HTTP ${res.status}`)
const json = await res.json()
const latestData = normalizeData(json)
// 2. 更新响应式状态与持久化缓存
Object.assign(vercountState, latestData)
setCachedData(latestData)
// 3. 待 DOM 节点更新后批量写入 ID 容器
await nextTick()
updateDomElements(latestData)
isVercountLoading.value = false
return latestData
} catch (err) {
clearTimeout(timeoutId)
isVercountLoading.value = false
return cached
}
}3.2 主题扩展与路由监听:.vitepress/theme/index.ts
在 VitePress 提供的 enhanceApp 生命周期钩子中挂载 router.onAfterRouteChanged,确保用户点击任何内链跳转时均能被捕获:
ts
import DefaultTheme from 'vitepress/theme'
import type { Theme } from 'vitepress'
import { inBrowser } from 'vitepress'
import { updateVercount } from './vercount'
// ... 其他组件引入
export default {
...DefaultTheme,
Layout: NewLayout,
enhanceApp({ app, router }) {
// ... 注册全局组件
// 监听 SPA 路由变化,在路由切换后手动触发计数更新
if (inBrowser && router) {
router.onAfterRouteChanged = () => {
updateVercount()
}
}
}
} satisfies Theme3.3 首次加载与布局组件集成:.vitepress/theme/components/NewLayout.vue
在根布局组件挂载 (onMounted) 时执行初次统计,确保直接输入 URL 进入页面时亦能准确计数:
vue
<script setup>
import { onMounted } from 'vue'
import { inBrowser } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import Copyright from './Copyright.vue'
import ArticleMeta from './ArticleMeta.vue'
import { updateVercount } from '../vercount'
const { Layout } = DefaultTheme
onMounted(() => {
if (inBrowser) {
updateVercount()
}
})
</script>3.4 文章阅读量展示:.vitepress/theme/components/ArticleMeta.vue
在文章详情页中,同时适配了移动端顶部胶囊栏与桌面端左下角固定栏:
vue
<!-- 移动端顶部 -->
<div class="mobile-item">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
<span id="vercount_container_page_pv">
<span id="vercount_value_page_pv">{{ vercountState.page_pv || '--' }}</span> 次阅读
</span>
</div>
<!-- 桌面端左下角信息栏 -->
<li class="aside-item aside-info-text">
<span id="vercount_container_page_pv">
阅读:<span id="vercount_value_page_pv">{{ vercountState.page_pv || '--' }}</span> 次
</span>
</li>3.5 全站访问量与访客数展示:.vitepress/theme/components/Copyright.vue
在页脚底部展示全站 PV 与 UV 统计信息:
vue
<div class="site-stats" id="vercount_container_site_pv">
<span>本站总访问量 <span id="vercount_value_site_pv">{{ vercountState.site_pv || '--' }}</span> 次</span>
<span class="site-stats-divider">|</span>
<span id="vercount_container_site_uv">本站访客数 <span id="vercount_value_site_uv">{{ vercountState.site_uv || '--' }}</span> 人</span>
</div>4. 关键注意事项与避坑指南 (Knowledge Points)
| 知识点 / 注意事项 | 说明与最佳实践 |
|---|---|
1. SSR 防护 (inBrowser) | VitePress 执行 vitepress build 时是在 Node.js 服务端渲染环境执行的。任何直接访问 window、document、localStorage 或 location 的代码都会导致构建崩溃(ReferenceError: window is not defined)。必须使用 VitePress 提供的 inBrowser 常量或 typeof window !== 'undefined' 进行前置防护。 |
2. 路由更新后的 DOM 挂载时机 (nextTick) | Vue 3 在路由切换后修改 DOM 节点是异步的。如果立即使用 document.getElementById 查找节点,可能获取到的是旧页面即将销毁的节点或 null。必须使用 await nextTick() 确保新页面的 DOM 节点挂载完毕后再执行数据回填。 |
| 3. UV 访客去重与 Cookie 规范 | UV(独立访客)计数依赖于客户端持久化标识。Vercount 使用站点 Host 专有的 Cookie(vercount_uv_<host>)标记访客。为符合现代浏览器隐私标准与跨站安全规范,写入 Cookie 时需声明 path=/; max-age=31536000; samesite=lax。 |
| 4. 缓存预填充与平滑体验 | 网络请求存在几百毫秒的延迟。为防止用户在页面跳转时看到统计数据从空白/-- 闪烁跳变为具体数字,利用 localStorage 缓存上一次请求的统计结果,在 API 响应前先行填充,待新数据到达后再无缝覆盖。 |
| 5. 响应式与 DOM ID 双轨兼容 | 直接在 Vue 模板中绑定 {{ vercountState.page_pv }} 能够享受 Vue 3 极致的高性能响应式更新;同时保留对 #vercount_value_xxx 和 #busuanzi_value_xxx DOM 节点的自动注入,使在 Markdown 正文中直接手写的原生 HTML 标签也能无缝生效。 |
| 6. 超时与容错处理 | 第三方 API 可能会受到网络抖动影响。封装 fetch 时通过 AbortController 设定 6 秒请求超时中断,并在捕获异常后安全静默回退,绝不阻塞页面的正常渲染与浏览。 |