1559 字
9 分鐘
請注意,本文編寫於 38 天前,其中某些信息可能已經過時。
📊 實現 Umami 單篇瀏覽量
2026-01-20
瀏覽量 0
Cover image for 📊 實現 Umami 單篇瀏覽量

#前言

這篇就是你現在在博客裡看到的「每篇文章都有自己瀏覽量」的完整實作紀錄,順便當作以後想重構或搬家時的備忘錄。

最終效果有兩個部分:

  1. 文章內頁 Meta 區塊:顯示 「 瀏覽量 X 」
  2. 首頁文章卡片:每張卡片右下角顯示對應文章的 「 瀏覽量 」

底層全部基於 Umami Cloud,再透過 Cloudflare Pages Function 代理 /api/umami,避免第三方追蹤腳本被瀏覽器或廣告阻擋器直接擋掉。


#一、整體架構概念

整體流程可以拆成三層:

  1. 資料來源:Umami Cloud
    • 在 Umami 後台建立站點,拿到 websiteId分享 Token (shareToken)
    • 透過 Umami 的 /websites/{websiteId}/metrics 端點,查詢「路徑級別」的瀏覽數據。
  2. 後端代理:Cloudflare Pages Functions
    • 建立 functions/api/umami/[[path]].ts,將 /api/umami/* 代理到 cloud.umami.is
    • 好處是瀏覽器只看到「同網域請求」,大幅降低被阻擋機率。
  3. 前端渲染:Astro + 原生 JS
    • 文章詳情頁:在 PostMeta.astro 內嵌一段小腳本,針對單一路徑查詢瀏覽量。
    • 首頁卡片:在 PostPage.astro 裡一次打 Umami /metrics,回傳所有路徑,再對應到每張文章卡片。

你現在在畫面上看到的數字,就是這三層配合的結果。


#二、Umami 代理:/api/umami 的設計

首先是 Cloudflare Functions 這一層,對應檔案是:

/**
* Cloudflare Functions - Umami API 反向代理
* ...
*/

這隻函數的關鍵點:

  • 入口路徑:所有打到 /api/umami/* 的請求都會進來這裡。
  • 路由分流
    • websites/* → 轉發到 https://cloud.umami.is/analytics/us/api/websites/*(統計查詢,如 metricsstats
    • script.js → 轉發到 https://cloud.umami.is/script.js(追蹤腳本)
    • api/* → 轉發到 https://cloud.umami.is/api/*(事件上報)
  • 統一 CORS
    • 回傳時加上 Access-Control-Allow-Origin: * 等 header,方便前端直接 fetch

這樣前端只需要打 /api/umami/...,不用關心實際 Umami Cloud 的網域與 CORS 細節。


#三、文章內頁:單篇文章的瀏覽量

文章詳情頁使用的是 src/pages/posts/[...slug].astro,在這個模板裡,我把當前文章的實際路徑傳給 PostMeta

<PostMetadata
published={entry.data.published}
updated={entry.data.updated}
tags={entry.data.tags}
category={entry.data.category}
postPath={Astro.url.pathname}
...
/>

重點是這個 postPath={Astro.url.pathname} —— 它會變成 /posts/xxx/ 這種實際路徑,後面拿來跟 Umami 數據對齊。

#3.1 PostMeta.astro:顯示與更新瀏覽量

src/components/PostMeta.astro 新增了一個 postPath 屬性,並在模板中多渲染一塊「單篇文章瀏覽量」:

const shouldShowPostViews = typeof postPath === "string" && postPath.length > 0;
<!-- 單篇文章瀏覽量 -->
{shouldShowPostViews && (
<div class="flex items-center umami-post-views" data-post-path={postPath}>
<div class="meta-icon">
<Icon name="material-symbols:bar-chart-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium page-views-display">瀏覽量 0</span>
</div>
)}

接著在同一檔案底部塞了一段 inline script,專門負責:

  1. 正規化路徑(確保前後斜線統一)。
  2. 呼叫 Umami /metrics,加上 x-umami-share-token header。
  3. 從回傳資料中找到對應路徑,抓出 pageviews
  4. 把數字寫回 .page-views-display
const UMAMI_CONFIG = Object.freeze({
baseUrl: "/api/umami",
websiteId: "29fdf136-182c-41a4-9d06-9bfab393e177",
shareToken: "..." // Umami 分享 Token
});
const buildMetricsUrl = (path) => {
const params = new URLSearchParams({
type: "path",
startAt: "0",
endAt: String(Date.now()),
timezone: "Asia/Shanghai",
});
return `${UMAMI_CONFIG.baseUrl}/websites/${UMAMI_CONFIG.websiteId}/metrics?${params.toString()}`;
};
const fetchPostViews = async (path) => {
const response = await fetch(buildMetricsUrl(path), {
headers: {
accept: "application/json",
"x-umami-share-token": UMAMI_CONFIG.shareToken,
},
});
// 解析 data / data.data / rows,找到對應 path 的 pageviews
};
const initPostViews = async () => {
const container = document.querySelector(".umami-post-views");
const normalizedPath = normalizePath(postPath || container.dataset.postPath);
const pageviews = await fetchPostViews(normalizedPath);
applyViewsText(container, pageviews ?? 0);
};

同時也掛了 Swup 事件:

const { swup } = window;
if (swup?.hooks?.on) {
swup.hooks.on("page:view", () => {
void initPostViews();
});
}

這樣就算是透過 Swup 無刷新切頁,瀏覽量也會跟著重新抓一次。


#四、首頁文章卡片:批次更新所有文章的瀏覽量

文章列表頁(首頁、分頁)使用的是 PostPage.astro + PostCard.astro,做法跟內頁有點不一樣:

  • 文章內頁:一次只查一篇(單一路徑)。
  • 首頁卡片:一次打 /metrics,拿到所有路徑的統計,最後在前端透過 Map 對應。

#4.1 卡片模板:每張卡片有自己的路徑

PostCard.astro 裡,底部多了一個小按鈕用來顯示瀏覽數:

<div
class="post-preview-views btn-regular ..."
data-post-path={url}
aria-label="文章瀏覽量"
>
<Icon name="material-symbols:bar-chart-rounded" class="text-sm" />
<span class="post-preview-views-text">0</span>
</div>

這裡的 data-post-path={url} 會是 /posts/xxx/,後面統一丟給 Umami。

#4.2 列表腳本:一次請求,全部回填

真正打 API 的是 PostPage.astro 底部那段 inline script:

const UMAMI_CONFIG = Object.freeze({
baseUrl: "/api/umami",
websiteId: "29fdf136-182c-41a4-9d06-9bfab393e177",
shareToken: "..."
});
const buildMetricsUrl = () => {
const params = new URLSearchParams({
type: "path",
startAt: "0",
endAt: String(Date.now()),
timezone: "Asia/Shanghai",
});
return `${UMAMI_CONFIG.baseUrl}/websites/${UMAMI_CONFIG.websiteId}/metrics?${params.toString()}`;
};
const fetchPathViewsMap = async () => {
const response = await fetch(buildMetricsUrl(), {
headers: {
accept: "application/json",
"x-umami-share-token": UMAMI_CONFIG.shareToken,
},
});
const data = await response.json();
const rows = Array.isArray(data) ? data : Array.isArray(data?.data) ? data.data : [];
const viewsMap = new Map();
rows.forEach((row) => {
const rowPath = normalizePath(String(row?.x ?? row?.url ?? row?.pathname ?? row?.path ?? ""));
const value = Number(row?.y ?? row?.value ?? row?.pageviews ?? 0);
if (!rowPath || !Number.isFinite(value)) return;
viewsMap.set(rowPath, Math.max(0, Math.round(value)));
});
return viewsMap;
};
const updatePreviewViews = async () => {
const pathViewsMap = await fetchPathViewsMap();
const nodes = Array.from(document.querySelectorAll(".post-preview-views"));
await Promise.all(
nodes.map(async (node) => {
const textNode = node.querySelector(".post-preview-views-text");
const path = normalizePath(node.dataset.postPath);
const value = pathViewsMap?.get(path) ?? 0;
if (textNode) textNode.textContent = String(value);
}),
);
};

同樣也綁定了 Swup 的 page:view,確保切換分頁後瀏覽量會重新更新。


#五、錯誤處理與降級策略

為了避免 Umami 掛掉時整個 UI 看起來壞掉,我在前端邏輯裡做了幾個保護:

  • 請求失敗 / 回傳結構異常:一律當成 0,頁面會顯示「瀏覽量 0」,不會跳錯或閃爍。
  • 路徑對不起來:透過 normalizePath / normalizeComparablePath 統一移除多餘斜線,盡可能匹配 /posts/foo/posts/foo/ 這種差異。
  • Swup 切頁:所有初始化函數都用「可重入」設計,切頁多次呼叫也不會重複綁定事件或爆錯。

整體來說,只要:

  1. Umami 的 websiteId / share token 正確無誤。
  2. Cloudflare Functions 的 /api/umami 代理部署成功。

那麼不管是首頁卡片還是文章詳情頁,都會自動顯示對應文章的瀏覽量,不需要在 Markdown 裡額外標註任何東西。


#六、如果要搬家或複用這套方案?

如果未來想在其他 Astro / 靜態博客裡複用這組邏輯,可以照這個 checklist 走:

  1. 在 Umami 建站,拿到新的 websiteId 與 share token。
  2. 部署一個對應的 /api/umami 代理(Cloudflare、Vercel edge functions 皆可)。
  3. 在文章模板裡,加上一個類似 postPath={Astro.url.pathname} 的欄位,並渲染一個容器(例如 .umami-post-views)。
  4. 在首頁卡片模板裡,為每張卡片加上 data-post-path,並用一支批次查詢腳本回填數字。

本篇等於是這套「單篇文章瀏覽量系統」的總說明,之後要改 Umami、換追蹤工具,或開新站時,都可以直接拿這篇當操作手冊用。

📊 實現 Umami 單篇瀏覽量
https://illumi.love/posts/指南向/實現-umami-單篇瀏覽量/
作者
𝑰𝒍𝒍𝒖𝒎𝒊糖糖
發布於
2026-01-20
許可協議
🔒CC BY-NC-ND 4.0
分享

如果這篇文章對你有幫助,歡迎分享給更多人!

💬 參與討論
使用 GitHub 帳號登入參與討論