在 VitePress 静态博客中集成不蒜子统计:优雅实现站点 PV/UV 计数
详细介绍如何在 VitePress 静态站点中集成不蒜子(busuanzi)统计工具,实现站点总 PV/UV 和单页面 PV 的云端统计。涵盖脚本引入、标签部署、Hydration Mismatch 修复、样式定制与常见问题排查。
引言:为什么选择不蒜子
对于一个静态博客而言,"我有多少人看过?"始终是一个绕不开的问题。不同于 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 步:
1. 浏览器加载你的页面,解析到 <script src="//cdn.busuanzi.cc/.../busuanzi.min.js">
2. 脚本扫描页面上所有以 busuanzi_ 开头的 <span id="..."> 标签
3. 脚本异步向 busuanzi.cc 发起请求,云端累加计数后回填到对应 span 的 innerText
由此可以提炼出几个关键点:
- 脚本是异步加载(
async关键字),不会阻塞首屏渲染 - 标签必须用
id(不是class或data-attr),脚本按 ID 命名约定识别 - 必须有
async:否则首屏渲染会等待统计请求完成 - 回填是 DOM 操作:脚本执行时直接修改
span.innerText,不经过 Vue 响应式系统 → 这就是 SSR 框架 Hydration Mismatch 的根源
二、v2.6 集成三步曲
下面进入实战。FilePress Blog 在 v2.6 完成了不蒜子集成,共分 3 步:注入脚本 → 部署标签 → 修复 Hydration。
步骤 1:注入不蒜子脚本
打开 .vitepress/config.mts,在 head 数组中添加一行:
// .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)
<!-- .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)
<!-- .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(关键!)
如果直接照上面的写法运行,控制台会立刻报:
[Vue warn]: Hydration completed but contains mismatches.
这是因为 VitePress 在构建期(SSR)会渲染 <span id="busuanzi_site_pv">0</span>,但用户访问时(CSR)不蒜子脚本已经异步回填了真实数字(如 12345),二者不一致就触发了 Vue 的水合校验失败。
解决方案:用 v-if="mounted" 让标签只在客户端挂载后才渲染:
<span v-if="mounted" id="busuanzi_site_pv">0</span>
其原理是:
- SSR 阶段:
mounted为false,<span>不出现在 HTML 中,SSR 输出为空 - 客户端水合:
onMounted钩子把mounted置为true,触发<span>渲染 - 不蒜子脚本执行(此时 span 已存在):脚本找到
busuanzi_site_pv标签,回填真实数字
至此整个流程就完整了。
三、本地降级方案:让统计"永远有数"
不蒜子虽然稳定,但毕竟是第三方服务,它挂了/被墙/网速慢时数字会一直显示为 0,体验不佳。一个生产级博客必须考虑降级方案。
FilePress Blog v2.6 的策略是:主用不蒜子,本地 localStorage 兜底。实现要点:
// .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))
}
使用方式(在文章页组件中):
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):
<!-- 引入 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 回填做一个增强:
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 变量就能自动适配:
.stat-num {
color: var(--vp-c-brand-1);
/* 暗色模式下 brand-1 会自动变浅,无需手动切换 */
}
五、SEO 副作用与对策
不蒜子默认会把用户的真实 IP上报到云端,部分用户可能对此敏感。如果你的读者群注重隐私,可以考虑:
- 合规声明:在页脚或隐私政策页面注明"本站使用不蒜子统计访问量"
- 可选降级:提供按钮让用户"不参与统计"(进阶功能,需修改不蒜子源码)
- 替代方案:如果不蒜子不够用,可以改用 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?
排查路径:
- 打开 DevTools → Network → 搜索
busuanzi,确认脚本已加载 - 检查
<span id="busuanzi_site_pv">是否存在(Elements 面板) - 控制台执行
busuanzi是否返回对象(非undefined) - 检查是否被广告拦截插件(如 uBlock Origin)屏蔽
Q2:控制台报 Hydration Mismatch?
→ 确认 <span> 用了 v-if="mounted" 包裹,否则必现。
Q3:刷新页面后数字一直累加?
→ 这是不蒜子的正常行为。PV 是"页面浏览量",每次刷新都计 1 次。UV 才去重。
Q4:能否同时显示站点 UV 和站点 PV?
→ 可以,但需要在页脚部署两个独立 <span>:
<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 驱动