引言
如果你用过WordPress,Halo等动态博客框架,你大概会在用户视角访问博文的时候看到浏览量这个信息。
这个原理很简单,因为动态博客依赖于一个VPS,只需要让用户每次访问的时候给浏览量+1即可。
那么如果我们是静态博客呢?
我们可以依赖一些第三方服务,比如Umami Cloud。在你的静态博客的head注入一个js,这样你就可以看到你的站点分析了,类似下图

现在我们可以看到网站整体的访问量和每个文章(即/posts/xxx)的访问量了,但是我们要如何展示给用户呢?
1. 分析实现过程
因为我采用的是免费方案,
Umami Cloud这个是免费,因此不能新建一个用户,同时博客部署都是采用免费方案。为了稍微安全一点,不建议采用
Umami Cloud账号登录当然如果只是用于测试,那么无所谓啦~

注意这里的 IrVVYJ4qc2J3tc7s ,每个站点都不一样
接着我们请求 https://us.umami.is/api/share/IrVVYJ4qc2J3tc7s,得到token和 websiteId
这个是2025年10月12日修改的,直接采用官网的,之前的方案不能用了
接着我们请求https://cloud.umami.is/analytics/us/api/share/IrVVYJ4qc2J3tc7s,得到token和websiteId
{ "websiteId": "77083547-4c2e-4183-b842-520d9de3d82b", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3ZWJzaXRlSWQiOiI3NzA4MzU0Ny00YzJlLTQxODMtYjg0Mi01MjBkOWRlM2Q4MmIiLCJpYXQiOjE3NTgwMzgyMzh9.7RNrqCaOvgKYFifvpbmtsZR1ykAETgqqOrPgFUpWtng"}这个时候可以通过/api/websites/:websiteId/stats 获取网站统计汇总。

携带请求头 x-umami-share-token 值为上一步获得的Token
2.0:每次访问都需要携带token才能正常使用,虽然访问
https://us.umami.is/api/share/IrVVYJ4qc2J3tc7s中x-umami-share-token是undefined,但是需要数据的请求都是有值的,3.0:现在请求链接:
https://cloud.umami.is/analytics/us/share/i6f3UwPY4n0w1LJa

https://us.umami.is/api/websites/77083547-4c2e-4183-b842-520d9de3d82b/stats?startAt=0&endAt=1757937599999&unit=hour&timezone=Asia/Hong_Kong&url=/posts/cf-fastip/&compare=false
https://cloud.umami.is/analytics/us/api/websites/77083547-4c2e-4183-b842-520d9de3d82b/stats?startAt=0&endAt=1757937599999&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&path=eq.%2Fposts%2F${slug}%2F&compare=false`;这里解释几个关键Params,其他的照搬
- startAt:统计开始时间。Unix时间戳,我们填写为0让Umami从1970年开始统计
- endAt:统计结束时间。Unix时间戳,我们可以使用
Date.now(),即当前时间,和startAt参数联动即可实现统计总浏览量 - url(弃用):要查询的路径,填写为你的文章页去除了Host的路径,如
/posts/hello。注意!Umami会将/posts/hello和/posts/hello/视为两个不同的路径,请注意你的博客框架是否使用/ path=eq.:通过path参数配合eq.前缀(表示 “等于”)筛选路径,这是 Umami 支持的 “高级筛选语法”(类似 SQL 的条件表达式),功能上与url参数等效,但格式更规范(支持更多条件如contains包含、ne不等于等)。
你会得到
// Umami2.0版本{ "pageviews": { "value": 26, "prev": 0 }, "visitors": { "value": 5, "prev": 0 }, "visits": { "value": 9, "prev": 0 }, "bounces": { "value": 3, "prev": 0 }, "totaltime": { "value": 2885, "prev": 0 }}
// Umami3.0版本
{ "pageviews": 125, "visitors": 2, "visits": 4, "bounces": 1, "totaltime": 5713, "comparison": { "pageviews": 0, "visitors": 0, "visits": 0, "bounces": 0, "totaltime": 0 }}2.0版本:pageviews.vlaue 即浏览量。 visits.value 即访问次数。
3.0版本:pageviews 即浏览量。 visits 即访问次数。
Tips:浏览量记录为任意用户只要访问了则计数一次。而访问数记录不会记录单IP多次重复访问和同一时间段的多次请求不同页面
2. 代码展示
src\config.ts中增加UmamiConfig 和 statsConfig
export const umamiConfig: UmamiConfig = { enable: true, baseUrl: "https://us.umami.is", baseUrl: "https://cloud.umami.is", shareId: "IrVVYJ4qc2J3tc7s", timezone: "Asia/Shanghai",};
export const statsConfig = { viewsText: "浏览量", visitsText: "访客", loadingText: "统计加载中...", unavailableText: "统计不可用。", getStatsText: (pageViews: number, visits: number) => `${statsConfig.viewsText} ${pageViews} · ${statsConfig.visitsText} ${visits}`,};src\types\config.ts配置UmamiConfig类别
export type UmamiConfig = { enable: boolean; baseUrl: string; shareId: string; timezone: string;};新增public\js\umami-share.js
((global) => { const cacheKey = "umami-share-cache"; const cacheTTL = 3600_000; // 1h
async function fetchShareData(baseUrl, shareId) { const cached = localStorage.getItem(cacheKey); if (cached) { try { const parsed = JSON.parse(cached); if (Date.now() - parsed.timestamp < cacheTTL) { return parsed.value; } } catch { localStorage.removeItem(cacheKey); } } const res = await fetch(`${baseUrl}/api/share/${shareId}`); const res = await fetch(`${baseUrl}/analytics/us/api/share/${shareId}`); if (!res.ok) { throw new Error("获取 Umami 分享信息失败"); } const data = await res.json(); localStorage.setItem( cacheKey, JSON.stringify({ timestamp: Date.now(), value: data }), ); return data; }
/** * 获取 Umami 分享数据(websiteId、token) * 在缓存 TTL 内复用;并用全局 Promise 避免并发请求 * @param {string} baseUrl * @param {string} shareId * @returns {Promise<{websiteId: string, token: string}>} */ global.getUmamiShareData = (baseUrl, shareId) => { if (!global.__umamiSharePromise) { global.__umamiSharePromise = fetchShareData(baseUrl, shareId).catch( (err) => { delete global.__umamiSharePromise; throw err; }, ); } return global.__umamiSharePromise; };
global.clearUmamiShareCache = () => { localStorage.removeItem(cacheKey); delete global.__umamiSharePromise; };})(window);文章页面src\components\PostMeta.astro增加显示浏览量

import { Icon } from "astro-icon/components";// 导入statsConfig, umamiConfig配置import { statsConfig, umamiConfig } from "../config";import I18nKey from "../i18n/i18nKey";import { i18n } from "../i18n/translation";import { formatDateToYYYYMMDD } from "../utils/date-utils";import { getCategoryUrl, getTagUrl } from "../utils/url-utils";
interface Props { class: string; published: Date; updated?: Date; tags: string[]; category: string | null; hideTagsForMobile?: boolean; hideUpdateDate?: boolean; //当前页面 slug?: string;}const { published, updated, tags, category, hideTagsForMobile = false, hideUpdateDate = false, //当前页面 slug,} = Astro.props;const className = Astro.props.class;
--------------------------------------------------------------------------------------------
<!-- page views / 页面显示 --> {slug && ( <div class="flex items-center"> <div class="meta-icon"> <Icon name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon> </div> <span class="text-50 text-sm font-medium" id="page-views-display">{statsConfig.loadingText}</span> </div> )}</div>
--------------------------------------------------------------------------------------------{slug && ( <script define:vars={{ slug, umamiConfig, unavailableText: statsConfig.unavailableText, viewsText: statsConfig.viewsText, visitsText: statsConfig.visitsText }}> // 客户端统计文案生成函数 function generateStatsText(pageViews, visits) { return `${viewsText} ${pageViews} · ${visitsText} ${visits}`; }
// 获取访问量统计 async function fetchPageViews(isRetry = false) { if (!umamiConfig.enable) { return; }
try { // 调用全局工具获取 Umami 分享数据 const { websiteId, token } = await getUmamiShareData(umamiConfig.baseUrl, umamiConfig.shareId);
// 第二步:获取统计数据 const currentTimestamp = Date.now(); const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&url=%2Fposts%2F${slug}%2F&compare=false`;
const statsUrl = `${umamiConfig.baseUrl}/analytics/us/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&path=eq.%2Fposts%2F${slug}%2F&compare=false`;
const statsResponse = await fetch(statsUrl, { headers: { 'x-umami-share-token': token } });
if (!statsResponse.ok) { if (statsResponse.status === 401 && !isRetry) { clearUmamiShareCache(); return fetchPageViews(true); } throw new Error('获取统计数据失败'); }
const statsData = await statsResponse.json(); const pageViews = statsData.pageviews?.value || 0; const visits = statsData.visits?.value || 0; const pageViews = statsData.pageviews || 0; const visits = statsData.visits || 0;
const displayElement = document.getElementById('page-views-display'); if (displayElement) { displayElement.textContent = generateStatsText(pageViews, visits); } } catch (error) { console.error('Error fetching page views:', error); const displayElement = document.getElementById('page-views-display'); if (displayElement) { displayElement.textContent = unavailableText; } } }
// 页面加载完成后获取统计数据 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fetchPageViews); } else { fetchPageViews(); } </script>)}首页卡片文章src\components\PostCard.astro 增加显示浏览量

import { statsConfig, umamiConfig } from "../config";
--------------------------------------------------------------------------------------------
<!-- word count and read time and 浏览量 --> <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> <span class="text-50 text-sm font-medium" id={`page-views-${entry.slug}`}>{statsConfig.loadingText}</span> </div> </div>
--------------------------------------------------------------------------------------------<script define:vars={{ entry, umamiConfig, unavailableText: statsConfig.unavailableText, viewsText: statsConfig.viewsText, visitsText: statsConfig.visitsText }}> // 客户端统计文案生成函数 function generateStatsText(pageViews, visits) { return `${viewsText} ${pageViews} · ${visitsText} ${visits}`; }
// 获取文章浏览量统计 async function fetchPostCardViews(slug) { if (!umamiConfig.enable) { return; }
try { // 调用全局工具获取 Umami 分享数据 const { websiteId, token } = await getUmamiShareData(umamiConfig.baseUrl, umamiConfig.shareId);
// 第二步:获取统计数据 const currentTimestamp = Date.now(); const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&url=%2Fposts%2F${slug}%2F&compare=false`;
const statsUrl = `${umamiConfig.baseUrl}/analytics/us/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&path=eq.%2Fposts%2F${slug}%2F&compare=false`;
const statsResponse = await fetch(statsUrl, { headers: { 'x-umami-share-token': token } });
if (statsResponse.status === 401) { clearUmamiShareCache(); return await fetchPostCardViews(slug); }
if (!statsResponse.ok) { throw new Error('获取统计数据失败'); }
const statsData = await statsResponse.json(); const pageViews = statsData.pageviews?.value || 0; const visits = statsData.visits?.value || 0;
const pageViews = statsData.pageviews || 0; const visits = statsData.visits || 0; const displayElement = document.getElementById(`page-views-${slug}`); if (displayElement) { displayElement.textContent = generateStatsText(pageViews, visits); } } catch (error) { console.error('Error fetching page views for', slug, ':', error); const displayElement = document.getElementById(`page-views-${slug}`); if (displayElement) { displayElement.textContent = unavailableText; } } }
// 页面加载完成后获取统计数据 function initPostCardStats() { const slug = entry.slug; if (slug) { fetchPostCardViews(slug); } }
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initPostCardStats); } else { initPostCardStats(); }</script><style define:vars={{coverWidth}}></style>显示总的浏览量src\components\widget\Profile.astro

import { profileConfig, statsConfig, umamiConfig } from "../../config";
--------------------------------------------------------------------------------------------
<!-- 全站访问量统计 --> <div class="text-center text-sm text-neutral-500 dark:text-neutral-400 mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700"> <div class="flex items-center justify-center gap-1"> <Icon name="material-symbols:visibility-outline" class="text-base"></Icon> <span id="site-stats">{statsConfig.loadingText}</span> </div> </div>
--------------------------------------------------------------------------------------------
<script define:vars={{ umamiConfig, unavailableText: statsConfig.unavailableText, viewsText: statsConfig.viewsText, visitsText: statsConfig.visitsText }}> // 获取全站访问量统计 // 客户端统计文案生成函数 function generateStatsText(pageViews, visits) { return `${viewsText} ${pageViews} · ${visitsText} ${visits}`; }
async function loadSiteStats() { if (!umamiConfig.enable) { return; }
try { // 调用全局工具获取 Umami 分享数据 const { websiteId, token } = await getUmamiShareData(umamiConfig.baseUrl, umamiConfig.shareId);
// 第二步:获取全站统计数据(不指定url参数获取全站数据) const currentTimestamp = Date.now(); const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&compare=false`;
const statsUrl = `${umamiConfig.baseUrl}/analytics/us/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&compare=false`;
const statsResponse = await fetch(statsUrl, { headers: { 'x-umami-share-token': token } });
if (statsResponse.status === 401) { // token 失效,清理缓存后重新获取一次 clearUmamiShareCache(); return await loadSiteStats(); }
if (!statsResponse.ok) { throw new Error('获取统计数据失败'); }
const statsData = await statsResponse.json(); const pageviews = statsData.pageviews?.value || 0; const visitors = statsData.visits?.value || 0;
const pageviews = statsData.pageviews || 0; const visitors = statsData.visits || 0;
const statsElement = document.getElementById('site-stats'); if (statsElement) { statsElement.textContent = generateStatsText(pageviews, visitors); } } catch (error) { console.error('获取全站统计失败:', error); const statsElement = document.getElementById('site-stats'); if (statsElement) { statsElement.textContent = unavailableText; } } }
// 页面加载完成后获取统计数据 document.addEventListener('DOMContentLoaded', loadSiteStats);</script>3. 代码引用
<!DOCTYPE html><html lang={siteLang} class="bg-[var(--page-bg)] transition text-[14px] md:text-[16px]" data-overlayscrollbars-initialize> <head>
<title>{pageTitle}</title> // .... <script defer src="https://cloud.umami.is/script.js" data-website-id="f11536ad-eac1-434b-a0c7-fc78faa63405"></script> <script src="/js/umami-share.js" defer></script> {favicons.map(favicon => ( <link rel="icon" href={favicon.src.startsWith('/') ? url(favicon.src) : favicon.src} sizes={favicon.sizes} media={favicon.theme && `(prefers-color-scheme: ${favicon.theme})`} /> ))}