主题
Vitepress 添加不蒜子统计
不蒜子 是一个极简的网页计数器,支持统计全站访客和访问量,支持单个网页访问量,免费稳定。先上定制效果图:
1. 安装插件
sh
pnpm add -D busuanzi.pure.js
2. 调用统计
要触发不蒜子统计,需要我们在项目路由切换后,手动调用它的方法,从而向不蒜子后台发送请求,增加计数。所以我们修改主题配置 .vitepress/theme/index.ts
ts
import DefaultTheme from 'vitepress/theme'
import { inBrowser } from 'vitepress'
import busuanzi from 'busuanzi.pure.js'
export default {
extends: DefaultTheme,
enhanceApp({ app , router }) {
if (inBrowser) {
router.onAfterRouteChanged = () => {
busuanzi.fetch()
}
}
},
}
3. 显示统计量
按不蒜子的使用说明,只要页面中出现它配套的 id,js 就会自动填充数字到对应id的元素中。
ID | 说明 |
---|---|
busuanzi_value_site_pv | 全站访问量 |
busuanzi_value_site_uv | 全站访客量 |
busuanzi_value_page_pv | 单个网页访问量 |
简单示例,页面中有如下内容即可
html
本站总访问量 <span id="busuanzi_value_site_pv" />
本站访客数 <span id="busuanzi_value_site_uv" />
4. 定制统计组件
以上使用简单,但是不好看,没有什么设计可言。这里我们设计一个统计卡片,数字可以跳动,有逐渐增长的进度条;并且我们将统计数据存储在 sessionStorage
中,当切换页面后,数据有增长时,数字还会跳动,进度条也会增长。
直接贴代码吧,注意:
- 其中
scss
中的一些变量可自行替换,或者在我的网站中查找 - 使用
setTimeout
是防止页面加载未完成时,不蒜子脚本已执行成功,从而无法获取统计数据 WStatistics.vue
组件最终要注册到主题配置.vitepress/theme/index.ts
中去,然后再其他地方使用
vue
<template>
<div class="statistics">
<div class="title-wrapper">
<div class="title">
<span>访问统计</span>
<span class="title-hover">访问统计</span>
</div>
<i class="weiz-icon weiz-icon-chart-line main"></i>
</div>
<div class="statistics-main">
<div class="statistics-wrapper">
<span class="statistics-title">总访问量</span>
<span class="statistics-pv" id="pv">{{ pv }}</span>
</div>
<div class="chart pv-wrapper">
<div class="pv-num" id="pvProgress" style="width: 70%"></div>
</div>
<div class="statistics-wrapper">
<span class="statistics-title">独立访客</span>
<span class="statistics-uv" id="uv">{{ uv }}</span>
</div>
<div class="chart uv-wrapper">
<div class="uv-num" id="uvProgress" style="width: 45%"></div>
</div>
</div>
<span id="busuanzi_value_site_pv" style="display: none" />
<span id="busuanzi_value_site_uv" style="display: none" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { getSessionStorage, setSessionStorage, numberWithCommas } from '../../utils/tools'
let sessionPv = getSessionStorage('pv')
let sessionUv = getSessionStorage('uv')
const pv = ref<string|number>(sessionPv ? numberWithCommas(parseInt(sessionPv)) : 'loading')
const uv = ref<string|number>(sessionUv ? numberWithCommas(parseInt(sessionUv)) : 'loading')
let timeoutPV = 0
const getPV = () => {
if (timeoutPV) clearTimeout(timeoutPV)
timeoutPV = window.setTimeout(() => {
const $PV = document.querySelector('#busuanzi_value_site_pv')
const text = $PV?.innerHTML
if ($PV && text) {
const start = getSessionStorage('pv') || '1000'
pv.value = numberWithCommas(parseInt(text))
setSessionStorage('pv', text)
// 调用封装的函数
animateNumberAndProgressBar({
counterSelector: '#pv',
fillBarSelector: '#pvProgress',
start: parseFloat(start),
end: parseInt(text),
totalDuration: 2000,
minPercentage: 5,
targetPercentage: 75
});
} else {
getPV()
}
}, 500)
}
let timeoutUV = 0
const getUV = () => {
if (timeoutUV) clearTimeout(timeoutUV)
timeoutUV = window.setTimeout(() => {
const $UV = document.querySelector('#busuanzi_value_site_uv')
const text = $UV?.innerHTML
if ($UV && text) {
const text = $UV.innerHTML
const start = getSessionStorage('uv') || '1000'
uv.value = numberWithCommas(parseInt(text))
setSessionStorage('uv', text)
// 调用封装的函数
animateNumberAndProgressBar({
counterSelector: '#uv',
fillBarSelector: '#uvProgress',
start: parseFloat(start),
end: parseInt(text),
totalDuration: 2000,
minPercentage: 5,
targetPercentage: 50
});
} else {
getUV()
}
}, 500)
}
// 统计数字动画
const animateNumberAndProgressBar = ({
counterSelector,
fillBarSelector,
start = 0,
end,
totalDuration = 2000,
minPercentage = 5,
targetPercentage = 75
}) => {
// 如果开始和结束的数字相同,直接返回
if (start == end) {
return
}
// 调整进度条起始位置,要基本符合进度条的长度
const maxNum = (end * 100) / targetPercentage
let startPercentage = (start / maxNum) * 100
const counterElement = document.querySelector(counterSelector)
const fillBarElement = document.querySelector(fillBarSelector)
let startTime = null
const totalSteps = end - start
function animateCounter(timestamp) {
if (!startTime) startTime = timestamp
const elapsed = timestamp - (startTime ?? timestamp)
const progress = Math.min(elapsed / totalDuration, 1)
const currentNumber = Math.floor(start + progress * totalSteps)
let stepPercentage = progress * (targetPercentage - startPercentage)
// 保证肉眼能看到至少5%的变化
if (targetPercentage - startPercentage < minPercentage) {
stepPercentage = progress * minPercentage
startPercentage = targetPercentage - minPercentage
}
const currentProgress = startPercentage + stepPercentage
counterElement.textContent = numberWithCommas(currentNumber)
fillBarElement.style.width = currentProgress + '%'
if (fillBarElement.style.display !== 'block') {
fillBarElement.style.display = 'block'
}
if (progress < 1) {
requestAnimationFrame(animateCounter)
}
}
fillBarElement.style.width = startPercentage + '%'
fillBarElement.style.display = 'block'
requestAnimationFrame(animateCounter)
}
onMounted(() => {
getUV()
getPV()
})
</script>
<style lang="scss" scoped>
.statistics {
width: 100%;
display: inline-block;
border-radius: var(--weiz-card-border-radius);
background-color: var(--vp-c-bg);
color: var(--vp-c-text-1);
font-weight: var(--weiz-font-weight-medium);
padding: var(--weiz-spacing-6xl);
box-shadow: var(--weiz-shadow);
transition: all cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.6s;
&:hover {
color: var(--vp-c-text-1);
transform: scale(1.03);
box-shadow: var(--weiz-shadow-hover);
.title .title-hover {
width: 100%;
}
}
.title-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--weiz-spacing-8xl);
}
.title {
font-size: var(--weiz-font-size-st);
line-height: var(--weiz-text-st-line-height);
font-weight: var(--weiz-font-weight-semibold);
white-space: nowrap;
position: relative;
overflow: hidden;
.title-hover {
position: absolute;
left: 0;
top: 0;
width: 0;
overflow: hidden;
background-color: var(--vp-c-bg);
color: var(--weiz-primary-color);
transition: width 0.4s ease-in-out;
}
}
.statistics-wrapper {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--weiz-spacing-2xl);
}
.chart {
height: calc(var(--weiz-spacing) * 2);
border-radius: calc(var(--weiz-spacing) * 2);
background-color: var(--vp-c-gray-3);
> div {
height: 100%;
border-radius: calc(var(--weiz-spacing) * 2);
background-color: var(--weiz-primary-color);
}
}
.pv-wrapper {
margin-bottom: var(--weiz-spacing-4xl);
}
.statistics-title {
& + span {
font-size: var(--weiz-font-size-st);
line-height: var(--weiz-text-st-line-height);
font-weight: var(--weiz-font-weight-semibold);
}
}
}
</style>
ts
/**
* 读取sessionStorage
*/
export const getSessionStorage = (key: string) => {
return sessionStorage.getItem(key)
}
/**
* 设置sessionStorage
*/
export const setSessionStorage = (key: string, value: string) => {
sessionStorage.setItem(key, value)
}
/**
* 将数字转化为千分位按照逗号,分割
*/
export const numberWithCommas = (num: number) => {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}
切换路由的效果展示
5. 单个网页统计
单个网页只显示访问量太单薄,因此我们还增加了额外信息,如文章创建更新时间,文章字数等
直接贴代码,注意事项同上
vue
<template>
<div class="weiz-title-meta">
<div class="tags">
<div class="created" title="发表于">
<i class="weiz-icon weiz-icon-created gray" />
<span>发表于 {{ firstCommit }}</span>
</div>
<div class="updated" title="更新于">
<i class="weiz-icon weiz-icon-updated gray" />
<span>更新于 {{ lastUpdated }}</span>
</div>
<div class="word" title="字数">
<i class="weiz-icon weiz-icon-word gray" />
<span>字数 {{ wordCount }}</span>
</div>
<div class="reader" title="阅读量">
<i class="weiz-icon weiz-icon-user gray"></i>
<span>阅读量 {{ pv }}<span id="busuanzi_value_page_pv" style="display: none" /></span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
import { ref, onMounted } from 'vue'
import { countWord, countTransK, formatDate } from '../../utils/tools'
const { frontmatter, page } = useData()
const wordCount = ref('')
const firstCommit = ref('')
const lastUpdated = ref('')
const pv = ref('')
let timeoutPV = 0
const getPV = () => {
if (timeoutPV) clearTimeout(timeoutPV)
timeoutPV = window.setTimeout(() => {
const $PV = document.querySelector('#busuanzi_value_page_pv')
const text = $PV?.innerHTML
if ($PV && text) {
pv.value = countTransK(parseInt(text))
} else {
getPV()
}
}, 500)
}
onMounted(() => {
const dateOption = formatDate()
firstCommit.value = dateOption.format(new Date(frontmatter.value.firstCommit!)).replace(/\//g, '-')
lastUpdated.value = dateOption.format(new Date(frontmatter.value.lastUpdated || page.value.lastUpdated!)).replace(/\//g, '-')
const docDomContainer = window.document.querySelector('#VPContent')
const words = docDomContainer?.querySelector('.content-container .main')?.textContent || ''
wordCount.value = countTransK(countWord(words))
getPV()
})
</script>
<style lang="scss" scoped>
.weiz-title-meta {
.tags {
display: flex;
flex-wrap: wrap;
margin: 0 0 32px;
color: var(--vp-c-text-2);
font-weight: 500;
line-height: 18px;
word-break: keep-all;
> div {
display: flex;
align-items: center;
margin-top: 16px;
margin-right: 6px;
&:last-child {
margin-right: 0;
}
}
}
.weiz-icon {
margin-right: 2px;
}
}
@media (min-width: 768px) {
.weiz-title-meta .tags > div {
margin-right: 16px;
}
}
</style>
ts
/**
* 文字统计
* @param data 字符串
* @returns 字符串长度
*/
export const countWord = (data: string) => {
const pattern =
/[a-zA-Z0-9_\u0392-\u03C9\u00C0-\u00FF\u0600-\u06FF\u0400-\u04FF]+|[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF\u3040-\u309F\uAC00-\uD7AF]+/g
const m = data.match(pattern)
let count = 0
if (!m) {
return count
}
for (let i = 0; i < m.length; i += 1) {
if (m[i].charCodeAt(0) >= 0x4e00) {
count += m[i].length
} else {
count += 1
}
}
return count
}
/**
* 数字千分位转换 1500 -> 1.5k,1500000 -> 1.5M
* @param count
* @returns
*/
export const countTransK = (count: number) => {
if (count >= 1000000) {
return (count / 1000000).toFixed(1) + 'M'
}
if (count >= 1000) {
return (count / 1000).toFixed(1) + 'K'
}
return count.toString()
}
/**
* 日期格式化程序
* @param hasTime 是否包含时间
* @returns
*/
export const formatDate = (hasTime?: boolean) => {
let formatOption = {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}
if (hasTime) {
Object.assign(formatOption, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
return new Intl.DateTimeFormat('zh', formatOption as Intl.DateTimeFormatOptions)
}
ts
import DefaultTheme from 'vitepress/theme'
import WDocTitleMeta from './components/WDocTitleMeta.vue' //文章顶部
export default {
extends: DefaultTheme,
enhanceApp({ app }) {
// 注册自定义全局组件
app.component('weiz-title-meta', WDocTitleMeta)
}
}
WDocTitleMeta.vue
组件需要注册到配置中心后,怎么插入到单个文章页面中去呢。这就需要用到 Vitepress的高级配置,我们在 Markdown 渲染器
里进行拦截,监听到有 h1
标签时,将此组件插入在 h1
后面
ts
import { defineConfig } from 'vitepress'
export default defineConfig({
//markdown配置
markdown: {
// 对markdown中的内容进行替换或者批量处理
config: (md) => {
// 创建 markdown-it 插件
md.use((md) => {
// 组件插入h1标题下
md.renderer.rules.heading_close = (tokens, idx, options, env, slf) => {
let htmlResult = slf.renderToken(tokens, idx, options)
if (tokens[idx].tag === 'h1') htmlResult += `<weiz-title-meta />`
return htmlResult
}
})
}
}
})