Skip to content

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)

  1. 路由无刷新切换:页面间的跳转基于 HTML5 History API (pushState) 和 Vue Router 机制,浏览器不会重新发起页面加载,因此 <script> 标签与 DOMContentLoaded 事件仅在首次进入网站时触发一次
  2. DOM 重新挂载与丢失:Vue 在切换页面时会卸载旧组件的 DOM 并挂载新组件的 DOM。若第三方脚本在初始加载时通过 document.getElementById 缓存了 DOM 引用,后续新页面中的 <span id="vercount_value_page_pv"> 将永远无法被该脚本更新。
  3. 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 Theme

3.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>

在页脚底部展示全站 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 服务端渲染环境执行的。任何直接访问 windowdocumentlocalStoragelocation 的代码都会导致构建崩溃(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 模板中绑定 &#123;&#123; vercountState.page_pv &#125;&#125; 能够享受 Vue 3 极致的高性能响应式更新;同时保留对 #vercount_value_xxx#busuanzi_value_xxx DOM 节点的自动注入,使在 Markdown 正文中直接手写的原生 HTML 标签也能无缝生效。
6. 超时与容错处理第三方 API 可能会受到网络抖动影响。封装 fetch 时通过 AbortController 设定 6 秒请求超时中断,并在捕获异常后安全静默回退,绝不阻塞页面的正常渲染与浏览。