Skip to content

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 中,当切换页面后,数据有增长时,数字还会跳动,进度条也会增长。

直接贴代码吧,注意:

  1. 其中 scss 中的一些变量可自行替换,或者在我的网站中查找
  2. 使用 setTimeout 是防止页面加载未完成时,不蒜子脚本已执行成功,从而无法获取统计数据
  3. 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
        }
      })
    }
  }
})