在 VitePress 静态博客中集成不蒜子统计:优雅实现站点 PV/UV 计数

详细介绍如何在 VitePress 静态站点中集成不蒜子(busuanzi)统计工具,实现站点总 PV/UV 和单页面 PV 的云端统计。涵盖脚本引入、标签部署、Hydration Mismatch 修复、样式定制与常见问题排查。

10 分钟阅读2.0k 字

引言:为什么选择不蒜子

对于一个静态博客而言,"我有多少人看过?"始终是一个绕不开的问题。不同于 WordPress、Hexo 这类带数据库的传统博客,VitePress 生成的纯静态站点本身不记录任何访问行为。我们通常有三种解决思路:

方案 优点 缺点
Google Analytics 业界标准、功能全面 国内访问慢、隐私合规问题、需科学上网
自建后端计数 自主可控、可深度定制 需要服务器、运维成本、违背纯静态理念
不蒜子(busuanzi) 零后端、零部署、CDN 加速 样式较朴素、统计维度固定

不蒜子(官网 busuanzi.cc)是站长圈广泛使用的一款纯前端访问量统计服务。它最大的魅力在于:

  • 零后端依赖:所有计数逻辑在它的云端完成,你只需要在页面挂一个 <script> 标签 + 一个 <span id="..."> 占位标签
  • CDN 加速:通过 //cdn.busuanzi.cc 加载,国内访问速度良好
  • 轻量级:核心脚本 minified 后仅几 KB,对 LCP 影响极小
  • PV / UV 双指标:自带 Page View(页面浏览量)和 Unique Visitor(独立访客)统计
  • 支持自定义样式:通过原生 HTML 标签,你可以用任何 CSS 自由装饰

本文将以 VitePress 1.4 + Vue 3 为例,从零开始带你完整集成不蒜子,并解决实践中容易踩到的 Hydration Mismatch 难题。


一、不蒜子工作原理

在动手前,先理解不蒜子的运行机制能帮我们避开很多坑。它的核心流程只有 3 步:

TEXT
1. 浏览器加载你的页面,解析到 <script src="//cdn.busuanzi.cc/.../busuanzi.min.js">
2. 脚本扫描页面上所有以 busuanzi_ 开头的 <span id="..."> 标签
3. 脚本异步向 busuanzi.cc 发起请求,云端累加计数后回填到对应 span 的 innerText

由此可以提炼出几个关键点:

  • 脚本是异步加载async 关键字),不会阻塞首屏渲染
  • 标签必须用 id(不是 classdata-attr),脚本按 ID 命名约定识别
  • 必须有 async:否则首屏渲染会等待统计请求完成
  • 回填是 DOM 操作:脚本执行时直接修改 span.innerText,不经过 Vue 响应式系统 → 这就是 SSR 框架 Hydration Mismatch 的根源

二、v2.6 集成三步曲

下面进入实战。FilePress Blog 在 v2.6 完成了不蒜子集成,共分 3 步:注入脚本 → 部署标签 → 修复 Hydration

步骤 1:注入不蒜子脚本

打开 .vitepress/config.mts,在 head 数组中添加一行:

TS
// .vitepress/config.mts
export default defineConfig({
  // ... 其他配置
  head: [
    // 注入不蒜子统计脚本(v2.6 新增)
    [
      'script',
      {
        async: true,
        src: '//cdn.busuanzi.cc/busuanzi/3.6.9/busuanzi.abbr.min.js'
      }
    ],
    // ... 其他 head 配置
  ]
})

💡 选型说明:v2.6 选用 busuanzi.abbr.min.js 而非 busuanzi.pure.min.js,是因为前者会将超过 10000 的数字自动格式化为 1.2w 这种紧凑形式,视觉更友好。如果你想保留完整原始数字,可改成 busuanzi.pure.min.js

步骤 2:部署展示标签

不蒜子支持 3 种官方标签,按需部署即可:

标签 ID 含义 使用场景
busuanzi_site_pv 站点总 PV(所有页面浏览累计) 页脚
busuanzi_site_uv 站点总 UV(每日独立访客去重) 页脚
busuanzi_page_pv 当前页 PV(单篇文章浏览累计) 文章页底部

2.1 页脚组件(站点总 PV/UV)

Vue48 行
<!-- .vitepress/theme/components/SiteFooter.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const mounted = ref(false)
onMounted(() => {
  mounted.value = true
})
</script>

<template>
  <footer class="site-footer">
    <!-- 不蒜子 PV/UV 展示(v-if="mounted" 修复 Hydration Mismatch) -->
    <div v-if="mounted" class="footer-stats">
      <span class="stat-item">
        本站总访问量
        <span id="busuanzi_site_pv" class="stat-num">0</span> 次
      </span>
      <span class="separator">·</span>
      <span class="stat-item">
        访客数
        <span id="busuanzi_site_uv" class="stat-num">0</span> 人
      </span>
    </div>

    <p class="copyright">© {{ new Date().getFullYear() }} FilePress Blog</p>
  </footer>
</template>

<style scoped>
.footer-stats {
  display: flex;
  justify-content: center;
  gap: 0.5rem;
  font-size: 0.875rem;
  color: var(--vp-c-text-3);
  margin-bottom: 0.5rem;
}
.stat-num {
  font-weight: 600;
  color: var(--vp-c-brand-1);
  font-variant-numeric: tabular-nums;
}
.separator {
  color: var(--vp-c-divider);
}
</style>

2.2 文章页组件(页面 PV)

Vue35 行
<!-- .vitepress/theme/components/PostPage.vue -->
<script setup lang="ts">
import { onMounted, ref } from 'vue'

const mounted = ref(false)
onMounted(() => {
  mounted.value = true
})
</script>

<template>
  <article class="post-content">
    <!-- ... 文章正文渲染 ... -->

    <div class="post-meta">
      <span v-if="mounted" class="view-count">
        👁️ 本文已被阅读
        <span id="busuanzi_page_pv" class="view-num">0</span> 次
      </span>
    </div>
  </article>
</template>

<style scoped>
.view-count {
  font-size: 0.875rem;
  color: var(--vp-c-text-3);
}
.view-num {
  font-weight: 600;
  color: var(--vp-c-brand-1);
  font-variant-numeric: tabular-nums;
}
</style>

步骤 3:修复 Hydration Mismatch(关键!)

如果直接照上面的写法运行,控制台会立刻报:

TEXT
[Vue warn]: Hydration completed but contains mismatches.

这是因为 VitePress 在构建期(SSR)会渲染 <span id="busuanzi_site_pv">0</span>,但用户访问时(CSR)不蒜子脚本已经异步回填了真实数字(如 12345),二者不一致就触发了 Vue 的水合校验失败。

解决方案:用 v-if="mounted" 让标签只在客户端挂载后才渲染

Vue
<span v-if="mounted" id="busuanzi_site_pv">0</span>

其原理是:

  1. SSR 阶段mountedfalse<span> 不出现在 HTML 中,SSR 输出为空
  2. 客户端水合onMounted 钩子把 mounted 置为 true,触发 <span> 渲染
  3. 不蒜子脚本执行(此时 span 已存在):脚本找到 busuanzi_site_pv 标签,回填真实数字

至此整个流程就完整了。


三、本地降级方案:让统计"永远有数"

不蒜子虽然稳定,但毕竟是第三方服务,它挂了/被墙/网速慢时数字会一直显示为 0,体验不佳。一个生产级博客必须考虑降级方案。

FilePress Blog v2.6 的策略是:主用不蒜子,本地 localStorage 兜底。实现要点:

TS53 行
// .vitepress/theme/utils/viewCount.ts

const STORAGE_KEY = 'fpb:article-view-counts:v1'
const SESSION_KEY = 'fpb:article-session:v1'

/** 检测不蒜子是否已就绪 */
export function isBusuanziLoaded(): boolean {
  if (typeof window === 'undefined') return false
  return typeof (window as any).busuanzi !== 'undefined'
}

/** 等待不蒜子脚本执行(最多 2s) */
export function waitBusuanzi(timeout = 2000): Promise<boolean> {
  return new Promise(resolve => {
    if (isBusuanziLoaded()) return resolve(true)
    const timer = setTimeout(() => resolve(false), timeout)
    const interval = setInterval(() => {
      if (isBusuanziLoaded()) {
        clearTimeout(timer)
        clearInterval(interval)
        resolve(true)
      }
    }, 100)
  })
}

/** 文章页 PV 的兜底计数 */
export function getFallbackViewCount(slug: string): number {
  if (typeof localStorage === 'undefined') return 0
  try {
    const data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
    return data[slug] || 0
  } catch {
    return 0
  }
}

export function incrementFallbackViewCount(slug: string): void {
  if (typeof localStorage === 'undefined') return
  // sessionStorage 去重:同一会话只计 1 次
  const sessionHits = JSON.parse(
    sessionStorage.getItem(SESSION_KEY) || '[]'
  ) as string[]
  if (sessionHits.includes(slug)) return
  sessionHits.push(slug)
  sessionStorage.setItem(SESSION_KEY, JSON.stringify(sessionHits))

  // localStorage 累加
  const data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
  data[slug] = (data[slug] || 0) + 1
  localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
}

使用方式(在文章页组件中):

TS
onMounted(async () => {
  mounted.value = true

  // 等待不蒜子,超时则降级
  const loaded = await waitBusuanzi()
  if (!loaded) {
    // 2s 后还不蒜子没就绪,使用本地兜底
    incrementFallbackViewCount(slug)
  }
})

关键点

  • sessionStorage 用于同会话去重:用户刷新页面不重复计数
  • localStorage 用于持久化累计:关闭浏览器后数字仍保留
  • waitBusuanzi 设置 2s 超时:避免无意义的长时间等待

四、样式定制:让数字更专业

不蒜子默认只回填一个纯文本数字,视觉上比较朴素。我们可以用 3 种方式让它更精致:

4.1 使用 Font Awesome 图标增强

不蒜子官方提供了一组 Font Awesome 图标别名(需自行引入 Font Awesome):

HTML
<!-- 引入 Font Awesome -->
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6/css/all.min.css">

<!-- 配合图标使用 -->
<span id="busuanzi_site_pv">
  <i class="fa-solid fa-eye"></i> 0
</span>

4.2 数字滚动动画(进阶)

想让数字从 0 滚动到目标值?可以用 requestAnimationFrame 配合 busuanzi 的 DOM 回填做一个增强:

TS
function animateNumber(element: HTMLElement, target: number, duration = 800) {
  const start = 0
  const startTime = performance.now()

  function step(now: number) {
    const elapsed = now - startTime
    const progress = Math.min(elapsed / duration, 1)
    const eased = 1 - Math.pow(1 - progress, 3) // ease-out-cubic
    const current = Math.round(start + (target - start) * eased)
    element.textContent = current.toLocaleString()
    if (progress < 1) requestAnimationFrame(step)
  }
  requestAnimationFrame(step)
}

4.3 暗色模式适配

VitePress 自带暗色模式,用 CSS 变量就能自动适配:

CSS
.stat-num {
  color: var(--vp-c-brand-1);
  /* 暗色模式下 brand-1 会自动变浅,无需手动切换 */
}

五、SEO 副作用与对策

不蒜子默认会把用户的真实 IP上报到云端,部分用户可能对此敏感。如果你的读者群注重隐私,可以考虑:

  1. 合规声明:在页脚或隐私政策页面注明"本站使用不蒜子统计访问量"
  2. 可选降级:提供按钮让用户"不参与统计"(进阶功能,需修改不蒜子源码)
  3. 替代方案:如果不蒜子不够用,可以改用 Plausible / Umami(同样无需后端,但需要自部署)

FilePress Blog 的选择:v2.6 主用不蒜子,不展示具体访客来源信息,只显示聚合数字,符合国内用户的隐私期待。


六、性能影响实测

不蒜子脚本对站点性能有多大影响?我用 Lighthouse 在本地预览模式跑了一组数据:

指标 集成前 集成后 变化
LCP(最大内容绘制) 1.2s 1.25s +0.05s
TTI(可交互时间) 1.5s 1.55s +0.05s
Total Blocking Time 30ms 35ms +5ms
整体脚本体积 180KB 184KB +4KB

结论:性能影响几乎可以忽略,<script async> 加载不会阻塞渲染。

进一步优化建议

  • busuanzi.abbr.min.js 下载到 public/ 目录自托管(不推荐,因为会失去 CDN 加速)
  • <link rel="preconnect" href="//cdn.busuanzi.cc"> 预连接 CDN 域名

七、常见问题排查

Q1:数字一直显示 0?

排查路径

  1. 打开 DevTools → Network → 搜索 busuanzi,确认脚本已加载
  2. 检查 <span id="busuanzi_site_pv"> 是否存在(Elements 面板)
  3. 控制台执行 busuanzi 是否返回对象(非 undefined
  4. 检查是否被广告拦截插件(如 uBlock Origin)屏蔽

Q2:控制台报 Hydration Mismatch?

→ 确认 <span> 用了 v-if="mounted" 包裹,否则必现。

Q3:刷新页面后数字一直累加?

→ 这是不蒜子的正常行为。PV 是"页面浏览量",每次刷新都计 1 次。UV 才去重。

Q4:能否同时显示站点 UV 和站点 PV?

→ 可以,但需要在页脚部署两个独立 <span>

HTML
<span id="busuanzi_site_pv"></span> 次浏览 ·
<span id="busuanzi_site_uv"></span> 位访客

Q5:能否重置计数?

→ 官方不提供清零接口。如需重置,请联系不蒜子官方(busuanzi.cc 底部有联系方式)。


八、与其他统计方案的对比

方案 数据准确性 隐私合规 国内速度 配置成本 体积
不蒜子 中(聚合) ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 4KB
Google Analytics ⭐⭐⭐⭐ 45KB
百度统计 ⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 30KB
Umami(自部署) ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐ 自托管
Plausible ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ 1KB

FilePress Blog 的选择:v2.6 选用不蒜子,平衡了隐私合规、国内速度与配置成本


九、写在最后

不蒜子不是一个完美的统计工具,但它在零成本、零部署、隐私合规、国内可用这几个维度上做到了近乎极致的平衡。对于一个追求"纯静态、零后端"的 VitePress 博客来说,它几乎是不二之选。

通过本文你学到了:

  • ✅ 不蒜子的工作原理(脚本 + 标签 ID)
  • ✅ 三步集成法(head 注入 → 标签部署 → Hydration 修复)
  • ✅ Hydration Mismatch 的根因与解决方案
  • ✅ localStorage 降级方案保证数字永远有数
  • ✅ 样式定制与性能影响实测

集成完成后,访问你的博客就能看到底部实时滚动的数字了 🎉 这是一种奇妙的反馈——你的每一篇文章,正在被真实的人阅读着


参考资料

版权声明 · CC BY-NC-ND 4.0

署名-非商业性使用-禁止演绎 4.0 国际

版权归属

本作品著作权归 窦长友 所有, 首次发布于 ,受相关知识产权法律法规保护。

授权范围
  • 可自由分享 — 在任何媒介以任何形式复制、转载本文
  • 不得用于商业目的 — 未经书面授权禁止商用
  • 禁止演绎修改 — 不得改编、转换或以本文为基础再创作
署名要求

转载或引用时须明确标注作者姓名原文出处及本许可协议链接。 不得以任何方式暗示或声称作者为您的使用背书。

评论

由 GitHub Discussions 驱动