1612 字
8 分钟
为 Fuwari 博客添加 Umami 访问量统计
TIP

你可以直接将这篇文章丢给你的ai Agent,让它帮你实现。

为 Fuwari 博客添加 Umami 访问量统计#

对于静态博客而言,了解访客来源与流量趋势是很有必要的。在不自建服务器的前提下,配置简单、功能强大的 Umami 往往是我们的首选。

然而,直接挂载 Umami 的分享外链不够直观且破坏页面一致性。本文将教你如何将 Umami 的统计数据以原生组件的形式集成到博客中,实现以下效果:

  • 侧边栏显示总浏览量访问数
  • 每篇文章卡片显示该文章的独立浏览量
  • 数据实时获取,支持数字动画

前置条件#

  • 一个已部署好的 Fuwari 博客
  • 启用 Umami 统计的站点
  • 获取 Umami 的分享链接

获取 Umami 分享链接#

在 Umami 后台启用”分享 URL”,你应该会得到一个类似 https://umami.example.com/share/OSwi3PpuZgZ2Io3a 格式的链接。记下这个链接,稍后配置需要用到。

步骤一:添加 Umami 跟踪脚本#

首先需要在博客的 <head> 中注入 Umami 的跟踪脚本,这样才能记录访问数据。

src/layouts/Layout.astro<head> 区域添加:

<script defer src="https://example.com/script.js" data-website-id="你的website-id"></script>

提示:这在Umami的后台配置中可以找到。

步骤二:创建侧边栏统计组件#

src/components/widget/ 目录下创建 UmamiStats.astro

---
import WidgetLayout from "./WidgetLayout.astro";
interface Props {
class?: string;
style?: string;
}
const { class: className, style } = Astro.props;
---
<WidgetLayout name="统计" id="umami-stats" class:list={[className, "cursor-pointer transition-opacity active:scale-95"]} {style}>
<a id="umami-link" target="_blank" rel="noopener noreferrer" class="block">
<div class="grid grid-cols-2 divide-x divide-neutral-200 dark:divide-neutral-700 text-center py-2">
<div class="px-2">
<div class="text-2xl font-bold text-neutral-900 dark:text-neutral-100" id="total-pageviews">-</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400">总浏览量</div>
</div>
<div class="px-2">
<div class="text-2xl font-bold text-neutral-900 dark:text-neutral-100" id="total-visits">-</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400">访问数</div>
</div>
</div>
</a>
</WidgetLayout>
<script>
const UMAMI_CONFIG = {
apiUrl: 'https://umami.flygeon.eu.org/api',
shareId: 'OSwi3PpuZgZ2Io3a',
};
let __UMAMI_INTERNAL = {
websiteId: '',
shareToken: '',
isReady: false
};
const FALLBACK_STATS = {
pageviews: 1000,
visits: 1000,
};
async function initUmamiConfig() {
try {
const res = await fetch(`${UMAMI_CONFIG.apiUrl}/share/${UMAMI_CONFIG.shareId}`);
const data = await res.json();
__UMAMI_INTERNAL = {
websiteId: data.websiteId,
shareToken: data.token,
isReady: true
};
const link = document.getElementById('umami-link');
if (link) link.setAttribute('href', `https://umami.flygeon.eu.org/share/${UMAMI_CONFIG.shareId}`);
} catch (e) {
console.error('Umami Config Init Failed:', e);
}
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return Math.round(num).toString();
}
function setStats(values: { pageviews: number; visits: number }) {
const pageviewsElement = document.getElementById('total-pageviews');
const visitsElement = document.getElementById('total-visits');
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const animHandles = new Map<HTMLElement, number>();
const animateStat = (el: HTMLElement | null, to: number, duration = 2000) => {
if (!el) return;
const prev = animHandles.get(el);
if (prev) cancelAnimationFrame(prev);
const from = 0;
const startTime = performance.now();
const tick = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(1, elapsed / duration);
const easedProgress = easeOutCubic(progress);
const current = from + (to - from) * easedProgress;
el.textContent = formatNumber(current);
if (progress < 1) {
animHandles.set(el, requestAnimationFrame(tick));
}
};
animHandles.set(el, requestAnimationFrame(tick));
};
animateStat(pageviewsElement, values.pageviews);
animateStat(visitsElement, values.visits);
}
async function fetchUmamiStats() {
if (!__UMAMI_INTERNAL.isReady) {
await initUmamiConfig();
}
if (!__UMAMI_INTERNAL.isReady) {
setStats(FALLBACK_STATS);
return;
}
try {
const endAt = Date.now();
const startAt = 0;
const url = `${UMAMI_CONFIG.apiUrl}/websites/${__UMAMI_INTERNAL.websiteId}/stats?startAt=${startAt}&endAt=${endAt}&unit=hour&timezone=Asia%2FShanghai`;
const response = await fetch(url, {
headers: {
'x-umami-share-token': __UMAMI_INTERNAL.shareToken
}
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
setStats({
pageviews: data.pageviews || 0,
visits: data.visits || 0,
});
} catch (error) {
console.error('Umami Fetch Failed:', error);
setStats(FALLBACK_STATS);
}
}
let __umamiStatsStarted = false;
function startUmamiStats() {
if (__umamiStatsStarted) return;
__umamiStatsStarted = true;
fetchUmamiStats();
}
function initUmamiStatsVisibility() {
const container = document.getElementById('umami-stats');
const io = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
startUmamiStats();
io.disconnect();
}
}, { threshold: 0.1 });
if (container) io.observe(container);
}
initUmamiStatsVisibility();
if (window.swup) {
window.swup.hooks.on('page:view', () => {
__umamiStatsStarted = false;
initUmamiStatsVisibility();
});
}
</script>

记得将 apiUrlshareId 替换为你自己的 Umami 信息,如果直接照抄我的配置,你的网站统计会算在我博客上。

我是自建umami服务器的,如果你使用Umami官方的服务,你的 apiUrl 应该是https://cloud.umami.is/analytics/地区/api

美国的是us,欧洲的是eu

如果你和我一样自建服务器的话,地址则是https://服务器地址/api

步骤三:将统计组件添加到侧边栏#

编辑 src/components/widget/SideBar.astro,导入并使用 UmamiStats 组件:

---
import type { MarkdownHeading } from "astro";
import Categories from "./Categories.astro";
import Profile from "./Profile.astro";
import RouteSwitch from "./RouteSwitch.astro";
import Tag from "./Tags.astro";
import UmamiStats from "./UmamiStats.astro";
interface Props {
class?: string;
headings?: MarkdownHeading[];
}
const className = Astro.props.class;
---
<div id="sidebar" class:list={[className, "w-full"]}>
<div class="flex flex-col w-full gap-4 mb-4">
<Profile></Profile>
</div>
<div id="sidebar-sticky" class="transition-all duration-700 flex flex-col w-full gap-4 top-4 sticky top-4">
<RouteSwitch class="onload-animation" style="animation-delay: 100ms"></RouteSwitch>
<Categories class="onload-animation" style="animation-delay: 150ms"></Categories>
<Tag class="onload-animation" style="animation-delay: 200ms"></Tag>
<UmamiStats class="onload-animation" style="animation-delay: 250ms"></UmamiStats>
</div>
</div>

步骤四:为文章卡片添加独立浏览量#

编辑 src/components/PostCard.astro,在 frontmatter 区域添加文章路径提取:

const postPath = url.replace(/\/$/, "");

在文章卡片的元信息区域(字数和阅读时间旁边)添加浏览量显示:

<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
<div>
{remarkPluginFrontmatter.words} {" " + i18n(remarkPluginFrontmatter.words === 1 ? I18nKey.wordCount : I18nKey.wordsCount)}
</div>
<div>|</div>
<div>
{remarkPluginFrontmatter.minutes} {" " + i18n(remarkPluginFrontmatter.minutes === 1 ? I18nKey.minuteCount : I18nKey.minutesCount)}
</div>
<div>|</div>
<div class="flex items-center gap-1 post-views" data-path={postPath}>
<Icon name="material-symbols:visibility-outline-rounded" class="text-base"></Icon>
<span class="post-views-count">-</span>
</div>
</div>

在文件末尾添加统计脚本:

<script>
const UMAMI_POST_CONFIG = {
apiUrl: 'https://umami.flygeon.eu.org/api',
shareId: 'OSwi3PpuZgZ2Io3a',
};
let __UMAMI_POST_INTERNAL = {
websiteId: '',
shareToken: '',
isReady: false
};
async function initUmamiPostConfig() {
try {
const res = await fetch(`${UMAMI_POST_CONFIG.apiUrl}/share/${UMAMI_POST_CONFIG.shareId}`);
const data = await res.json();
__UMAMI_POST_INTERNAL = {
websiteId: data.websiteId,
shareToken: data.token,
isReady: true
};
} catch (e) {
console.error('Umami Post Config Init Failed:', e);
}
}
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return Math.round(num).toString();
}
async function fetchPostStats(path: string) {
if (!__UMAMI_POST_INTERNAL.isReady) {
await initUmamiPostConfig();
}
if (!__UMAMI_POST_INTERNAL.isReady) {
return null;
}
try {
const endAt = Date.now();
const startAt = 0;
// Umami v3 API: use path=eq. prefix for exact match
const apiUrl = `${UMAMI_POST_CONFIG.apiUrl}/websites/${__UMAMI_POST_INTERNAL.websiteId}/stats?startAt=${startAt}&endAt=${endAt}&unit=hour&timezone=Asia%2FShanghai&path=eq.${encodeURIComponent(path + '/')}`;
const response = await fetch(apiUrl, {
headers: {
'x-umami-share-token': __UMAMI_POST_INTERNAL.shareToken
}
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
return {
pageviews: data.pageviews || 0,
visits: data.visits || 0,
};
} catch (error) {
console.error('Umami Post Fetch Failed:', error);
return null;
}
}
async function initPostViews() {
const viewElements = document.querySelectorAll('.post-views');
if (viewElements.length === 0) return;
for (const el of viewElements) {
const path = el.getAttribute('data-path');
const countEl = el.querySelector('.post-views-count');
if (!path || !countEl) continue;
const stats = await fetchPostStats(path);
if (stats) {
countEl.textContent = formatNumber(stats.pageviews);
} else {
countEl.textContent = '-';
}
}
}
initPostViews();
if (window.swup) {
window.swup.hooks.on('page:view', () => {
initPostViews();
});
}
</script>

自定义选项#

修改统计周期#

默认统计所有时间的数据(startAt = 0)。如需调整时间范围,可修改 startAt 参数:

// 最近24小时
const startAt = Date.now() - 86400000;
// 最近30天
const startAt = Date.now() - 2592000000;
// 最近90天
const startAt = Date.now() - 7776000000;

修改备用数据#

如果 API 请求失败,组件会显示备用数据。可在 FALLBACK_STATS 中修改:

const FALLBACK_STATS = {
pageviews: 1000,
visits: 1000,
};

最终效果#

完成以上步骤后,你将看到:

  1. 侧边栏统计卡片:实时显示博客总浏览量和总访问数,点击可跳转到 Umami 分享页
  2. 文章卡片浏览量:每篇文章显示独立的浏览量数据
  3. 数字动画:数据加载时带有平滑的增长动画
  4. SPA 兼容:使用 Swup 路由时数据会自动刷新

参考文献

为 Fuwari 博客添加 Umami 访问量统计
https://flygeon.eu.org/posts/3/
作者
Flygeon
发布于
2026-05-24
许可协议
CC BY-NC-SA 4.0